Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 49 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ both servers, and atomically writes `cutover-report.json`. It moves data and
metadata and judges neither: freezing application writes and deciding the copy is
good enough to serve are the operator's, done before it runs.

`pgmigrate sequences` runs that one step on its own, from `follow` onwards, for a
cutover that moves traffic before it moves the database. Sequences are set
absolutely, so it is rerunnable and `cutover` redoes it against the source's final
values. `--sequence-offset` is the room the source keeps: whatever it allocates
beyond that collides with the target.

## Documentation

This README is the documentation. [Design considerations](#design-considerations-why-oh-why)
Expand All @@ -69,14 +75,15 @@ explains why the mechanism is what it is and what each choice cost,
[Limitations](#limitations) what the tool does not do. Test patterns and
environment controls are in [test/README.md](test/README.md).

There are five commands:
There are six commands:

| command | what it does |
|---|---|
| `preflight` | checks whether a migration can succeed, and persists its findings |
| `run` | starts or resumes the migration, and waits in `follow` until cutover completes |
| `status` | reads local state only, so it is safe to run beside `run` |
| `verify` | samples each table against the target and checks what replication wrote |
| `sequences` | advances target sequences alone, so the target can take writes before the cutover |
| `cutover` | performs the rerunnable, durably stepped cutover |

Every command takes `--dir`. All but `status` also need source and target
Expand Down Expand Up @@ -177,9 +184,9 @@ step log trimmed:
"completed_at": "2026-08-11T07:40:10.07482Z",
"end_position": "0/1BE9D08",
"sequences": [
{"schema": "e2e", "name": "order_id_seq", "source_value": 1143, "target_value": 2143, "is_called": true}
{"schema": "e2e", "name": "order_id_seq", "source_value": 1143, "target_value": 1001143, "is_called": true}
],
"configuration": {"sequence_offset": 1000, "values": {"workers": "16"}},
"configuration": {"sequence_offset": 1000000, "values": {"workers": "16"}},
"steps": []
}
```
Expand All @@ -188,9 +195,10 @@ step log trimmed:
version, and one built from a checkout names the commit it came from and whether
that tree was clean. `end_position` is the boundary the target was drained
through, and it is the line between what this migration carried and what it did
not: anything the source wrote after it stayed behind. `sequences` records that `order_id_seq` was left 1000 ahead
of the source, so the application cannot collide with an existing key. `steps` is
the durable log the cutover resumed against.
not: anything the source wrote after it stayed behind. `sequences` records that
`order_id_seq` was left 1,000,000 ahead of the source, so the application cannot
collide with an existing key. `steps` is the durable log the cutover resumed
against.

## Installing pgmigrate

Expand All @@ -216,10 +224,17 @@ others are ignored. `--source` and `--target` default to `PGMIGRATE_SOURCE` and
### pgmigrate preflight

Inventories the selected tables and checks server versions, logical-replication
settings, replica identity, WAL headroom, collations, extensions, target state,
privileges, and client-tool versions. Findings are persisted in the migration
directory, so `status` shows them later. Warnings block until acknowledged, and
`run` repeats the same checks with the same gate.
settings, replica identity, sequence headroom, WAL headroom, collations,
extensions, target state, privileges, and client-tool versions. Findings are
persisted in the migration directory, so `status` shows them later. Warnings block
until acknowledged, and `run` repeats the same checks with the same gate.

Sequence headroom is checked against `--sequence-offset`, for every sequence a
selected table owns or draws a column default from. A sequence with fewer than ten
million values left before its maximum, or its minimum when it counts down, is a
warning: what remains has to cover both databases until traffic moves. One with
less room than the offset is an error, because `setval` refuses a value past the
bound and the cutover would fail at its sequence step.

| flag | default | what it does |
|---|---|---|
Expand All @@ -232,6 +247,7 @@ directory, so `status` shows them later. Warnings block until acknowledged, and
| `--pg-dump <path>` | found on `PATH` | `pg_dump` executable, whose version is checked here |
| `--pg-restore <path>` | found on `PATH` | `pg_restore` executable, whose version is checked here |
| `--wal-sample-duration <duration>` | `1m` | how long to sample the source WAL rate when judging slot retention headroom |
| `--sequence-offset <n>` | `1000000` | the gap the cutover will leave, which is the room each selected sequence is checked for |
| `--workers <n>` | host CPU count | index-build concurrency the tuning plan is sized for, so preflight reports the plan `run` would apply |
| `--skip-target-tuning` | false | report no tuning plan, because the run will not tune |
| `--target-memory <size>` | estimated from `shared_buffers` | target memory the plan is sized against, for example `64GB` |
Expand Down Expand Up @@ -306,6 +322,28 @@ standard error. A named divergence, or a table stopped early, exits non-zero.
| `--verify-converge-timeout <duration>` | `1m` | how long a row that appears to differ is given to settle against a fixed WAL position before it is reported |
| `--verify-cdc-rows <n>` | `100000` | applier-recorded keys per table checked alongside the heap sample. `0` falls back to the default |

### pgmigrate sequences

Runs the cutover's sequence step on its own, from the `follow` phase onwards, for
a cutover that moves traffic before it moves the database. It reads each selected
sequence's next value on the source and sets the target's copy that far past it, so
the target can accept writes while the source is still serving. Every sequence a
selected table owns or draws a column default from is included. The results are
written to standard output as JSON.

Values are set absolutely rather than advanced, so running it again is harmless,
and `cutover` runs the same step against the source's final values. The offset is
the room the source keeps: whatever it allocates beyond that collides with what the
target has already handed out, so size it above what the source can consume before
traffic moves.

| flag | default | what it does |
|---|---|---|
| `--dir <path>` | required | migration state directory holding the schema selection |
| `--source <dsn>` | `PGMIGRATE_SOURCE` | source connection string |
| `--target <dsn>` | `PGMIGRATE_TARGET` | target connection string |
| `--sequence-offset <n>` | `1000000` | values each target sequence is set past the source's. `0` leaves no gap, which is only safe once the source will never allocate again |

### pgmigrate cutover

Performs the cutover as a sequence of durably recorded steps: validate the
Expand All @@ -327,6 +365,7 @@ the result, and decide for yourself.
| `--source <dsn>` | `PGMIGRATE_SOURCE` | source connection string |
| `--target <dsn>` | `PGMIGRATE_TARGET` | target connection string |
| `--endpos <LSN>` | the boundary cutover emits | explicit inclusive end position, for advanced use. Must resolve to an exact durable transaction or boundary |
| `--sequence-offset <n>` | `1000000` | values each target sequence is set past the source's; see [pgmigrate sequences](#pgmigrate-sequences) |
| `--no-cleanup` | false | retain the source replication objects and target migration metadata. Target tuning and target replica identities are still reverted, because the target is about to serve production |

## Dependencies
Expand Down
48 changes: 44 additions & 4 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ func (a App) Preflight(ctx context.Context, cfg config.Config) error {
RequiredExtensions: preflightSelection.Extensions,
AcknowledgeWarnings: cfg.AckWarnings, AllowCollationChange: cfg.AllowCollationChange,
PGDumpPath: cfg.PGDumpPath, PGRestorePath: cfg.PGRestorePath,
WALSampleDuration: cfg.WALSampleDuration,
SequenceOffset: cfg.SequenceOffset, WALSampleDuration: cfg.WALSampleDuration,
}
if err := applyTuningPreflight(cfg, &preflightConfig); err != nil {
return err
Expand Down Expand Up @@ -402,7 +402,7 @@ func (a App) Run(ctx context.Context, cfg config.Config) (runErr error) {
SourceDSN: cfg.Source, TargetDSN: cfg.Target, Tables: toPreflight(tables),
AcknowledgeWarnings: cfg.AckWarnings, AllowCollationChange: cfg.AllowCollationChange,
PGDumpPath: cfg.PGDumpPath, PGRestorePath: cfg.PGRestorePath,
WALSampleDuration: cfg.WALSampleDuration,
SequenceOffset: cfg.SequenceOffset, WALSampleDuration: cfg.WALSampleDuration,
}
if err := applyTuningPreflight(cfg, &preflightConfig); err != nil {
return err
Expand Down Expand Up @@ -2184,6 +2184,45 @@ func (a App) Verify(ctx context.Context, cfg config.Config) error {
return nil
}

// Sequences advances the target's sequences without cutting over, so the target
// can accept writes while the source still holds the traffic. Cutover runs the
// same step again against the source's final values.
func (a App) Sequences(ctx context.Context, cfg config.Config) error {
if cfg.SequenceOffset < 0 {
return errors.New("sequence offset must not be negative")
}
store, err := state.OpenControl(ctx, cfg.Dir)
if err != nil {
return err
}
defer store.Close()
if err := validateTargetIdentity(ctx, cfg, store); err != nil {
return err
}
migration, err := store.Migration(ctx)
if err != nil {
return err
}
switch migration.Phase {
case state.PhaseFollow, state.PhaseDrained, state.PhaseCutover, state.PhaseComplete:
default:
return fmt.Errorf("sequences requires follow phase or later, current phase is %s", migration.Phase)
}
schemaSelection, err := loadSchemaSelection(ctx, store)
if err != nil {
return err
}
selected := make([]cutover.Sequence, len(schemaSelection.DependentRelations))
for i, sequence := range schemaSelection.DependentRelations {
selected[i] = cutover.Sequence{Schema: sequence.Schema, Name: sequence.Name}
}
results, err := cutover.SynchronizeSequences(ctx, connector(cfg.Source), connector(cfg.Target), cfg.SequenceOffset, selected)
if err != nil {
return err
}
return json.NewEncoder(a.output()).Encode(results)
}

func (a App) Cutover(ctx context.Context, cfg config.Config) error {
store, err := state.OpenControl(ctx, cfg.Dir)
if err != nil {
Expand Down Expand Up @@ -2254,8 +2293,9 @@ func (a App) Cutover(ctx context.Context, cfg config.Config) error {
}
report, err := cutover.Run(ctx, cutover.Config{
Source: connector(cfg.Source), Target: connector(cfg.Target), State: store, Dir: cfg.Dir,
WaitDrain: waitDrain,
Sequences: selectedSequences,
WaitDrain: waitDrain,
Sequences: selectedSequences,
SequenceOffset: cfg.SequenceOffset,
EmitBoundary: func(ctx context.Context) (string, error) {
conn, err := pgx.Connect(ctx, cfg.Source)
if err != nil {
Expand Down
7 changes: 7 additions & 0 deletions internal/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -386,3 +386,10 @@ func TestCommentSelectionAndLookupUseTheEntryNamespace(t *testing.T) {
t.Errorf("materialized view comment lookup = %v, %v", args, err)
}
}

func TestSequencesRejectsNegativeOffset(t *testing.T) {
err := App{}.Sequences(context.Background(), config.Config{SequenceOffset: -1})
if err == nil || !strings.Contains(err.Error(), "must not be negative") {
t.Errorf("Sequences with a negative offset = %v, want a negative-offset error", err)
}
}
3 changes: 3 additions & 0 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ func NewRootCommand() *cobra.Command {
flags.DurationVar(&cfg.StatusWatch, "watch", 0, "refresh status at this interval")
flags.BoolVar(&cfg.NoCleanup, "no-cleanup", false, "retain replication and target metadata")
flags.StringVar(&cfg.EndPosition, "endpos", "", "explicit cutover end LSN")
flags.Int64Var(&cfg.SequenceOffset, "sequence-offset", cfg.SequenceOffset,
"values each target sequence is set past the source's, leaving the source room to keep allocating")
flags.DurationVar(&cfg.WALSampleDuration, "wal-sample-duration", cfg.WALSampleDuration, "source WAL-rate sample duration")
flags.DurationVar(&cfg.SegmentPruneInterval, "segment-prune-interval", cfg.SegmentPruneInterval, "minimum interval between applied CDC segment pruning")
flags.BoolVar(&cfg.RetryBaseCopy, "retry-base-copy", false, "restart the base copy even though the last attempts failed the same way")
Expand All @@ -77,6 +79,7 @@ func NewRootCommand() *cobra.Command {
newDatabaseCommand("run", "Start or resume a migration", &cfg, application.Run),
newStateCommand("status", "Show migration progress", &cfg, false, application.Status),
newStateCommand("verify", "Verify source and target data", &cfg, true, application.Verify),
newStateCommand("sequences", "Advance target sequences past the source", &cfg, true, application.Sequences),
newStateCommand("cutover", "Finalize a migration for cutover", &cfg, true, application.Cutover),
)

Expand Down
27 changes: 27 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package cli

import (
"slices"
"strings"
"testing"
)

func TestSequencesIsItsOwnCommand(t *testing.T) {
root := NewRootCommand()
var names []string
for _, command := range root.Commands() {
names = append(names, command.Name())
}
if !slices.Contains(names, "sequences") {
t.Fatalf("root commands = %s, want one named sequences", strings.Join(names, ", "))
}
offset := root.PersistentFlags().Lookup("sequence-offset")
if offset == nil {
t.Fatal("sequence-offset flag is missing")
}
// Cutover leaves the source room to keep allocating, and so must a
// standalone run: a zero default would hand both databases the same values.
if offset.DefValue != "1000000" {
t.Errorf("sequence-offset defaults to %s, want 1000000", offset.DefValue)
}
}
2 changes: 2 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ type Config struct {
StatusWatch time.Duration
NoCleanup bool
EndPosition string
SequenceOffset int64
WALSampleDuration time.Duration
SegmentPruneInterval time.Duration
RetryBaseCopy bool
Expand Down Expand Up @@ -118,6 +119,7 @@ func FromEnvironment() Config {
RestoreJobs: max(1, runtime.NumCPU()/2),
WALSampleDuration: time.Minute,
SegmentPruneInterval: time.Minute,
SequenceOffset: 1_000_000,

VerifyWorkers: 1,
VerifySampleRows: 1_000_000,
Expand Down
11 changes: 6 additions & 5 deletions internal/cutover/cutover.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,6 @@ func Run(ctx context.Context, cfg Config) (Report, error) {
cfg.WaitDrain == nil || cfg.Cleanup == nil || strings.TrimSpace(cfg.Dir) == "" {
return Report{}, errors.New("source, target, state, directory, drain, and cleanup are required")
}
if cfg.SequenceOffset == 0 {
cfg.SequenceOffset = 1000
}
if cfg.SequenceOffset < 0 {
return Report{}, errors.New("sequence offset must not be negative")
}
Expand Down Expand Up @@ -180,7 +177,7 @@ func Run(ctx context.Context, cfg Config) (Report, error) {
}

if err := runStep(ctx, cfg.State, stepSequences, func() (string, error) {
sequences, err := synchronizeSequences(ctx, cfg.Source, cfg.Target, cfg.SequenceOffset, cfg.Sequences)
sequences, err := SynchronizeSequences(ctx, cfg.Source, cfg.Target, cfg.SequenceOffset, cfg.Sequences)
report.Sequences = sequences
data, _ := json.Marshal(sequences)
return string(data), err
Expand Down Expand Up @@ -292,7 +289,11 @@ func runStep(ctx context.Context, store State, name string, action func() (strin
return nil
}

func synchronizeSequences(
// SynchronizeSequences sets each selected target sequence to the source's value
// plus offset. It is absolute, so rerunning it is safe, and the offset is the
// room the source has left to keep allocating: run it before the source stops
// and anything it allocates beyond the offset collides with the target.
func SynchronizeSequences(
ctx context.Context,
sourceConnect, targetConnect Connector,
offset int64,
Expand Down
6 changes: 3 additions & 3 deletions internal/cutover/cutover_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ func TestPostgres17SequenceSynchronization(t *testing.T) {
CREATE SCHEMA "odd""schema";
CREATE SEQUENCE "odd""schema"."never called";
CREATE SEQUENCE "odd""schema"."called";
CREATE SEQUENCE "odd""schema"."descending" INCREMENT BY -1 MINVALUE -10000 MAXVALUE -1 START -1`
CREATE SEQUENCE "odd""schema"."descending" INCREMENT BY -1 MINVALUE -2000000 MAXVALUE -1 START -1`
if _, err := source.Exec(ctx, ddl); err != nil {
t.Fatal(err)
}
Expand All @@ -44,7 +44,7 @@ func TestPostgres17SequenceSynchronization(t *testing.T) {
{Schema: `odd"schema`, Name: "called"},
{Schema: `odd"schema`, Name: "descending"},
}
results, err := synchronizeSequences(ctx, connect(sourceInstance.URI), connect(targetInstance.URI), 1000, selected)
results, err := SynchronizeSequences(ctx, connect(sourceInstance.URI), connect(targetInstance.URI), 1_000_000, selected)
if err != nil {
t.Fatal(err)
}
Expand All @@ -61,7 +61,7 @@ func TestPostgres17SequenceSynchronization(t *testing.T) {
if err := target.QueryRow(ctx, `SELECT nextval('"odd""schema"."descending"')`).Scan(&descending); err != nil {
t.Fatal(err)
}
if never != 1010 || called != 1021 || descending != -1021 {
if never != 1_000_010 || called != 1_000_021 || descending != -1_000_021 {
t.Fatalf("next sequence values = %d, %d, %d", never, called, descending)
}
}
Loading
Loading