Repository navigation
Reuse a stale non-completed snapshot request when snapshots are repeatable - #1237
theluckystrike wants to merge 2 commits into
Conversation
…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).
CoverageTotal: 63.3% (±0.0% vs Coverage in packages changed by this PR:
|
|
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
|
Thanks @AasheeshLikePanner, you were right on all three, and I reproduced each one against PostgreSQL 16 on the previous commit 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 made a data run report success while the row stayed `requested`, because `UpdateSnapshotRequest` keys on mode and matched nothing. Any class 23 error was also taken for the duplicate.
1fdc11b changes this. For the old writer question, the recorder now holds a session advisory lock for the whole run, keyed like the unique index on schema and table names hash without mode. A second run on the same scope fails with `ErrSnapshotRequestLocked` before it touches the row or the target.
The server frees that lock when the holder's connection ends, so only a run that has ended can be taken over, and two retries can't both get it. A late completion can't land on a newer request either, since the newer run can't start while the old one holds the lock.
On the gap and the mode mismatch, the take over is now `ResumeSnapshotRequest`, which matches the index key whatever the mode, writes the new mode and reports whether a row matched. If none matched because the old row completed in between, the recorder inserts again. Only a unique violation on `schema_table_status_hash_unique_index` counts as the stale row now, and other constraint errors are returned as before.
The new integration tests in `pg_snapshot_store_recovery_integration_test.go` cover lock exclusivity across modes, the lock freed after `pg_terminate_backend` on its holder, take over across modes plus the completion update, and two retries started together while a slow run copies. Both retries are refused without copying, the slow run's row stays in progress until it completes, and a dead run is then recovered. They pass with `-race -count=3` against a local PostgreSQL 16, and `make test` passes for all 56 packages with golangci-lint at 0 issues.
Two limits remain. If the lock connection drops while the writer keeps copying, the server frees the lock early, and a binary from before this change doesn't take the lock at all. Fencing writes that a Kafka or webhook target already accepted is the larger design you sketched in #1222, and it stays out of this PR.
Thank you,
Michael Lip
Founder of https://zovo.one, 108 Chrome extensions with 150,000+ users
Also building https://makeroll.com and https://ml0x.com, open-source guardrails for Claude Code agents
Open source has to win, and small fixes like this one are my part of it
GitHub Sponsors keeps these fixes coming, https://github.com/sponsors/theluckystrike
|
Description
A snapshot that never records completion leaves its row in
pgstream.snapshot_requestsasrequestedorin progress. That happens after a kill, an OOM or a lost store connection. Withrepeatable_snapshotson, every later run of the same snapshot then fails on the insert withduplicate 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.
repeatable_snapshotson, the recorder logs a warning and reuses the leftover row. It marks the row in progress and the run goes on as normal.pgstream.snapshot_requestsand the schema, and says how to clear the row. The original error is still wrapped.There's no migration and no index change. The
modegap 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:markSnapshotInProgressnow handlesErrConstraintViolationfromCreateSnapshotRequest.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
Both new tests fail on main and pass here.
I also followed the steps in the issue by hand, with real
pgstream snapshotruns against a local PostgreSQL 16.13 server. The setup waspgstream snapshotwithrepeatable_snapshots: true, modedata, andpublic.t1with 3,000,000 rows. Run 1 was killed withkill -9once its request row wasin progressand the target had rows. Run 2 was the same snapshot into the truncated target.A leftover
requestedrow behaves the same way. A repeatable run after that adds a new completed row, as before.Other checks on this commit:
TZ=UTC make testok for all 56 packages,make generatewith no diff,golangci-lint run(v2.10.1) 0 issues, and the license header check 495 ok. I didn't runmake integration-test, because Docker isn't available on my machine.Checklist
Additional notes
This touches
pkg/snapshot/, so the approval agent policy sends it to a human reviewer.Update after review (1fdc11b)
Store.LockSnapshotRequests), keyed like the unique index (schema name and table names hash, no mode). A second run on the same scope fails withErrSnapshotRequestLockedand 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.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.schema_table_status_hash_unique_indexis treated as a stale request (ErrNonCompletedRequestExists). Other constraint errors are returned as before.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=3against a local PostgreSQL 16.TZ=UTC make testok for 56 packages, golangci-lint v2.10.1 0 issues.