Skip to content

Commit 192f4eb

Browse files
Keep pgroll state schema in sync with pgroll binary version (#876)
## Background `pgroll` stores its state in the `pgroll` schema in the target database. This schema includes the data and SQL functions that it needs to operate. Including: - the `pgroll.migrations` table: tracks all schema changes applied to all schema in the target database. - various SQL functions like: - `is_active_migration_period` - determines if there is an in-progress migration. - `read_schema` - returns all tables, constraints, indexes etc in the given schema. - `raw_migration` - installed by the event trigger to capture inferred migrations. The `pgroll` schema is created when a user runs `pgroll init`. Internally this runs `init.sql` against the target database. `init.sql` is written to be idempotent; a user can run `pgroll init` multiple times without fear of corrupting `pgroll`'s internal state. When a user upgrades their version of `pgroll` , there is no automated mechanism to ensure that `pgroll init` is re-run. This means that the new version of `pgroll` is not in sync with any changes to `pgroll`'s internal state tables/functions required by that new version. --- This PR keeps the version of `pgroll` and its internal `pgroll` schema in sync: ### The pgroll_version table `pgroll` will track the version number of the `pgroll` binary used to initialize `pgroll`'s internal state in a new `pgroll_version` table: ```sql +-------------+-------------------------------+ | version | initialized_at | |-------------+-------------------------------| | 0.14.0 | 2025-06-09 14:04:23.601366+00 | +-------------+-------------------------------+ ``` The table will contain exactly one row. The table is created by `init.sql` as follows: ```sql -- Table to track pgroll binary version CREATE TABLE IF NOT EXISTS placeholder.pgroll_version ( version text NOT NULL, initialized_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (version) ); ``` When the user runs `pgroll init` the `pgroll_version` table is truncated and the current version of the `pgroll` binary is inserted into the table. ## Automatic upgrades When a user upgrades their version of `pgroll` , `pgroll` will automatically re-initialize its internal state to ensure that there is no version skew between the `pgroll` binary and the version of `pgroll`'s internal state. For example, a user has `pgroll` **0.14.0** installed. The `pgroll_version` table looks ike this ```sql +-------------+-------------------------------+ | version | initialized_at | |-------------+-------------------------------| | 0.14.0 | 2025-06-09 14:04:23.601366+00 | +-------------+-------------------------------+ ``` The user installs `pgroll` **0.15.0.** The first time the user runs a `pgroll` command that requires a connection to the target database (technically whenever a `state.State` instance is constructed), the version stored in the `pgroll_version` table is checked against the current `pgroll` binary version. If the state version is less than the binary version (using a standard semver comparison), then the state is re-initialized and the `pgroll_version` table is updated. ### Disallow downgrades In the case where a user attempts to use a version of `pgroll` that is older than the version stored in the `pgroll_version` table, the `pgroll` command will display an error and ask the user to upgrade their version of `pgroll`. Using an older version of `pgroll` against newer internal state is not supported and a downgrade risks permanent data loss so aborting is the correct option here. ### Edge cases **Missing pgroll_version table** For versions of `pgroll`'s internal state that don’t have a `pgroll_version` table, the version comparison will always report that the internal state version is smaller than the current `pgroll` binary version. This will cause re-initialization and creation of the `pgroll_version` table. **Development versions** The version number is injected into the `pgroll` CLI using linker flags at build time during our CI process. For local builds that don’t perform this version injection, the version is set to the string `“development”`. - A development build of `pgroll` will never cause re-initialization of `pgroll` internal state. - No version of `pgroll` will re-initialize If the version in the `pgroll_version` table is “development”. So `"development"` builds of `pgroll` are isolated from versioned builds in terms of their effects on `pgroll` ’s internal state.
1 parent fcda88a commit 192f4eb

File tree

11 files changed

+306
-10
lines changed

11 files changed

+306
-10
lines changed

cmd/analyze.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ var analyzeCmd = &cobra.Command{
1919
Args: cobra.NoArgs,
2020
RunE: func(cmd *cobra.Command, _ []string) error {
2121
ctx := cmd.Context()
22-
state, err := state.New(ctx, flags.PostgresURL(), flags.StateSchema())
22+
state, err := state.New(ctx, flags.PostgresURL(), flags.StateSchema(), state.WithPgrollVersion(Version))
2323
if err != nil {
2424
return err
2525
}

cmd/root.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ func NewRoll(ctx context.Context) (*roll.Roll, error) {
2525
skipValidation := flags.SkipValidation()
2626
verbose := flags.Verbose()
2727

28-
state, err := state.New(ctx, pgURL, stateSchema)
28+
state, err := state.New(ctx, pgURL, stateSchema, state.WithPgrollVersion(Version))
2929
if err != nil {
3030
return nil, err
3131
}

cmd/status.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ var statusCmd = &cobra.Command{
1818
RunE: func(cmd *cobra.Command, _ []string) error {
1919
ctx := cmd.Context()
2020

21-
state, err := state.New(ctx, flags.PostgresURL(), flags.StateSchema())
21+
state, err := state.New(ctx, flags.PostgresURL(), flags.StateSchema(), state.WithPgrollVersion(Version))
2222
if err != nil {
2323
return err
2424
}

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ require (
1616
github.com/testcontainers/testcontainers-go v0.37.0
1717
github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0
1818
github.com/xataio/pg_query_go/v6 v6.0.0-20250425105130-ed1845ee2d75
19+
golang.org/x/mod v0.25.0
1920
golang.org/x/tools v0.34.0
2021
sigs.k8s.io/yaml v1.4.0
2122
)

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,8 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
257257
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
258258
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
259259
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
260+
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
261+
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
260262
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
261263
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
262264
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=

internal/testutils/util.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,24 @@ func WithStateAndConnectionToContainer(t *testing.T, fn func(*state.State, *sql.
131131
WithStateInSchemaAndConnectionToContainer(t, "pgroll", fn)
132132
}
133133

134+
func WithStateAtVersionAndConnectionToContainer(t *testing.T, version string, fn func(*state.State, string, *sql.DB)) {
135+
t.Helper()
136+
ctx := context.Background()
137+
138+
db, connStr, _ := setupTestDatabase(t)
139+
140+
st, err := state.New(ctx, connStr, "pgroll", state.WithPgrollVersion(version))
141+
if err != nil {
142+
t.Fatal(err)
143+
}
144+
145+
if err := st.Init(ctx); err != nil {
146+
t.Fatal(err)
147+
}
148+
149+
fn(st, connStr, db)
150+
}
151+
134152
func WithUninitializedState(t *testing.T, fn func(*state.State)) {
135153
t.Helper()
136154
ctx := context.Background()

pkg/state/init.sql

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,13 @@ ALTER TABLE placeholder.migrations
4343
ALTER COLUMN created_at SET DATA TYPE timestamptz USING created_at AT TIME ZONE 'UTC',
4444
ALTER COLUMN updated_at SET DATA TYPE timestamptz USING updated_at AT TIME ZONE 'UTC';
4545

46+
-- Table to track pgroll binary version
47+
CREATE TABLE IF NOT EXISTS placeholder.pgroll_version (
48+
version text NOT NULL,
49+
initialized_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
50+
PRIMARY KEY (version)
51+
);
52+
4653
-- Helper functions
4754
-- Are we in the middle of a migration?
4855
CREATE OR REPLACE FUNCTION placeholder.is_active_migration_period (schemaname name)

pkg/state/opts.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package state
4+
5+
type StateOpt func(s *State)
6+
7+
// WithPgrollVersion sets the version of `pgroll` that is constructing the State
8+
// instance
9+
func WithPgrollVersion(version string) StateOpt {
10+
return func(s *State) {
11+
s.pgrollVersion = version
12+
}
13+
}

pkg/state/state.go

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,12 @@ var sqlInit string
2525
const applicationName = "pgroll-state"
2626

2727
type State struct {
28-
pgConn *sql.DB
29-
schema string
28+
pgConn *sql.DB
29+
pgrollVersion string
30+
schema string
3031
}
3132

32-
func New(ctx context.Context, pgURL, stateSchema string) (*State, error) {
33+
func New(ctx context.Context, pgURL, stateSchema string, opts ...StateOpt) (*State, error) {
3334
dsn, err := pq.ParseURL(pgURL)
3435
if err != nil {
3536
dsn = pgURL
@@ -46,10 +47,38 @@ func New(ctx context.Context, pgURL, stateSchema string) (*State, error) {
4647
return nil, err
4748
}
4849

49-
return &State{
50-
pgConn: conn,
51-
schema: stateSchema,
52-
}, nil
50+
st := &State{
51+
pgConn: conn,
52+
pgrollVersion: "development",
53+
schema: stateSchema,
54+
}
55+
56+
// Apply options to the State instance
57+
for _, opt := range opts {
58+
opt(st)
59+
}
60+
61+
// Check version compatibility between the pgroll version and the version of
62+
// the pgroll state schema.
63+
compat, err := st.VersionCompatibility(ctx)
64+
if err != nil {
65+
return nil, err
66+
}
67+
68+
// If the state schema is newer than the pgroll version, return an error
69+
if compat == VersionCompatVersionSchemaNewer {
70+
return nil, ErrNewPgrollSchema
71+
}
72+
73+
// if the state schema is older than the pgroll version, re-initialize the
74+
// state schema
75+
if compat == VersionCompatVersionSchemaOlder {
76+
if err := st.Init(ctx); err != nil {
77+
return nil, err
78+
}
79+
}
80+
81+
return st, nil
5382
}
5483

5584
// Init initializes the required pg_roll schema to store the state
@@ -76,13 +105,30 @@ func (s *State) Init(ctx context.Context) error {
76105
return err
77106
}
78107

108+
// Clear the pgroll_version table
109+
_, err = tx.ExecContext(ctx, fmt.Sprintf("TRUNCATE TABLE %s.pgroll_version",
110+
pq.QuoteIdentifier(s.schema)))
111+
if err != nil {
112+
return err
113+
}
114+
115+
// Insert the version of `pgroll` that is being initialized into the
116+
// pgroll_version table
117+
_, err = tx.ExecContext(ctx, fmt.Sprintf("INSERT INTO %s.pgroll_version (version) VALUES ($1)",
118+
pq.QuoteIdentifier(s.schema)),
119+
s.pgrollVersion)
120+
if err != nil {
121+
return err
122+
}
123+
79124
return tx.Commit()
80125
}
81126

82127
func (s *State) PgConn() *sql.DB {
83128
return s.pgConn
84129
}
85130

131+
// IsInitialized checks if the pgroll state schema is initialized.
86132
func (s *State) IsInitialized(ctx context.Context) (bool, error) {
87133
var isInitialized bool
88134
err := s.pgConn.QueryRowContext(ctx,

pkg/state/state_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1353,6 +1353,85 @@ func TestReadSchema(t *testing.T) {
13531353
})
13541354
}
13551355

1356+
func TestPgrollSchemaVersionUpgrades(t *testing.T) {
1357+
t.Parallel()
1358+
1359+
ctx := context.Background()
1360+
1361+
tests := []struct {
1362+
name string
1363+
initialSchemaVersion string
1364+
pgrollVersion string
1365+
expectedSchemaVersion string
1366+
expectedError error
1367+
}{
1368+
{
1369+
name: "pgroll schema is older than the pgroll version - pgroll schema is updated",
1370+
initialSchemaVersion: "0.13.0",
1371+
pgrollVersion: "0.14.0",
1372+
expectedSchemaVersion: "0.14.0",
1373+
},
1374+
{
1375+
name: "pgroll schema is newer than the pgroll version - state initialization fails",
1376+
initialSchemaVersion: "0.15.0",
1377+
pgrollVersion: "0.14.0",
1378+
expectedError: state.ErrNewPgrollSchema,
1379+
},
1380+
{
1381+
name: "pgroll schema is the same as the pgroll version - pgroll schema is not updated",
1382+
initialSchemaVersion: "0.13.0",
1383+
pgrollVersion: "0.13.0",
1384+
expectedSchemaVersion: "0.13.0",
1385+
},
1386+
{
1387+
name: "development versions of pgroll never cause a pgroll schema update",
1388+
initialSchemaVersion: "0.13.0",
1389+
pgrollVersion: "development",
1390+
expectedSchemaVersion: "0.13.0",
1391+
},
1392+
{
1393+
name: "development versions of the pgroll schema are never upgraded",
1394+
initialSchemaVersion: "development",
1395+
pgrollVersion: "0.13.0",
1396+
expectedSchemaVersion: "development",
1397+
},
1398+
{
1399+
name: "invalid pgroll version - pgroll schema is not updated",
1400+
initialSchemaVersion: "0.14.0",
1401+
pgrollVersion: "banana",
1402+
expectedSchemaVersion: "0.14.0",
1403+
},
1404+
{
1405+
name: "invalid pgroll schema version - pgroll schema is not updated",
1406+
initialSchemaVersion: "banana",
1407+
pgrollVersion: "0.14.0",
1408+
expectedSchemaVersion: "banana",
1409+
},
1410+
}
1411+
1412+
for _, tt := range tests {
1413+
t.Run(tt.name, func(t *testing.T) {
1414+
testutils.WithStateAtVersionAndConnectionToContainer(t, tt.initialSchemaVersion, func(st *state.State, connStr string, _ *sql.DB) {
1415+
// Create a new state instance with the specified pgroll version. This
1416+
// will upgrade the pgroll schema if necessary.
1417+
s, err := state.New(ctx, connStr, "pgroll", state.WithPgrollVersion(tt.pgrollVersion))
1418+
1419+
if tt.expectedError != nil {
1420+
require.ErrorIs(t, err, tt.expectedError)
1421+
} else {
1422+
require.NoError(t, err)
1423+
// Get the version of the pgroll schema
1424+
schemaVersion, err := s.SchemaVersion(ctx)
1425+
require.NoError(t, err)
1426+
1427+
// Ensure the expected pgroll schema version
1428+
require.Equal(t, tt.expectedSchemaVersion, schemaVersion)
1429+
}
1430+
})
1431+
})
1432+
}
1433+
}
1434+
13561435
func clearOIDS(s *schema.Schema) {
13571436
for k := range s.Tables {
13581437
c := s.Tables[k]

0 commit comments

Comments
 (0)