Skip to content

Compact by moving pages instead of rewriting trees - #80

Merged
venkat1701 merged 8 commits into
mainfrom
feat/compaction-by-moving
Oct 11, 2026
Merged

venkat1701 merged 8 commits into
mainfrom
feat/compaction-by-moving

Conversation

@venkat1701

Copy link
Copy Markdown
Collaborator

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

  • Moving. For each live page in a victim segment, PageStore.copy checks 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.
  • Liveness is one bit per page number instead of a map entry per page. The walk covers the generations recorded in a short cut taken under the commit lock, with the group committer drained, and only segments already sealed at that cut can be victims. Without the cut, a segment that filled up and was sealed while the walk ran could hold pages the walk never saw; an early version of this branch lost pages exactly that way under the probe below.
  • Deleting a victim waits for the next checkpoint, which makes the new entries durable and drops the WAL records that still carry the old addresses, and for every reader pinned at or before the move to finish. It no longer waits for history_limit to pass, because old generations reach moved pages through the directory like everything else.
  • Moves aren't logged. After a crash before that checkpoint, recovery writes the original addresses back from the WAL. Those still hold the same bytes, because the victims haven't been deleted.
  • Bulk loads. Pages spilled by Transaction.load aren'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.relocate and the relocation fence are gone.
  • A segment left COMPACTING by a crash comes back SEALED.

Testing

  • Full suite: 112 tests pass, and each commit builds and passes on its own.
  • New CompactionTest, where every check reopens the engine so reads come from disk rather than the node cache:
    • compaction commits nothing, and its victims are deleted;
    • a generation still in history reads back after the segments holding its pages are gone;
    • a reader from before the move keeps the old segment until it closes;
    • commits run alongside three compactions with no conflicts;
    • a bulk load started before a compaction is refused, and the retry succeeds;
    • crashes at the two new crash points, COMPACTION_MOVE and COMPACTION_RETIRE, each with and without losing the directory writes since the last checkpoint, lose nothing.
  • I checked the tests catch the mistakes they target. Skipping the directory update fails four of them. Deleting a victim while a reader is open fails the reader test. Limiting the bulk-load check to the slow path fails the bulk-load test.
  • Native image: 30,000 single-row commits filled 9 segments, and background compaction deleted 7 of them during the load. COMPACT; then emptied the 268 MB segment that was left, hstore check verified 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:

#79, 4 GiB heap this branch, 4 GiB heap this branch, 1 GiB heap
full compaction out of memory, both runs 1.2 to 1.5 s 1.2 to 1.4 s
allocated on the compacting thread 5.2 to 5.5 GB before failing 1.0 to 1.2 GB 1.0 GB
GC during compaction 21 to 62 collections, 2.2 to 6.0 s 2 to 3 collections, under 45 ms 12 to 15 collections, 140 to 230 ms
longest commit during compaction 3.3 to 7.1 s 21 to 32 ms 25 to 29 ms
commit p99 during compaction (before) 0.40 to 0.42 ms (0.41 to 0.65) 0.41 to 0.42 ms (0.54 to 0.56) 0.39 to 0.42 ms (0.41 to 0.76)

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.

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.
@venkat1701
venkat1701 changed the base branch from feat/page-directory to main October 11, 2026 09:17
@venkat1701
venkat1701 merged commit 7bebeff into main Oct 11, 2026
@venkat1701
venkat1701 deleted the feat/compaction-by-moving branch October 11, 2026 09:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant