fix(sync): fix SnapshotReader goroutine race and fsync ordering#1166
fix(sync): fix SnapshotReader goroutine race and fsync ordering#1166corylanou wants to merge 4 commits into
Conversation
PR Build Metrics✅ All clear — no issues detected
Binary Size
Dependency ChangesNo dependency changes. govulncheck OutputBuild Info
History (10 previous)
🤖 Updated on each push. |
f311865 to
f32e404
Compare
f32e404 to
8905efd
Compare
Testing against the reproduction repoWe used the schema and access patterns from @fuchstim's reproduction repo to build a deterministic test ( The reproduction repo creates a table with 5 indexes and 6 triggers, then runs 100 ops/sec (inserts, updates, soft-deletes, outbox cleanup). Each transaction touches many WAL pages across different B-tree structures — exactly the scenario that maximizes the checkpoint race window. What the test does
Test outputThe old approach produces 10/10 corrupted pages when the WAL is modified — this is what was happening in production. The new approach produces 0/10 corrupted pages because the data was already captured during the initial read. Thanks again to @fuchstim for the detailed trace logs, reproduction repo, and all the debugging — it made pinpointing this race condition much faster. |
8905efd to
2a735f6
Compare
1d27456 to
f8beb02
Compare
|
I separately ran into this issue. I'm not a Go or Litestream expert so I had Claude Code try to debug it. After confirming that the issue was with the data in the object store, not a problem with the production database or the restoration process, CC identified that this PR's work would have fixed the problem. I've copied CC's analysis below, which includes a new test that could be included in this PR. Analysis and test by Claude Code, from a production incident; verify independently.
Production isolation:
Deterministic regression test (no MinIO, no timing dependence). RED on
|
a59947d to
4b68110
Compare
Why this PR was rebased onto the sync-stability seriesThis PR's CI was red on So rather than leave this PR red against Verified green on HEAD
Scope was also reduced to honor small, reviewable increments: this PR is now just the two product fixes ( |
|
Status note for reviewers — this PR has been rescoped since the earlier review. The The current PR is scoped to two
It is stacked behind #1289 → #1292 (base Since the prior verdict predates the rescope, a fresh review would be appreciated. Full stack map: https://gist.github.com/corylanou/f2709d9868cad1943c63096bf978e881 |
34a2963 to
255abe8
Compare
4b68110 to
d2d5a2f
Compare
255abe8 to
65867bf
Compare
65867bf to
828e2ff
Compare
374d420 to
2045e7f
Compare
828e2ff to
76cd056
Compare
2045e7f to
b99709f
Compare
b99709f to
5f321ac
Compare
32090a3 to
959dcce
Compare
5f321ac to
d41cec3
Compare
959dcce to
99cf34f
Compare
d41cec3 to
209f2ba
Compare
99cf34f to
ce6c0d8
Compare
209f2ba to
7393941
Compare
ce6c0d8 to
901b086
Compare
Add f.Sync() before f.Truncate() in applyLTXFile() to ensure pages are durably written before the file is truncated. Without this ordering, a crash between truncate and the implicit close-sync could leave the LTX file with missing pages. Add sync→restore integrity tests that exercise concurrent writes, checkpoints, and syncs then verify the restored database passes PRAGMA integrity_check with correct row counts. These tests reproduce the scenario from issue #1164. Ref #1164
Pin the atomicity between position capture and the chkMu read lock in SnapshotReader: the lock is acquired while the executor semaphore is still held, so no checkpoint can run between reading the position and encoding the snapshot. If that handoff is broken, a snapshot can advertise TXID T while containing post-T pages read from the checkpointed database file -- the #1164 corruption class. The test reproduces the corruption on first iteration when the handoff is removed. The previous chkMu restructure commit is dropped from this branch: it was authored against main, where the lock was released when SnapshotReader returned, but on this stack the checkpoint-snapshot hardening already holds the lock across the full encode via the snapshotReadPosition handoff. Re-applying it here reopened the window it was meant to close.
Sleep briefly when the truncate checkpoint reports busy so the loop does not spin hot on slow runners, and use t.Context and range loops in the consistency test.
Bracket the consistency check against the nearest recorded TXID on either side of the snapshot position to tolerate the recording race with concurrent boundary snapshots, while requiring at least one unambiguous at-or-before match so systematic content-ahead regressions still fail. Dedupe the sync-restore integrity variants behind runSyncRestoreIntegrity (failing if the checkpoint variant never checkpoints) and share LTX scaffolding in the applyLTXFile tests.
7393941 to
e7e96d6
Compare
Summary
Fixes the issue #1164 corruption hazards in a small stacked PR:
applyLTXFile()now callsf.Sync()beforef.Truncate(), so restored page writes are durable before the database file is shortened to the LTX commit size.TestDB_SnapshotReaderConsistentDuringConcurrentCheckpoints, which pins the atomicity betweenSnapshotReader's position capture and itschkMuread lock. The lock is acquired while the executor semaphore is still held (the arrangement introduced in fix(db): harden checkpoint snapshots #1292), so no checkpoint can run between reading the position and encoding the snapshot. The test reproduces the Database corrupted when restoring from backup #1164 corruption class on the first iteration if that handoff is ever broken.TestSyncRestoreIntegrity,TestSyncRestoreIntegrity_WithCheckpoints) and apply-LTX restore tests.Note: an earlier revision of this PR moved
chkMu.RLock()into the snapshot goroutine. That change was authored againstmain(where the lock was released whenSnapshotReaderreturned, before the encode finished) but on this stack #1292 already holds the lock across the full encode via thesnapshotReadPositionhandoff — re-applying it here reopened the checkpoint window it was meant to close. The commit was dropped; the new concurrency test now guards that invariant instead.This PR stacks on #1289 through #1292 via
issue-1280-checkpoint-snapshot-hardening.Ref #1164
Ref #1199
Ref #1281
Scope
In scope:
replica.go(fsync-before-truncate)db_internal_test.goNot in scope:
tests/integration/soak_replicate_restore_test.go, which moved to test(integration): add replicate restore soak test #1302Test Plan
Verified
TestDB_SnapshotReaderConsistentDuringConcurrentCheckpointsfails immediately ("snapshot at txid N contains v=X, want Y: content inconsistent with advertised position") when the dropped lock-restructure commit is re-applied, and passes on this branch.