Skip to content

Reuse a stale non-completed snapshot request when snapshots are repeatable - #1237

Open
theluckystrike wants to merge 2 commits into
xataio:mainfrom
theluckystrike:fix/pgstream-stale-request
Open

theluckystrike wants to merge 2 commits into
xataio:mainfrom
theluckystrike:fix/pgstream-stale-request

Conversation

@theluckystrike

@theluckystrike theluckystrike commented Oct 7, 2026 •

Copy link
Copy Markdown

Description

A snapshot that never records completion leaves its row in pgstream.snapshot_requests as requested or in progress. That happens after a kill, an OOM or a lost store connection. With repeatable_snapshots on, every later run of the same snapshot then fails on the insert with duplicate key value violates unique constraint "schema_table_status_hash_unique_index". The only way out was to delete the row by hand.

This change handles that conflict in the snapshot recorder.

  • With repeatable_snapshots on, the recorder logs a warning and reuses the leftover row. It marks the row in progress and the run goes on as normal.
  • With it off, the error now names pgstream.snapshot_requests and the schema, and says how to clear the row. The original error is still wrapped.

There's no migration and no index change. The mode gap in the unique index (point 3 in the issue) isn't part of this PR, since it needs its own change.

Related issue

Fixes #1222. The issue proposes three fixes, and this PR does the first two.

Type of change

Bug fix (non-breaking change that fixes an issue)

Changes made

  • pkg/snapshot/generator/snapshot_generator_recorder.go: markSnapshotInProgress now handles ErrConstraintViolation from CreateSnapshotRequest.
  • pkg/snapshot/generator/snapshot_generator_recorder_test.go: two new tests. One gives the recorder a store that returns the duplicate key error on create, with repeatable snapshots on. It checks that the snapshot runs and the row goes to in progress and then completed. The other checks the error text with repeatable snapshots off.

Testing

  • Unit tests added/updated
  • Integration tests added/updated
  • Manual testing performed
  • All existing tests pass

Both new tests fail on main and pass here.

go test -count=1 -run 'TestSnapshotRecorder_CreateSnapshot_(repeatableReusesStaleRequest|nonRepeatableConflictIsActionable)' -v ./pkg/snapshot/generator/

main:
  Error: Received unexpected error:
         error creating snapshot request: constraint violation: duplicate key value violates unique constraint "schema_table_status_hash_unique_index"
  --- FAIL: TestSnapshotRecorder_CreateSnapshot_repeatableReusesStaleRequest (0.00s)
  Error: Error "error creating snapshot request: constraint violation: duplicate key value violates unique constraint \"schema_table_status_hash_unique_index\"" does not contain "pgstream.snapshot_requests"
  --- FAIL: TestSnapshotRecorder_CreateSnapshot_nonRepeatableConflictIsActionable (0.00s)

this branch:
  --- PASS: TestSnapshotRecorder_CreateSnapshot_nonRepeatableConflictIsActionable (0.00s)
  --- PASS: TestSnapshotRecorder_CreateSnapshot_repeatableReusesStaleRequest (0.00s)
  PASS
  ok  	github.com/xataio/pgstream/pkg/snapshot/generator	1.683s

I also followed the steps in the issue by hand, with real pgstream snapshot runs against a local PostgreSQL 16.13 server. The setup was pgstream snapshot with repeatable_snapshots: true, mode data, and public.t1 with 3,000,000 rows. Run 1 was killed with kill -9 once its request row was in progress and the target had rows. Run 2 was the same snapshot into the truncated target.

binary built from main:
  run 1 killed, row public {t1} "in progress", target 3000 rows
  run 2 exit 1
  Error: error creating snapshot request: constraint violation: duplicate key value violates unique constraint "schema_table_status_hash_unique_index"
  row stays "in progress", target 0 rows

binary built from this branch:
  run 1 killed, row public {t1} "in progress", target 21000 rows
  run 2 exit 0
  WRN reusing non-completed snapshot request left by a previous run mode=data schema=public
  row ends "completed" with no errors, target 3000000 rows

