Skip to content

Commit eeb6a5f

Browse files
postgres: disable the session timeouts that cancel COPY
A COPY part is one statement and holds its transaction until it finishes. So are pg_dump and pg_restore. Inherited statement_timeout, lock_timeout, idle_in_transaction_session_timeout, and idle_session_timeout cancel that work with SQLSTATE 57014, and pgmigrate never cleared them. Every SQL session it opens now sets those four GUCs to 0 before BEGIN. pg_dump and pg_restore get the same via PGOPTIONS. Preflight warns when the inherited values are non-zero, so they are visible before a long run; pgmigrate does not ALTER ROLE for itself. 57014 is still not retried: after the timeouts are off, a cancel is external. --retry-base-copy is not the fix for that. Closes #5. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent f7652ff commit eeb6a5f

18 files changed

Lines changed: 458 additions & 48 deletions

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,13 @@ warning: what remains has to cover both databases until traffic moves. One with
236236
less room than the offset is an error, because `setval` refuses a value past the
237237
bound and the cutover would fail at its sequence step.
238238

239+
Inherited `statement_timeout`, `lock_timeout`, `idle_in_transaction_session_timeout`,
240+
and `idle_session_timeout` on the source or target are warnings. pgmigrate sets
241+
those GUCs to 0 on every SQL session it opens, including `pg_dump` and
242+
`pg_restore`, so a COPY that would otherwise die at 60s can finish. The warning
243+
is so the inherited values are visible before a long run, not a request to
244+
`ALTER ROLE` for pgmigrate.
245+
239246
| flag | default | what it does |
240247
|---|---|---|
241248
| `--dir <path>` | required | migration state directory, created if absent |
@@ -689,6 +696,10 @@ Re-run `pgmigrate run` with the same DSNs, filter, and directory.
689696
- Copy parts retry classified connection failures up to five times. CDC
690697
transport failures reconnect automatically; corruption, protocol errors,
691698
divergence, and prolonged handoff backpressure stop the run for diagnosis.
699+
SQLSTATE 57014 is not a connection failure: after pgmigrate has set
700+
`statement_timeout`, `lock_timeout`, `idle_in_transaction_session_timeout`,
701+
and `idle_session_timeout` to 0 on its SQL sessions, a cancel is external
702+
(`pg_cancel_backend`, a proxy idle timeout) and stops the part.
692703
- Cutover records each successful step and resumes at the first incomplete one,
693704
reusing the end position the first attempt recorded. It never moves that
694705
boundary: the target has already been drained to it, and a fresh one would
@@ -718,6 +729,10 @@ the same reason rather than resuming the loop. Resolve the cause and pass
718729
`--retry-base-copy` once; the record clears by itself as soon as the run reaches
719730
`indexes`, after which restarts resume instead of discarding work. A process
720731
killed outright, or stopped with a signal, is not a failed attempt.
732+
`--retry-base-copy` is not the fix for SQLSTATE 57014: pgmigrate already
733+
disables the session timeouts that cancel long COPY, dump, and restore. If a
734+
cancel still happens, it is external, and retrying the whole base copy will
735+
hit it again.
721736

722737
## Security
723738

internal/app/app.go

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ func (a App) progressOutput() io.Writer {
6060
}
6161

6262
func connector(dsn string) func(context.Context) (*pgx.Conn, error) {
63-
return func(ctx context.Context) (*pgx.Conn, error) { return pgx.Connect(ctx, dsn) }
63+
return func(ctx context.Context) (*pgx.Conn, error) { return postgres.Connect(ctx, dsn) }
6464
}
6565

