Repository navigation
Compact by moving pages instead of rewriting trees - #80
Merged
Merged
Conversation
PageStore.copy reads a page, checks its header and checksum, and writes the same bytes to space claimed in the active segment, returning the new address. It doesn't touch the directory; the caller points the entry at the copy once the bytes are synced. Allocation and copying share the code that claims space and rolls segments. Directory entries are now written with release and read with acquire, so a reader that races a move sees the old address or the new one.
Compaction used to rebuild every node on the path to each page it moved, inside a commit that held the commit lock for the whole rewrite. At scale 4 a full pass allocated 6 to 9 GB and could not finish in a 4 GB heap. Page numbers are stable now, so a live page in a victim segment is copied byte for byte to the active segment and its directory entry is pointed at the copy. Copies are synced in 4 MiB passes before their entries change, so an entry never reaches the disk ahead of its bytes. No root changes and nothing commits, so writers carry on during compaction and the node cache stays valid. Liveness is one bit per page number. It walks the generations taken in a short cut under the commit lock, and only segments already sealed at that cut can be victims. Without the cut, a segment sealed during the walk could hold pages the walk never saw. A retired segment is deleted after the next checkpoint, which makes the new entries durable and drops the WAL records that still name the old addresses, and once no reader pinned at or before the move remains. History no longer holds segments back, since old generations reach moved pages through the directory too. Bulk loads that spilled pages before a compaction are still refused, now by counting compactions instead of comparing generations, and on the fast path as well. A crash mid-compaction leaves the segment SEALED for the next pass. COMPACTION_MOVE and COMPACTION_RETIRE are new crash points, kept out of the commit crash matrix.
CompactionTest checks that compaction commits nothing and deletes its victims, that a generation still in history reads back after the segments holding its pages are gone, that a reader from before the move keeps the old segment until it closes, that commits run during compaction without conflicts, and that a bulk load begun before a compaction is refused and then succeeds on retry. A crash after the moves or after retirement, with or without losing the directory writes since the last checkpoint, loses nothing. Each test reopens the engine before checking, so the reads come from disk rather than the node cache.
maintenance.md walks through the cut, the walk, the move and the rules for deleting a segment, and why moves need no WAL records. The transaction docs describe the compaction fence. Other pages drop the word relocation where compaction no longer rewrites anything.
…id-move The compaction count only went up after the pages had moved. A bulk load that began before the cut and committed while pages were moving passed the check, and its spilled pages, which the walk couldn't see, stayed in a victim that was then retired and deleted. The count now goes up when the cut is taken, under the commit lock, so that load fails with a retryable conflict.
A failed move left the victims in COMPACTING until a restart. Later passes only pick SEALED segments and reclaim only deletes RETIRED ones, so those segments were neither retried nor freed.
# Conflicts: # docs/storage/pages.md
This was referenced Oct 11, 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.
Stacked on #79. Phase 2 of the page directory work: compaction moves pages instead of rewriting the trees that point at them.
Why
Before this, compacting a segment meant rebuilding every node on the path to each live page in it, as one commit that held the commit lock for the whole rewrite. At scale 4 a full pass allocated 6 to 9 GB and ran out of a 4 GB heap, and every writer waited until it finished.
With #79 a page keeps its number wherever it is stored, so a page can move without anything that points at it changing.
What changes
PageStore.copychecks the header and checksum and writes the same bytes to the active segment. Every 4 MiB the copies are synced, and only then are their directory entries pointed at them, so an entry never reaches the disk ahead of its bytes. Nothing is decoded or re-encoded, no root changes and nothing commits.history_limitto pass, because old generations reach moved pages through the directory like everything else.Transaction.loadaren't reachable until the transaction commits, so compaction can't move them. A transaction carrying them is refused if a compaction finished after it began. That used to compare generations and only applied off the fast path; it now counts compactions and applies on both paths. Comparing generations also refused a retry that began right after a compaction, until some other commit happened.TransactionManager.rewrite,TreeWalker.relocateand the relocation fence are gone.COMPACTINGby a crash comes backSEALED.Testing
CompactionTest, where every check reopens the engine so reads come from disk rather than the node cache:COMPACTION_MOVEandCOMPACTION_RETIRE, each with and without losing the directory writes since the last checkpoint, lose nothing.COMPACT;then emptied the 268 MB segment that was left,hstore checkverified all 717 pages, and every row was there.Timing
On a fresh Azure D8ds_v5 (8 vCPU, 32 GiB, local SSD). A probe loads scale 4, deletes 20% of the edges and removes 20,000 members, then runs a full
compact(). A second thread commits single updates the whole time. Node cache 64 MiB, two runs each:On #79 the commit lock is held for the whole rewrite, so a commit that arrives during it waits seconds. Here commits run at the same speed during compaction as before it. Disk after compaction went from 0.9 to 1.1 GB down to 0.28 to 0.41 GB; the starting point differs between runs because background compaction already runs during the load.
Almost all of the 1 GB that is still allocated is the liveness walk decoding interior nodes and catalog leaves. Moving the pages themselves allocates about one copy of the live data in 4 MiB passes. Phase 3 replaces the walk with reference counts kept up to date at commit time.