perf(restore): drop unused page index map on restore decode paths - #1497
Open
corylanou wants to merge 4 commits into
Open
perf(restore): drop unused page index map on restore decode paths#1497corylanou wants to merge 4 commits into
corylanou wants to merge 4 commits into
Conversation
The ltx decoder builds a map[uint32]PageIndexElem with one entry per page during Close. On restore paths the index is never read, so for large databases the map is built and immediately discarded at peak memory (issue #1486, ~30M pages). Add newRestoreDecoder, which disables page index retention, and use it on the three decode paths that never read the index: Replica.Restore, Replica.applyLTXFile, and Hydrator hydration. Validation of the index in ltx Decoder.Close is unaffected by the flag. Claude-Session: https://claude.ai/code/session_01JAbGJy6uwUcj87ZRJkVXnF
Address adversarial review findings: prove structural index validation still runs with retention off (out-of-order entry rejected identically in both retention modes, before any checksum comparison) and exercise post-apply checksum validation with a checksum-tracked snapshot fixture, which the NoChecksum fixture could not reach. Claude-Session: https://claude.ai/code/session_01JAbGJy6uwUcj87ZRJkVXnF
PR Build Metrics
Binary Size
Dependency ChangesNo dependency changes. govulncheck OutputBuild Info
History (3 previous)
🤖 Updated on each push. |
The restore pipe's compactor never enabled index spilling, so its encoder held a 24-byte entry per output page in memory (~687 MiB at 30M pages) for the duration of the restore. Spill into the restore output directory for Replica.Restore and the hydration file's directory for the VFS Hydrator, matching store compaction and snapshot encoding. Claude-Session: https://claude.ai/code/session_01JAbGJy6uwUcj87ZRJkVXnF
If DecodeDatabaseTo returns early, the compactor goroutine on the other side of the pipe stays blocked in a pipe write, so its deferred Cleanup never runs and an active spill file is left behind. Close the pipe reader with the decode error, mirroring the store compaction path. The hydrator additionally waits for the compactor goroutine to exit so its caller-side Cleanup cannot run concurrently with Compact. Claude-Session: https://claude.ai/code/session_01JAbGJy6uwUcj87ZRJkVXnF
corylanou
force-pushed
the
perf/1486-restore-skip-page-index
branch
from
August 31, 2026 22:30
cdb854c to
ca66a47
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Adds two unexported helpers and wires them into the restore paths:
newRestoreDecodercallsltx.Decoder.SetRetainPageIndex(false)soClose()validates but does not materialize the per-page index map.newRestoreCompactorsets the restore header flags and enables output page-index spilling vialtx.Compactor.SetSpillDir, used byReplica.Restore(spills to the restore output directory) and the VFSHydrator(spills to the hydration file's directory).newRestoreDecoderis used on the three decode paths that materialize a database file and never read the decoder's page index:Replica.Restore(replica.go) — the restore path from Restoring a large database OOMs at the very end — ltx.Decoder.Close() builds a full-DB page-index map that the restore never uses #1486Replica.applyLTXFile(replica.go) — follow-mode LTX application, which also callsClose()Hydratorhydration (vfs.go) — same compactor-into-DecodeDatabaseTopatternThe other six
ltx.NewDecodercall sites were audited and left unchanged:db.go:1704,db.go:2795,vfs.goreadPageSizeFromInfo, andHydrator.ApplyLTXdecode only the header (or never callClose()), so no index is ever built there;db.goPos()usesVerify()on a single small L0 file and is out of scope for this change.Motivation and Context
Fixes #1486 — with #1483, which this PR is based on and depends on.
Full credit to @darkgnotic for the diagnosis: the issue includes the exact call path, both allocations, and an RSS graph showing a ~124 GB restore flat at ~8 GB until the final instant, then OOM-killed past 20 GB.
Decoder.Close()in ltx had two per-database costs on the restore path, both wasted becauseDecodeDatabaseTonever readsdec.PageIndex():io.ReadAll(dec.r)slurping the whole trailing page-index block into one[]byte.DecodePageIndex(...)building amap[uint32]PageIndexElemwith one entry per page (~2.5 GB at ~30M pages).The split between the two PRs: the ltx bump in #1483 (
v0.5.3-0.20260828134549) removes cost 1 by streaming the index instead of slurping it, and adds theSetRetainPageIndexopt-out — but nothing in litestream called it, so the map (cost 2) was still built. This PR wires up the opt-out. Neither PR alone fully closes #1486; this one completes it.Correctness note: with retention off,
Decoder.Close()in the pinned ltx version still streams, checksums, and validates the page index (entry ordering, overlap, entry count vs. decoded pages, page-sequence hash, file checksum, post-apply checksum) — it only skips materializing the map, andPageIndex()returns nil.Adversarial review also flagged the last per-database allocation on the restore pipe: the
ltx.Compactor's encoder kept its own output page index in memory (compact 24-byte entries, ~687 MiB at 30M pages) because the restore paths never calledSetSpillDir, unlike store compaction (compactor.go:173) and snapshots (db.go:2890). This PR fixes that too via anewRestoreCompactorhelper:Replica.Restorespills into the restore output directory and the VFSHydratorinto the hydration file's directory — both writable by construction, consistent with the spill-dir rationale from #1477 (hardened images may lack/tmp). The compactor's input decoders already disable retention upstream, so restore memory no longer scales with database page count anywhere in the pipe.How Has This Been Tested?
TestNewRestoreDecoder(written first, watched fail): assertsPageIndex()is nil afterDecodeDatabaseTowith the helper while decoded bytes are intact, that the stock decoder still retains the index (guards against silently relying on an upstream default change), and that a corrupted page index still fails the decode with retention off (validation is not skipped).NoChecksumfixture alone could not exercise that path.TestNewRestoreCompactor: compacts a snapshot plus an incremental through the spill-configured helper intoDecodeDatabaseTo, verifying the merged output and that the spill directory is left empty after cleanup. (Honest limitation: the fixture is far below the 1M-entry spill threshold, so actual spill mechanics are covered by ltx's ownencoder_spill_test.go, not here — the compactor API does not expose the threshold.)TestNewRestoreCompactor_ConsumerAbort: closes the pipe reader mid-stream and assertsCompactunblocks with an error and cleanup leaves the spill directory empty — the contract the error paths below rely on.A second adversarial review pass on the spill commit found that neither restore path closed the pipe reader when
DecodeDatabaseTofailed, leaving the compactor goroutine blocked in a pipe write with its deferred cleanup (and any active spill file) stranded. Fixed by closing the reader with the decode error, mirroring store compaction'sCompactor.compact; the hydrator additionally waits for the compactor goroutine to exit so its caller-sideCleanupcannot run concurrently withCompact. That pass also confirmed the spill temp file (.ltx-page-index-*.tmpviaos.CreateTemp) cannot collide with the restore temp output and is rejected by litestream's directory discovery, and flagged one pre-existing, unrelated data race (h.compactorwritten inRestorevs. read inStatus) that predates this PR and is left untouched.go build ./...andgo build -tags vfs ./...go test -race -count=1 ./...— greengo test -tags vfs -race -count=1 ./cmd/litestream-vfs/...— compared against a baseline run at this PR's base commit (perf: spill large page indexes into the db meta directory #1483 tip); failures match the pre-existing nondeterministic set (vfs: test suite is red on main and CI never runs it #1449) — see comparison in the PR discussion if neededpre-commit run --all-files— cleanTypes of changes
Checklist
go fmt,go vet)go test ./...)https://claude.ai/code/session_01JAbGJy6uwUcj87ZRJkVXnF