A leftover requested row behaves the same way. A repeatable run after that adds a new completed row, as before.

Other checks on this commit: TZ=UTC make test ok for all 56 packages, make generate with no diff, golangci-lint run (v2.10.1) 0 issues, and the license header check 495 ok. I didn't run make integration-test, because Docker isn't available on my machine.

Checklist

  • Code follows project style guidelines
  • Self-review completed
  • Code is well-commented
  • Documentation updated where necessary

Additional notes

This touches pkg/snapshot/, so the approval agent policy sends it to a human reviewer.

Update after review (1fdc11b)

  • The recorder holds a session advisory lock per request for the whole run (Store.LockSnapshotRequests), keyed like the unique index (schema name and table names hash, no mode). A second run on the same scope fails with ErrSnapshotRequestLocked and does not touch the row or the target. The server frees the lock when the holder's connection ends, so only a request whose run has ended is reused.
  • The reuse is Store.ResumeSnapshotRequest. It matches the index key whatever the mode, writes the new mode, and reports whether a row matched. If no row matched (the old row completed after the failed insert), the recorder inserts again.
  • Only a unique violation on schema_table_status_hash_unique_index is treated as a stale request (ErrNonCompletedRequestExists). Other constraint errors are returned as before.
  • New integration tests in pkg/snapshot/store/postgres/integration/pg_snapshot_store_recovery_integration_test.go: lock exclusivity across modes, lock freed after the holder's backend ends, reuse across modes with the completion update, and two retries started together while a slow run copies, then recovery of a dead run. They pass with -race -count=3 against a local PostgreSQL 16. TZ=UTC make test ok for 56 packages, golangci-lint v2.10.1 0 issues.

…table

