fix(store): serialize snapshots and compactions per database - #1479
Open
corylanou wants to merge 2 commits into
Open
fix(store): serialize snapshots and compactions per database#1479corylanou wants to merge 2 commits into
corylanou wants to merge 2 commits into
Conversation
5 tasks
PR Build Metrics✅ All clear — no issues detected
Binary Size
Dependency ChangesAdded:
Removed:
govulncheck OutputBuild Info
History (2 previous)
🤖 Updated on each push. |
Store.Open runs one goroutine per compaction level plus one for the snapshot level, and CompactDB did nothing to serialize work per database. On a large database an L9 snapshot and an L1 compaction can therefore run concurrently, and each retains a database-sized LTX page index, multiplying peak memory with database size: a 130 GiB database was OOM-killed in a 12 GiB container this way (#1477). Guard CompactDB with a per-database TryLock. A refused operation returns ErrMaintenanceBusy instead of queueing so that one database's long snapshot cannot stall the level monitor for every other database. The monitor logs the refusal and caps its next delay at MaintenanceBusyRetryInterval (10s) so the refused operation is retried soon after the in-flight one finishes; without that cap a snapshot that lost startup contention to L1 would wait for the next snapshot boundary, up to 24h. Compaction across different databases is unaffected. The snapshot reader's Close now joins the producing goroutine, so a snapshot whose replica write fails early cannot leave its WAL page map and encoder alive after CompactDB releases the lock. This bounds concurrent maintenance memory to a single operation per database. The page indexes themselves still scale with database size; shrinking them is addressed separately in superfly/ltx. Fixes #1477
Pins github.com/superfly/ltx to the head of superfly/ltx#95 (v0.5.3-0.20260827162011-d457a1ab7844) so this PR builds and soaks with the chunked encoder page index, which is the other half of the #1477 fix. Replace with the tagged ltx release before merging.
corylanou
force-pushed
the
fix/1477-serialize-db-maintenance
branch
from
August 27, 2026 19:10
4013591 to
10d628d
Compare
This was referenced Aug 27, 2026
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.
Summary
Store.Openruns one goroutine per compaction level plus one for the snapshot level, andCompactDBdid nothing to serialize work per database, so an L9 snapshot and an L1 compaction can run at the same time on the same database. Each holds a database-sized LTX page index, so peak memory multiplies with database size; a 130 GiB database was OOM-killed in a 12 GiB container this way (#1477).Store.CompactDBnow takes a per-databaseTryLock. A refused operation returns the newErrMaintenanceBusyinstead of queueing, so one database's long snapshot cannot stall the level monitor for every other database.MaintenanceBusyRetryInterval(10s) while the database stays busy. An early pass only re-attempts the databases that were refused (plus any registered since the last regular pass) and skips snapshot retention; regular passes stay anchored to the level's schedule. Without the early retry a snapshot that lost startup contention to L1 would wait for the next snapshot boundary (up to 24h); without the 1s start, levels whose boundaries coincide (e.g. L1 15s / L2 30s in the LTX behavioral gate) would drift by a full 10s on every collision.Closenow cancels and joins the producer goroutine, so a snapshot whose replica write fails early cannot leave its WAL page map and encoder alive after the lock is released.This bounds concurrent maintenance memory to one operation per database. The page indexes themselves still scale with database size; shrinking the encoder's index is superfly/ltx#95, and this PR pins
go.modto that branch's head so it builds and soaks as the intended final combination. The pin must be replaced with the tagged ltx release before merge.Fixes #1477
Evidence
Measured with the litestream-soak
snapshot-compaction-overlaprig (corylanou/litestream-soak#196, corylanou/litestream-soak#197): it runs snapshot and L1 compaction sequentially as a baseline, then holds the snapshot's replica stream at 95% so the encoder's page index stays resident while the L1 compaction runs, samplingruntime.MemStats(GOGC=25) and writing a heap profile at each phase's peak. "Overlap" is the peak heap growth during that phase; the pass limit is 1.10× the larger sequential phase plus one multipart upload's fixed buffers. The "this PR" rows were built by the rig straight from this PR's head (go.modas committed, ltx pinned to superfly/ltx#95), not via a local replace.gate_release_reason=timeout, 364 busy retries)On main the overlap's memory is the sum of the two operations; with serialization it is the larger of the two; with ltx#95 the encoder index itself shrinks from ~95 B/page to ~18 B/page. Peak heap profiles (
inuse_space, 4 GiB run):Overlap cost: ~450 B/page on main, ~270 B/page with serialization alone, ~190 B/page for this PR as pinned — at the reporter's 34.2M pages that is ≈15 GB → ≈9.3 GB → ≈6.5 GB. The decoder-side
DecodePageIndexmap is now the biggest remaining per-page term (~80 B/page) and is called out as follow-up work in superfly/ltx#95.The same head is also deployed as the
pr-1479litestream-soak fleet (15 workers incl. two 100-database workers that upload hourly heap/alloc/goroutine/CPU pprof captures) for a longer-running comparison againstmain.Known limits
TryLockis not fair; under continuous contention on one database a level could repeatedly lose to another. Defaults make that unlikely (snapshots are daily, compactions minutes apart).Store.CompactDBtakes the lock. DirectDB.Snapshot/DB.Compactcallers (e.g.replicate -force-snapshot, which runs with monitors disabled) are not serialized.IsOpencheck andDisableDB/UnregisterDBis unchanged; a re-registered path gets a freshDBand therefore a fresh lock.Test plan
tests/integrationLTX behavioral gate (-short) passes locally with the 1s→10s backoff (it failed on the first push, where a 10s retry after every L1/L2 boundary collision fell outside the ±50% timing window)TestStore_CompactDB_SerializesPerDBMaintenance: same-DB compaction refused withErrMaintenanceBusywhile a snapshot is held, other DB unaffected, lock available after releaseTestBusyRetryDelayfor the monitor delay capgo test ./root package,go vet ./..., pre-commit (goimports, vet, staticcheck)