Repository navigation
Detect and reject torn checkpoint saves - #99
matt-welch wants to merge 5 commits into
Conversation
A checkpoint save writes a vamana index in three separate steps: the graph/data/config files in place, then the TID map via temp-and-rename, then the metapage last. A crash between the first and second step left a newer graph beside an older TID map and metapage, and the loader accepted that mismatched state with no error, silently serving a large share of the index's rows as missing. A real kill -9 of the worker mid- save reproduced the loss with no error logged anywhere. The fix stamps a single generation value into three places as a save progresses: a save-directory marker file written before the graph write begins, the TID map header, and the metapage. On load, all three must agree; any disagreement is treated as a torn save and triggers a rebuild from the heap instead of serving stale or partial data. Stamping the marker before the graph write (rather than after) is what closes the full crash window: a crash at any later point leaves the marker already at the new generation while something else lags behind. Because the whole metapage is discarded on a mismatch, a stale nextExternalId from a torn save can never be read back either. - Added a generation marker file (generation.bin) alongside the existing save directory, written durably before SVSSaveIndex overwrites graph/data/config - Added a saveGeneration field to the TID map header (version bump) and to the metapage, both checked against the marker on load - Updated every load path that reads the TID map or metapage (query- time load, worker load-slot handling, vacuum) to pass through and check the generation - Added TAP tests reproducing the torn state both offline (file splicing) and via a real crash (gdb-attached kill -9 against the live worker) - Updated the existing checkpoint-durability TAP test for the TID map header's new size Signed-off-by: Matt Welch <matt.welch@intel.com>
CodeQL flagged the orphaned-temp-file cleanup in VamanaCheckSaveGeneration: it checked the temp file's mode and mtime with lstat on its path, then unlinked that same path separately, leaving a window where the file on disk could change between the check and the delete. Switched the check to open the file first and fstat the resulting descriptor instead of lstat-ing the path, so the staleness decision is tied to the exact file that was opened rather than to whatever is at that path a moment later. unlink still has to operate by name afterward since POSIX has no fd-based unlink, but the race window is now limited to that one step instead of spanning the whole check-then-delete sequence. The identical pre-existing pattern in VamanaLoadTidMap was left alone; it's a separate, already-dismissed CodeQL alert on main with the same worst-case (a wasted retry, no data loss) and isn't part of this PR's diff. Signed-off-by: Matt Welch <matt.welch@intel.com>
asonje
left a comment
There was a problem hiding this comment.
The marker-before-graph-write ordering is the right call, and the argument for why a TID-map/metapage cross-check alone would miss this is convincing.
One thing worth considering before this lands: how the generation check behaves
on a standby.
saveGeneration is in the metapage so it replicates; generation.bin and tidmap.bin are plain files so they don't. On a standby built from a basebackup carrying a save directory, the marker is frozen at backup time while the metapage keeps advancing by replay — so the first primary checkpoint after the backup makes the two disagree permanently.
With #58 merged that's no longer dangerous: the mismatch deletes the save directory, and the next load falls through to the heap rebuild without attempting the WAL write. But two things follow:
-
The standby logs
WARNING: ... torn save detected, will rebuildon a healthy cluster. Nothing tore — the stamps differ because one of them replicates and two don't. this standby will emit a corruption-shaped warning every single time this index reloads,
forever, by design. -
The check runs after
SVSLoadDynamicIndexreturns, so a standby deserialises
the whole graph, frees it, deletes the directory, and rebuilds from the heap. #54's slot check was going to discard that copy regardless.
Both point the same direction as #58 did: the save directory isn't doing any work on a standby, and arguably shouldn't be in the basebackup at all. Separate change, but this is now the third PR to run into it.
Minor: vamanaio.c:871 as the K2 breakpoint in 76_torn_graph_natural_crash.pl will drift on any edit to that file, and the "breakpoint hit" assertion would still pass while parked somewhere else. A function-entry target would be more durable.
The K2 case in the crash test stopped the worker with a gdb breakpoint on a source line number, which drifts on any edit to vamanaio.c, and the "breakpoint hit" check only looked for any breakpoint, so a worker parked somewhere else would still pass. VamanaMarkIndexSaved was being inlined, which is why a line number was used. Marking it noinline gives it a symbol, so the test can break on its entry by name like the K1 case does. The hit check now also requires the reported breakpoint to be in the expected function. - Mark VamanaMarkIndexSaved pg_noinline so a debugger can stop on entry by name - Use the function name as the K2 breakpoint target in the crash test - Require the gdb breakpoint-hit line to name the expected function Signed-off-by: Matt Welch <matt.welch@intel.com>
asonje
left a comment
There was a problem hiding this comment.
Two issues found, verified empirically rather than just read.
- 76_torn_graph_natural_crash.pl's K2 case is broken, and nothing in CI has ever caught it
K2's breakpoint target, 'vamanaio.c:871', can't resolve — not intermittently, unconditionally. svs.so is built with no debug symbols at all (confirmed directly: gdb's info sources on the live worker reports (Objfile has no debug information.) for svs.so). Reproducing the exact gdb sequence by hand:
No source file named vamanaio.c.
Make breakpoint pending on future shared library load? (y or [n]) [answered N; input not from terminal]
No breakpoint is ever set. The scripted kill -9 then fires at whatever arbitrary point continue next returns control (I observed it stop on a stray SIGUSR1, unrelated to the save sequence), not the intended "after TID-map rename, before metapage update" window. On this box the result was 3 of 23 subtests failing ('k2: gdb reported the breakpoint hit', 'k2: worker process confirmed dead', 'K2: the crash-torn generation is reported and rejected').
Fix needs two changes together, verified independently:
- Change the K2 target from 'vamanaio.c:871' to 'VamanaMarkIndexSaved' (matching K1's working convention — symbol-table breakpoints don't need debug info, file:line ones do).
- Mark VamanaMarkIndexSaved pg_noinline. Without it, even the corrected name-based breakpoint still fails: it's a small static function called from exactly one place, so GCC fully inlines it away under normal optimization (confirmed via nm: symbol present with the attribute, absent without it).
With both applied: 76 goes from 3/23 failing to 23/23 passing, in 14s instead of wasting 60s waiting on a breakpoint that could never fire. 75 stays 57/57.
Separately: this is the first gdb-based test anywhere in this suite (checked every other test/t/*.pl, on this branch and main — zero other matches), and no CI workflow provisions gdb — not build-and-test.yml, not assert-build.yml either (which doesn't run on PRs at all, and wouldn't have gdb even on its weekly schedule run). So "all CI checks passed" here means literally nothing about K1 or K2: the job that runs the TAP suite reports test/t/76_torn_graph_natural_crash.pl ... skipped: gdb is not available and moves on. This bug could sit unnoticed indefinitely unless someone happens to run the suite on a box that actually has gdb + passwordless sudo.
- Generation stamps diverge permanently on a standby built from a basebackup that includes a save directory
saveGeneration lives in the metapage, which is WAL-logged (GenericXLogFinish) and so replicates continuously. generation.bin and tidmap.bin are plain files — never WAL-logged, frozen at whatever a base backup captured. The primary's very next checkpoint after that backup advances the replicated metapage generation past what the standby's own frozen files say, and they disagree from that point on.
Confirmed this isn't self-healing: ShouldCheckpoint has a pre-existing guard — "A standby cannot persist (no WAL in recovery); it never checkpoints" — so there's no path back to a consistent state short of promotion. Every load attempt on that standby for that index, forever, hits the mismatch branch and logs "torn save detected, will rebuild" — a corruption-shaped warning on a cluster where nothing tore.
It's not dangerous — checked both callers of VamanaCheckSaveGeneration (vamanascan.c, vamanaworkerwrite.c) and neither attempts a WAL write on mismatch, so PR #58's crash-loop hazard doesn't reopen — but it's a real, permanent, misleading operational signal on any standby built this way, and nothing in the current two commits addresses it.
The real-crash test parked the worker with gdb attached through sudo. That depends on gdb being installed, passwordless sudo, and the ptrace_scope setting, none of which CI provides, so the test skipped silently there and a green check said nothing about it. It was also sensitive to build details: a source-line breakpoint only resolves with debug info, and a function breakpoint only works if the static function is not inlined. The suite already builds with injection points and uses them to hold the background worker in other tests. Added two injection points to VamanaSaveIndexToDisk, one before the TID map write and one before the metapage update, which are the two places a crash can land. The test now attaches a wait action to each, waits for the worker to park there, and kills it with SIGKILL from the test script. No debugger or elevated privileges are involved, and the test runs wherever the server is built with injection points. - Add vamana-save-before-tidmap-write and vamana-save-before-mark-saved injection points to VamanaSaveIndexToDisk - Replace the gdb attach and breakpoint handling in the crash test with wait injection points and a SIGKILL from the test - Drop the sudo, gdb and Linux-only skip conditions in favor of the existing injection-point check Signed-off-by: Matt Welch <matt.welch@intel.com>
Signed-off-by: Matt Welch <matt.welch@intel.com>
Description
A checkpoint save writes a vamana index in three separate steps:
SVSSaveIndexoverwrites the graph/data/config files in place,VamanaSaveTidMapAtomicallyreplaces the TID map via temp-and-rename, andVamanaMarkIndexSavedwrites the metapage last. A crash between the first two steps left a newer graph beside an older TID map and metapage, and the loader accepted that mismatched state with no error, silently serving a large share of the index's rows as missing. This was reproduced both offline (splicing files from two confirmed-clean checkpoint generations) and with a real crash (killing the worker withSIGKILLwhile it was parked mid-save), losing roughly a third of the table's rows with nothing logged.The fix stamps the same generation value into three places as a save progresses: a new save-directory marker file written before the graph write begins, the TID map header, and the metapage. On load, all three must agree; any disagreement is treated as a torn save and the index is rebuilt from the heap instead of being served stale or partial. Writing the marker before the graph write (rather than after it completes) is what closes the window completely: a crash at any point from there through the end of the save leaves the marker already at the new generation while something else is still at the old one, which the generation check catches.
A simpler design that only cross-checks the TID map against the metapage would miss this: by write order, the metapage's generation is always at or behind the TID map's, so those two can agree with each other even when the graph itself is ahead. The marker file is what makes the graph's own progress visible to the check.
Because a mismatch discards the whole metapage rather than individual fields, a stale
nextExternalIdleft over from a torn save can never be read back either.Two injection points were added to
VamanaSaveIndexToDisk(vamana-save-before-tidmap-writeandvamana-save-before-mark-saved) so the crash test can hold the worker at the two places a crash can land. The orphaned-temp-file cleanup for the new marker checks the file through an open descriptor rather than by path, so the check and the delete refer to the same file.Related Issues
Type of Change
Pre-Merge Checklist
Build
makecompletes without errors or warningsmake installcompletes successfullyTests
make installcheck) and TAP tests (test/t/) pass with no failurestest/sql/and/ortest/t/test/modules/: it builds and passes (make -C test/modules/<module> installcheck)Documentation
docs/updated if architecture or usage changedTesting Notes
Full TAP suite: 65 files, 1465 tests, all passing (only the pre-existing
enable_slow_testsskip on the large-offset test). SQL regression suite: all files passing. The hardware-dependent targets also pass on a machine that supports them:prove_installcheck-hw(the two compression TAP files, 55 tests) and thehalfvec_compressionandvector_compressionregression tests. The persistence compression test matters here because it saves and reloads real LeanVec and LVQ compressed indexes.Two new TAP tests cover the fix directly:
test/t/75_torn_graph_offline.plreproduces the torn state offline by splicing a TID map (and separately, the generation marker) from an older save generation onto a newer one, and confirms a clean restart, a mismatched TID map, a mismatched marker, and an absent marker (standing in for a save made before this fix existed) are all rejected and rebuilt with full recall.test/t/76_torn_graph_natural_crash.plattaches awaitinjection point at the two places a real crash can land inside the save sequence (before the TID-map write, and before the metapage update), kills the parked worker withSIGKILL, and confirms both are now detected and rejected rather than served. It needs a server built with--enable-injection-points, which CI already uses, and no debugger or elevated privileges.test/t/64_vamana_checkpoint_durability.plwas updated for the TID map header's new size (the generation stamp adds 8 bytes).To confirm both new tests actually exercise the defect, the generation checks were disabled and both tests were rerun. The offline test showed 35-45 of 80 rows missing from every query vector with no error logged. The crash test's K1 case (killed before the TID-map write) returned noticeably fewer rows than a sequential scan with nothing logged, and its K2 case failed the check that the torn save is reported. K2 loses no rows on its own, since the TID map and graph are already current at that point and only the metapage lags. The checks were then restored and the full suite rerun clean.