Skip to content

Commit f7d8c2c

Browse files
committed
Add hazard for new NOT NULL columns requiring backfill
Adding a new NOT NULL column usually needs a backfill. This change adds a hazard and shows a suggested online migration path (add nullable -> backfill -> set NOT NULL).
1 parent 9966185 commit f7d8c2c

10 files changed

Lines changed: 134 additions & 15 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,9 @@ Apply the schema. Any hazards in the generated plan must be approved
152152
pg-schema-diff apply --from-dsn "postgres://postgres:postgres@localhost:5432/postgres" --to-dir schema --allow-hazards INDEX_BUILD
153153
```
154154

155+
# Stateful migrations
156+
- Adding a brand new `NOT NULL` column without a constant `DEFAULT` requires backfilling existing rows. pg-schema-diff will emit the `NEW_NOT_NULL_COLUMN_REQUIRES_BACKFILL` hazard and annotate the plan with an online sequence: add the column as nullable, backfill in batches, then rerun pg-schema-diff to flip to `NOT NULL` via the online CHECK/VALIDATE/SET NOT NULL flow.
157+
155158
# Using Library
156159
Docs to use the library can be found [here](https://pkg.go.dev/github.com/stripe/pg-schema-diff). Check out [the CLI](https://github.com/stripe/pg-schema-diff/tree/main/cmd/pg-schema-diff)
157160
for an example implementation with the library

cmd/pg-schema-diff/apply_cmd_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package main
33
import (
44
"github.com/stripe/pg-schema-diff/internal/pgdump"
55
"github.com/stripe/pg-schema-diff/internal/pgengine"
6+
"github.com/stripe/pg-schema-diff/pkg/diff"
67
)
78

89
func (suite *cmdTestSuite) TestApplyCmd() {
@@ -87,3 +88,21 @@ func (suite *cmdTestSuite) TestApplyCmd() {
8788
})
8889
}
8990
}
91+
92+
func (suite *cmdTestSuite) TestFailIfHazardsNotAllowed() {
93+
plan := diff.Plan{
94+
Statements: []diff.Statement{{
95+
DDL: "ALTER TABLE public.t ADD COLUMN city_name text NOT NULL",
96+
Hazards: []diff.MigrationHazard{{
97+
Type: diff.MigrationHazardTypeNewNotNullColumnRequiresBackfill,
98+
}},
99+
}},
100+
}
101+
102+
err := failIfHazardsNotAllowed(plan, nil)
103+
suite.Error(err)
104+
suite.Contains(err.Error(), string(diff.MigrationHazardTypeNewNotNullColumnRequiresBackfill))
105+
106+
err = failIfHazardsNotAllowed(plan, []string{string(diff.MigrationHazardTypeNewNotNullColumnRequiresBackfill)})
107+
suite.NoError(err)
108+
}

internal/migration_acceptance_tests/acceptance_test.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
stdlog "log"
88
"os"
9+
"strings"
910
"testing"
1011

1112
"github.com/google/uuid"
@@ -62,6 +63,9 @@ type (
6263
//
6364
// If no expectedDBSchemaDDL is specified, the newSchemaDDL will be used
6465
expectedDBSchemaDDL []string
66+
// expectedHazardMessages is a list of substrings that must be found within the set of hazard messages generated
67+
// by the plan.
68+
expectedHazardMessages []string
6569
}
6670
)
6771

@@ -178,6 +182,19 @@ func runTest(t *testing.T, tc acceptanceTestCase) {
178182
}
179183
assert.ElementsMatch(t, tc.expectedHazardTypes, getUniqueHazardTypesFromStatements(plan.Statements), prettySprintPlan(plan))
180184

185+
if len(tc.expectedHazardMessages) > 0 {
186+
var hazardMsgs []string
187+
for _, stmt := range plan.Statements {
188+
for _, hazard := range stmt.Hazards {
189+
hazardMsgs = append(hazardMsgs, hazard.Message)
190+
}
191+
}
192+
joinedHazardMsgs := strings.Join(hazardMsgs, "\n")
193+
for _, expectedHazardMsg := range tc.expectedHazardMessages {
194+
assert.Contains(t, joinedHazardMsgs, expectedHazardMsg, prettySprintPlan(plan))
195+
}
196+
}
197+
181198
// Apply the plan
182199
require.NoError(t, applyPlan(oldDb, plan), prettySprintPlan(plan))
183200

internal/migration_acceptance_tests/column_cases_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@ var columnAcceptanceTestCases = []acceptanceTestCase{
8585
);
8686
`,
8787
},
88+
expectedHazardTypes: []diff.MigrationHazardType{
89+
diff.MigrationHazardTypeNewNotNullColumnRequiresBackfill,
90+
},
8891
},
8992
{
9093
name: "Add one column with serial",

internal/migration_acceptance_tests/database_schema_source_cases_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ var databaseSchemaSourceTestCases = []acceptanceTestCase{
156156

157157
expectedHazardTypes: []diff.MigrationHazardType{
158158
diff.MigrationHazardTypeIndexBuild,
159+
diff.MigrationHazardTypeNewNotNullColumnRequiresBackfill,
159160
},
160161
expectedDBSchemaDDL: []string{
161162
`

internal/migration_acceptance_tests/foreign_key_constraint_cases_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -866,6 +866,7 @@ var foreignKeyConstraintCases = []acceptanceTestCase{
866866

867867
diff.MigrationHazardTypeAcquiresAccessExclusiveLock,
868868
diff.MigrationHazardTypeAcquiresShareRowExclusiveLock,
869+
diff.MigrationHazardTypeNewNotNullColumnRequiresBackfill,
869870
diff.MigrationHazardTypeIndexBuild,
870871
diff.MigrationHazardTypeIndexDropped,
871872
},

internal/migration_acceptance_tests/procedure_cases_test.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,10 @@ var procedureAcceptanceTestCases = []acceptanceTestCase{
9595
$$;
9696
`,
9797
},
98-
expectedHazardTypes: []diff.MigrationHazardType{diff.MigrationHazardTypeHasUntrackableDependencies},
98+
expectedHazardTypes: []diff.MigrationHazardType{
99+
diff.MigrationHazardTypeHasUntrackableDependencies,
100+
diff.MigrationHazardTypeNewNotNullColumnRequiresBackfill,
101+
},
99102
},
100103
{
101104
name: "Drop procedure and its dependencies",

internal/migration_acceptance_tests/table_cases_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,7 @@ var tableAcceptanceTestCases = []acceptanceTestCase{
541541
diff.MigrationHazardTypeAuthzUpdate,
542542
diff.MigrationHazardTypeDeletesData,
543543
diff.MigrationHazardTypeIndexDropped,
544+
diff.MigrationHazardTypeNewNotNullColumnRequiresBackfill,
544545
diff.MigrationHazardTypeIndexBuild,
545546
},
546547
},
@@ -569,6 +570,50 @@ var tableAcceptanceTestCases = []acceptanceTestCase{
569570
diff.MigrationHazardTypeImpactsDatabasePerformance,
570571
},
571572
},
573+
{
574+
name: "Add NOT NULL column without default emits backfill hazard",
575+
oldSchemaDDL: []string{
576+
`
577+
CREATE TABLE t(
578+
id INT
579+
);
580+
`,
581+
},
582+
newSchemaDDL: []string{
583+
`
584+
CREATE TABLE t(
585+
id INT,
586+
city_name VARCHAR(255) NOT NULL
587+
);
588+
`,
589+
},
590+
expectedHazardTypes: []diff.MigrationHazardType{
591+
diff.MigrationHazardTypeNewNotNullColumnRequiresBackfill,
592+
},
593+
expectedHazardMessages: []string{
594+
"backfill",
595+
"Add the column as NULLABLE",
596+
"SET NOT NULL",
597+
},
598+
},
599+
{
600+
name: "Add NOT NULL column with constant default avoids backfill hazard",
601+
oldSchemaDDL: []string{
602+
`
603+
CREATE TABLE t(
604+
id INT
605+
);
606+
`,
607+
},
608+
newSchemaDDL: []string{
609+
`
610+
CREATE TABLE t(
611+
id INT,
612+
city_name VARCHAR(255) NOT NULL DEFAULT ''
613+
);
614+
`,
615+
},
616+
},
572617
}
573618