A run killed mid-snapshot leaves a "requested" or "in progress" row in
pgstream.snapshot_requests. The partial unique index
schema_table_status_hash_unique_index allows one non-completed row per
schema and table list. So every later run fails at once in
CreateSnapshotRequest with a constraint violation. With repeatable
snapshots on, the recorder never reads the existing rows, so the only
way out was to delete the row by hand (xataio#1222).

When CreateSnapshotRequest returns a constraint violation:
- With repeatable snapshots on, log a warning and reuse the row. The
  existing MarkInProgress and UpdateSnapshotRequest calls already match
  the non-completed row by schema, tables and mode.
- With repeatable snapshots off, return an error that names the table
  and tells the user what to do. The original error stays wrapped.

Other errors are returned as before. No migration: the mode column
missing from the unique index (issue point 3) is out of scope.

Tests (go1.26.6 darwin/arm64)

go test -count=1 -run 'TestSnapshotRecorder_CreateSnapshot_(repeatableReusesStaleRequest|nonRepeatableConflictIsActionable)' -v ./pkg/snapshot/generator/

Before the fix (main code, new tests only):
  Error: Received unexpected error:
         error creating snapshot request: constraint violation: duplicate key value violates unique constraint "schema_table_status_hash_unique_index"
  --- FAIL: TestSnapshotRecorder_CreateSnapshot_repeatableReusesStaleRequest (0.00s)
  Error: Error "error creating snapshot request: constraint violation: duplicate key value violates unique constraint \"schema_table_status_hash_unique_index\"" does not contain "pgstream.snapshot_requests"
  --- FAIL: TestSnapshotRecorder_CreateSnapshot_nonRepeatableConflictIsActionable (0.00s)
  FAIL	github.com/xataio/pgstream/pkg/snapshot/generator	0.507s

After the fix:
  --- PASS: TestSnapshotRecorder_CreateSnapshot_nonRepeatableConflictIsActionable (0.00s)
  --- PASS: TestSnapshotRecorder_CreateSnapshot_repeatableReusesStaleRequest (0.00s)
  ok  	github.com/xataio/pgstream/pkg/snapshot/generator	1.683s

End to end, the issue's steps against a local PostgreSQL 16.13:
`pgstream snapshot` with repeatable_snapshots: true, mode data,
public.t1 with 3,000,000 rows, recorder store and target in the same
database. Run 1 is killed with kill -9 once its request row is
"in progress" and the target has rows. Run 2 is the same snapshot into
the truncated target.
  binary built from main:
    run 1 SIGKILL, row 3 public {t1} "in progress", target 3000 rows
    run 2 exit 1
    Error: error creating snapshot request: constraint violation: duplicate key value violates unique constraint "schema_table_status_hash_unique_index"
    row stays "in progress", target 0 rows
  binary built with this fix:
    run 1 SIGKILL, row 5 public {t1} "in progress", target 21000 rows
    run 2 exit 0
    WRN reusing non-completed snapshot request left by a previous run mode=data schema=public
    row 5 ends "completed" with no errors, target 3000000 rows
A leftover "requested" row behaves the same (main exit 1, fix exit 0 and
the row ends "completed"), and a repeatable run after that one adds a
new completed row as before.

Other gates run on this commit:
  go test -count=1 -race ./pkg/snapshot/...                  ok
  go vet ./pkg/snapshot/...                                  ok
  TZ=UTC go test -timeout 10m -race -cover ./... (make test) ok, 56 packages
  make generate                                              no diff
  golangci-lint v2.10.1 run --config=.golangci.yml           0 issues
  license-header-checker -a -r ./license-header.txt . go     495 licenses ok

Not run: make integration-test (testcontainers needs Docker, which is
not available here).
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown

Coverage

Total: 63.3% (±0.0% vs main)

Coverage in packages changed by this PR:

Package Coverage Δ
pkg/snapshot/generator 95.6% +0.1%

@AasheeshLikePanner

AasheeshLikePanner commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

a duplicate row does not prove the old writer stopped so what happens if the old run is just slow or two retries start together they can reuse the same row and both keep writing then one can mark it completed while the other is still copying and a late completion can update a newer request too

there is also a gap between the failed insert and the update where the old row could complete and the update would touch nothing but still report success the mode mismatch has the same problem since the index ignores mode but the update requires it and we are catching every constraint error as if it was this duplicate

manual test kills the old process and truncates the target so it does not cover these cases or retrying without clearing the target first can we check that the old writer cannot interfere before reusing the row and test slow writers two retries together and late completion against real postgres

Review on xataio#1237 pointed out that a duplicate row does not prove the old
run stopped. Checked against real PostgreSQL 16 on f4dc1db:
- a retry started while a slow run was still copying went ahead, and
  its completion marked the shared row completed before the slow run
  finished
- a leftover schema-only row for the same tables made a data run
  report success while UpdateSnapshotRequest (keyed on mode) matched
  nothing, so the row stayed "requested"
- every class 23 error was taken for the duplicate

Changes:
- Store.LockSnapshotRequests takes a session level advisory lock per
  request on a dedicated connection, keyed like the unique index
  (schema name and table names hash, no mode). The recorder holds it
  for the whole run. A second run on the same scope fails with
  ErrSnapshotRequestLocked and touches nothing. The server frees the
  lock when the holder's connection ends, so a killed run's request
  can be recovered.
- Store.ResumeSnapshotRequest takes the leftover row over by schema
  and table names whatever its mode, sets it in progress with the new
  mode and reports whether a row matched. When none matched (the row
  completed between the insert and the take over) the recorder
  inserts again.
- CreateSnapshotRequest wraps ErrNonCompletedRequestExists only for a
  unique violation on schema_table_status_hash_unique_index. Other
  constraint errors are returned as before.

Tests:
- unit tests for the recorder (take over, insert again after the gap,
  take over error, non repeatable conflict, other constraint error,
  lock held, lock released on failure) and for the store methods
- integration tests against real postgres: lock exclusivity across
  modes, lock freed when the holder's backend is terminated, take over
  across modes and completion update, and two retries started together
  while a slow run copies (both refused, no copy, the slow run's row
  stays in progress until it completes), then recovery of a dead run
@theluckystrike

theluckystrike commented Oct 19, 2026 via email •

Copy link
Copy Markdown
Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Stuck non-completed snapshot request blocks all later snapshots

2 participants