6666
func loadFilter(path string) (config.Filter, error) {
@@ -89,7 +89,7 @@ func sourceFingerprint(ctx context.Context, dsn string) (string, error) {
8989
}
9090

9191
func inventory(ctx context.Context, cfg config.Config, filter config.Filter) ([]pgcopy.Table, error) {
92-
conn, err := pgx.Connect(ctx, cfg.Source)
92+
conn, err := postgres.Connect(ctx, cfg.Source)
9393
if err != nil {
9494
return nil, fmt.Errorf("connect source inventory: %w", err)
9595
}
@@ -260,7 +260,7 @@ func loadCDCBinaryMode(ctx context.Context, store *state.Store) (bool, error) {
260260
}
261261

262262
func initializeTargetProgress(ctx context.Context, targetDSN, streamID, generation string) error {
263-
conn, err := pgx.Connect(ctx, targetDSN)
263+
conn, err := postgres.Connect(ctx, targetDSN)
264264
if err != nil {
265265
return err
266266
}
@@ -286,7 +286,7 @@ func initializeTargetProgress(ctx context.Context, targetDSN, streamID, generati
286286
}
287287

288288
func validateTargetProgress(ctx context.Context, targetDSN, streamID, generation string) error {
289-
conn, err := pgx.Connect(ctx, targetDSN)
289+
conn, err := postgres.Connect(ctx, targetDSN)
290290
if err != nil {
291291
return err
292292
}
@@ -338,7 +338,7 @@ func finalizeTargetCleanup(ctx context.Context, targetDSN string, store *state.S
338338
if err := validateTargetOnly(ctx, targetDSN, migration); err != nil {
339339
return err
340340
}
341-
target, err := pgx.Connect(ctx, targetDSN)
341+
target, err := postgres.Connect(ctx, targetDSN)
342342
if err != nil {
343343
return err
344344
}
@@ -572,7 +572,7 @@ func (a App) Run(ctx context.Context, cfg config.Config) (runErr error) {
572572
if err := pauseForCrashTest(groupCtx, state.PhaseIndexes); err != nil {
573573
return err
574574
}
575-
source, err := pgx.Connect(groupCtx, cfg.Source)
575+
source, err := postgres.Connect(groupCtx, cfg.Source)
576576
if err != nil {
577577
return err
578578
}
@@ -760,7 +760,7 @@ func resumeIndexes(ctx context.Context, cfg config.Config, store *state.Store) e
760760
for _, table := range tables {
761761
selected[table.OID] = true
762762
}
763-
source, err := pgx.Connect(ctx, cfg.Source)
763+
source, err := postgres.Connect(ctx, cfg.Source)
764764
if err != nil {
765765
return err
766766
}
@@ -940,7 +940,7 @@ func dumpSelection(ctx context.Context, sourceDSN, snapshot string, tables []pgc
940940
selection.Tables[i] = schema.QualifiedName{Schema: table.Schema, Name: table.Name}
941941
oids[i] = table.OID
942942
}
943-
conn, err := pgx.Connect(ctx, sourceDSN)
943+
conn, err := postgres.Connect(ctx, sourceDSN)
944944
if err != nil {
945945
return selection, err
946946
}
@@ -1125,7 +1125,7 @@ func dumpSelection(ctx context.Context, sourceDSN, snapshot string, tables []pgc
11251125

11261126
func inspectDeferred(dir, sourceDSN string) schema.DeferredInspector {
11271127
return func(ctx context.Context, target *pgx.Conn, entry schema.TOCEntry) (schema.DeferredStatus, error) {
1128-
source, err := pgx.Connect(ctx, sourceDSN)
1128+
source, err := postgres.Connect(ctx, sourceDSN)
11291129
if err != nil {
11301130
return schema.DeferredStatus{}, err
11311131
}
@@ -1559,7 +1559,7 @@ func followChecks(ctx context.Context, cfg config.Config, store *state.Store, sl
15591559
if migration.Phase != state.PhaseFollow {
15601560
continue
15611561
}
1562-
conn, err := pgx.Connect(ctx, cfg.Source)
1562+
conn, err := postgres.Connect(ctx, cfg.Source)
15631563
if err != nil {
15641564
_ = store.UpsertFinding(ctx, state.Finding{ID: "follow-source-health", Kind: "health", Severity: "error", Message: err.Error()})
15651565
continue
@@ -1714,7 +1714,7 @@ func monitorProgress(ctx context.Context, store *state.Store, targetDSN, streamI
17141714
defer ticker.Stop()
17151715
nextLog := time.Now()
17161716
for {
1717-
conn, err := pgx.Connect(ctx, targetDSN)
1717+
conn, err := postgres.Connect(ctx, targetDSN)
17181718
if err != nil {
17191719
return err
17201720
}
@@ -1763,7 +1763,7 @@ func resetInterruptedBaseCopy(ctx context.Context, cfg config.Config, store *sta
17631763
if err != nil && !errors.Is(err, os.ErrNotExist) {
17641764
return fmt.Errorf("read prior snapshot metadata: %w", err)
17651765
}
1766-
target, err := pgx.Connect(ctx, cfg.Target)
1766+
target, err := postgres.Connect(ctx, cfg.Target)
17671767
if err != nil {
17681768
return err
17691769
}
@@ -1912,7 +1912,7 @@ func resetInterruptedBaseCopy(ctx context.Context, cfg config.Config, store *sta
19121912
}
19131913

19141914
func recordTargetIdentity(ctx context.Context, targetDSN, sourceFingerprint, filterFingerprint, streamID, generation string) error {
1915-
conn, err := pgx.Connect(ctx, targetDSN)
1915+
conn, err := postgres.Connect(ctx, targetDSN)
19161916
if err != nil {
19171917
return err
19181918
}
@@ -1975,7 +1975,7 @@ func validateTargetIdentity(ctx context.Context, cfg config.Config, store *state
19751975
}
19761976

19771977
func validateTargetOnly(ctx context.Context, targetDSN string, migration state.Migration) error {
1978-
conn, err := pgx.Connect(ctx, targetDSN)
1978+
conn, err := postgres.Connect(ctx, targetDSN)
19791979
if err != nil {
19801980
return err
19811981
}
@@ -2085,7 +2085,7 @@ func verificationInventory(ctx context.Context, cfg config.Config, tables []stat
20852085
for _, table := range tables {
20862086
selected[table.Schema+"\x00"+table.Name] = true
20872087
}
2088-
source, err := pgx.Connect(ctx, cfg.Source)
2088+
source, err := postgres.Connect(ctx, cfg.Source)
20892089
if err != nil {
20902090
return nil, err
20912091
}
@@ -2118,7 +2118,7 @@ func waitTargetProgress(ctx context.Context, targetDSN, streamID, wanted string)
21182118
var reached pglogrepl.LSN
21192119
advancedAt := time.Now()
21202120
for {
2121-
conn, err := pgx.Connect(ctx, targetDSN)
2121+
conn, err := postgres.Connect(ctx, targetDSN)
21222122
if err != nil {
21232123
return err
21242124
}
@@ -2297,7 +2297,7 @@ func (a App) Cutover(ctx context.Context, cfg config.Config) error {
22972297
Sequences: selectedSequences,
22982298
SequenceOffset: cfg.SequenceOffset,
22992299
EmitBoundary: func(ctx context.Context) (string, error) {
2300-
conn, err := pgx.Connect(ctx, cfg.Source)
2300+
conn, err := postgres.Connect(ctx, cfg.Source)
23012301
if err != nil {
23022302
return "", err
23032303
}
@@ -2360,7 +2360,7 @@ func cleanupAfterCutover(ctx context.Context, cfg config.Config, store *state.St
23602360

23612361
func waitSlotInactive(ctx context.Context, sourceDSN, slot string) error {
23622362
for {
2363-
conn, err := pgx.Connect(ctx, sourceDSN)
2363+
conn, err := postgres.Connect(ctx, sourceDSN)
23642364
if err != nil {
23652365
return err
23662366
}

internal/app/replident.go

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,9 @@ import (
77
"io"
88
"strings"
99

10-
"github.com/jackc/pgx/v5"
11-
1210
"github.com/GetStream/pgmigrate/internal/config"
1311
pgcopy "github.com/GetStream/pgmigrate/internal/copy"
12+
"github.com/GetStream/pgmigrate/internal/postgres"
1413
"github.com/GetStream/pgmigrate/internal/replident"
1514
"github.com/GetStream/pgmigrate/internal/state"
1615
)
@@ -60,7 +59,7 @@ func (r replidentRecorder) Record(ctx context.Context, record replident.Record)
6059
func (a App) applyReplicaIdentityFallback(
6160
ctx context.Context, cfg config.Config, store *state.Store, tables []pgcopy.Table,
6261
) error {
63-
conn, err := pgx.Connect(ctx, cfg.Source)
62+
conn, err := postgres.Connect(ctx, cfg.Source)
6463
if err != nil {
6564
return err
6665
}
@@ -157,7 +156,7 @@ func restoreReplicaIdentities(ctx context.Context, sourceDSN string, store *stat
157156
if len(records) == 0 {
158157
return nil
159158
}
160-
conn, err := pgx.Connect(ctx, sourceDSN)
159+
conn, err := postgres.Connect(ctx, sourceDSN)
161160
if err != nil {
162161
return err
163162
}
@@ -191,7 +190,7 @@ func restoreTargetReplicaIdentities(ctx context.Context, targetDSN string, store
191190
if len(records) == 0 {
192191
return nil
193192
}
194-
conn, err := pgx.Connect(ctx, targetDSN)
193+
conn, err := postgres.Connect(ctx, targetDSN)
195194
if err != nil {
196195
return err
197196
}

internal/app/tuning.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,8 @@ import (
88
"slices"
99
"strings"
1010

11-
"github.com/jackc/pgx/v5"
12-
1311
"github.com/GetStream/pgmigrate/internal/config"
12+
"github.com/GetStream/pgmigrate/internal/postgres"
1413
"github.com/GetStream/pgmigrate/internal/preflight"
1514
"github.com/GetStream/pgmigrate/internal/state"
1615
"github.com/GetStream/pgmigrate/internal/tuning"
@@ -69,7 +68,7 @@ func tuneTarget(ctx context.Context, cfg config.Config, store *state.Store) (map
6968
if err != nil {
7069
return nil, err
7170
}
72-
conn, err := pgx.Connect(ctx, cfg.Target)
71+
conn, err := postgres.Connect(ctx, cfg.Target)
7372
if err != nil {
7473
return nil, err
7574
}
@@ -203,7 +202,7 @@ func revertTargetTuning(ctx context.Context, targetDSN string, store *state.Stor
203202
if len(changes) == 0 {
204203
return nil
205204
}
206-
conn, err := pgx.Connect(ctx, targetDSN)
205+
conn, err := postgres.Connect(ctx, targetDSN)
207206
if err != nil {
208207
return err
209208
}

internal/app/vacuum.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ func vacuumOne(
129129
// settings, which is what lets one vacuum use the memory and parallel workers the
130130
// target was sized for.
131131
func vacuumSession(ctx context.Context, cfg config.Config, sessionGUCs map[string]string) (*pgx.Conn, error) {
132-
conn, err := pgx.Connect(ctx, cfg.Target)
132+
conn, err := postgres.Connect(ctx, cfg.Target)
133133
if err != nil {
134134
return nil, err
135135
}

internal/app/verify.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ func (m *marker) flushWAL(ctx context.Context) error {
7878
m.mu.Lock()
7979
defer m.mu.Unlock()
8080
if m.nudge == nil {
81-
conn, err := pgx.Connect(ctx, m.dsn)
81+
conn, err := postgres.Connect(ctx, m.dsn)
8282
if err != nil {
8383
return fmt.Errorf("connect to flush the verification marker: %w", err)
8484
}
@@ -114,7 +114,7 @@ func (m *marker) close() {
114114

115115
// sourceCapabilities reads what the source's release supports.
116116
func sourceCapabilities(ctx context.Context, dsn string) (postgres.Capabilities, error) {
117-
conn, err := pgx.Connect(ctx, dsn)
117+
conn, err := postgres.Connect(ctx, dsn)
118118
if err != nil {
119119
return postgres.Capabilities{}, err
120120
}

internal/cdc/applier.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ func (a *Applier) WaitUntil(ctx context.Context, boundary LSN) error {
119119
effectiveBoundary := boundary
120120
resolved := false
121121
for {
122-
conn, err := pgx.Connect(ctx, a.config.ConnString)
122+
conn, err := postgres.Connect(ctx, a.config.ConnString)
123123
if err != nil {
124124
return fmt.Errorf("cdc: connect catch-up observer: %w", err)
125125
}
@@ -152,7 +152,7 @@ func (a *Applier) WaitUntil(ctx context.Context, boundary LSN) error {
152152
}
153153

154154
func (a *Applier) runConnection(ctx context.Context) error {
155-
conn, err := pgx.Connect(ctx, a.config.ConnString)
155+
conn, err := postgres.Connect(ctx, a.config.ConnString)
156156
if err != nil {
157157
return fmt.Errorf("cdc: connect applier: %w", err)
158158
}

internal/copy/copy.go

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,17 @@ func retryableConnectionError(err error) bool {
483483
return pgconn.SafeToRetry(err)
484484
}
485485

486+
func annotateCopyError(side string, err error) error {
487+
if err == nil {
488+
return nil
489+
}
490+
var pgErr *pgconn.PgError
491+
if errors.As(err, &pgErr) && pgErr.Code == "57014" {
492+
return fmt.Errorf("%s: query canceled (SQLSTATE 57014); pgmigrate already set statement_timeout, lock_timeout, idle_in_transaction_session_timeout, and idle_session_timeout to 0 on this session, so this is an external cancel: %w", side, err)
493+
}
494+
return fmt.Errorf("%s: %w", side, err)
495+
}
496+
486497
// LargestFirst returns a stable, independently owned worker schedule.
487498
func LargestFirst(parts []Part) []Part {
488499
result := append([]Part(nil), parts...)
@@ -593,13 +604,7 @@ func (r Runner) copyPart(ctx context.Context, p Part) error {
593604
_ = pr.CloseWithError(targetErr)
594605
src := <-ch
595606
if src.err != nil || targetErr != nil {
596-
if src.err != nil {
597-
src.err = fmt.Errorf("copy out of source: %w", src.err)
598-
}
599-
if targetErr != nil {
600-
targetErr = fmt.Errorf("copy into target: %w", targetErr)
601-
}
602-
return errors.Join(src.err, targetErr)
607+
return errors.Join(annotateCopyError("copy out of source", src.err), annotateCopyError("copy into target", targetErr))
603608
}
604609
if _, err := ttx.Exec(ctx, `
605610
INSERT INTO pgmigrate_internal.copy_parts(table_oid, part_id, rows_copied, bytes_copied)

0 commit comments

Comments
 (0)