574619
func TestTableTestCases(t *testing.T) {

pkg/diff/plan.go

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,19 @@ import (
1010
type MigrationHazardType = string
1111

1212
const (
13-
MigrationHazardTypeAcquiresAccessExclusiveLock MigrationHazardType = "ACQUIRES_ACCESS_EXCLUSIVE_LOCK"
14-
MigrationHazardTypeAcquiresShareLock MigrationHazardType = "ACQUIRES_SHARE_LOCK"
15-
MigrationHazardTypeAcquiresShareRowExclusiveLock MigrationHazardType = "ACQUIRES_SHARE_ROW_EXCLUSIVE_LOCK"
16-
MigrationHazardTypeCorrectness MigrationHazardType = "CORRECTNESS"
17-
MigrationHazardTypeDeletesData MigrationHazardType = "DELETES_DATA"
18-
MigrationHazardTypeHasUntrackableDependencies MigrationHazardType = "HAS_UNTRACKABLE_DEPENDENCIES"
19-
MigrationHazardTypeIndexBuild MigrationHazardType = "INDEX_BUILD"
20-
MigrationHazardTypeIndexDropped MigrationHazardType = "INDEX_DROPPED"
21-
MigrationHazardTypeImpactsDatabasePerformance MigrationHazardType = "IMPACTS_DATABASE_PERFORMANCE"
22-
MigrationHazardTypeIsUserGenerated MigrationHazardType = "IS_USER_GENERATED"
23-
MigrationHazardTypeExtensionVersionUpgrade MigrationHazardType = "UPGRADING_EXTENSION_VERSION"
24-
MigrationHazardTypeAuthzUpdate MigrationHazardType = "AUTHZ_UPDATE"
13+
MigrationHazardTypeAcquiresAccessExclusiveLock MigrationHazardType = "ACQUIRES_ACCESS_EXCLUSIVE_LOCK"
14+
MigrationHazardTypeAcquiresShareLock MigrationHazardType = "ACQUIRES_SHARE_LOCK"
15+
MigrationHazardTypeAcquiresShareRowExclusiveLock MigrationHazardType = "ACQUIRES_SHARE_ROW_EXCLUSIVE_LOCK"
16+
MigrationHazardTypeCorrectness MigrationHazardType = "CORRECTNESS"
17+
MigrationHazardTypeDeletesData MigrationHazardType = "DELETES_DATA"
18+
MigrationHazardTypeHasUntrackableDependencies MigrationHazardType = "HAS_UNTRACKABLE_DEPENDENCIES"
19+
MigrationHazardTypeIndexBuild MigrationHazardType = "INDEX_BUILD"
20+
MigrationHazardTypeIndexDropped MigrationHazardType = "INDEX_DROPPED"
21+
MigrationHazardTypeImpactsDatabasePerformance MigrationHazardType = "IMPACTS_DATABASE_PERFORMANCE"
22+
MigrationHazardTypeIsUserGenerated MigrationHazardType = "IS_USER_GENERATED"
23+
MigrationHazardTypeExtensionVersionUpgrade MigrationHazardType = "UPGRADING_EXTENSION_VERSION"
24+
MigrationHazardTypeAuthzUpdate MigrationHazardType = "AUTHZ_UPDATE"
25+
MigrationHazardTypeNewNotNullColumnRequiresBackfill MigrationHazardType = "NEW_NOT_NULL_COLUMN_REQUIRES_BACKFILL"
2526
)
2627

2728
// MigrationHazard represents a hazard that a statement poses to a database

pkg/diff/sql_generator.go

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,14 @@ var (
6666
Type: MigrationHazardTypeExtensionVersionUpgrade,
6767
Message: "This extension's version is being upgraded. Be sure the newer version is backwards compatible with your use case.",
6868
}
69+
migrationHazardNewNotNullColumnRequiresBackfill = MigrationHazard{
70+
Type: MigrationHazardTypeNewNotNullColumnRequiresBackfill,
71+
Message: "Adding a new NOT NULL column without a constant DEFAULT requires a backfill to populate existing rows. " +
72+
"Recommended online sequence:\n" +
73+
" 1) Add the column as NULLABLE (no NOT NULL)\n" +
74+
" 2) Backfill existing rows in batches via application jobs or scripts\n" +
75+
" 3) Re-run pg-schema-diff to enforce NOT NULL using the online CHECK/VALIDATE/SET NOT NULL flow",
76+
}
6977
)
7078

7179
type oldAndNew[S any] struct {
@@ -1204,11 +1212,29 @@ func (csg *columnSQLVertexGenerator) Add(column schema.Column) ([]Statement, err
12041212
if err != nil {
12051213
return nil, fmt.Errorf("building column definition: %w", err)
12061214
}
1207-
return []Statement{{
1215+
stmt := Statement{
12081216
DDL: fmt.Sprintf("%s ADD COLUMN %s", alterTablePrefix(csg.tableName), columnDef),
12091217
Timeout: statementTimeoutDefault,
12101218
LockTimeout: lockTimeoutDefault,
1211-
}}, nil
1219+
}
1220+
if columnRequiresBackfill(column) {
1221+
stmt.Hazards = append(stmt.Hazards, migrationHazardNewNotNullColumnRequiresBackfill)
1222+
}
1223+
return []Statement{stmt}, nil
1224+
}
1225+
1226+
func columnRequiresBackfill(column schema.Column) bool {
1227+
if column.IsNullable {
1228+
return false
1229+
}
1230+
return !columnHasSafeDefault(column)
1231+
}
1232+
1233+
func columnHasSafeDefault(column schema.Column) bool {
1234+
if column.Identity != nil || column.IsGenerated {
1235+
return true
1236+
}
1237+
return len(strings.TrimSpace(column.Default)) > 0
12121238
}
12131239

12141240
func (csg *columnSQLVertexGenerator) Delete(column schema.Column) ([]Statement, error) {

0 commit comments

Comments
 (0)