Repository navigation
Count page references and compact from the victim segment - #81
Merged
Merged
Conversation
The reference counts file in the next commit is laid out the same way: a 64-byte header and one 8-byte entry per page number. MappedLongs holds the mapping, the header check and the chunked growth for both.
createBranch logs the new branch's Root records ahead of its BranchMeta record, and redo applied them in log order. A branch created after the last checkpoint therefore made recovery fail with a NullPointerException on the next open. Redo now applies BranchMeta records first.
Each page number now has a count of the pages and branch roots that point to it, kept in data/segments/pages.ref next to the page directory. A background thread updates the counts after every published commit, so the commit path only hands it the generation before and after. Pages that nothing points to any more are marked with the generation they died in, and a checkpoint releases them once no retained generation or open reader is older than that. Releasing clears the directory entry and lowers the live byte count of the segment the page sits in. The counts file is only written at checkpoints. The checkpoint writes the changed counts and the released directory entries to pages.ref.journal first, publishes the catalog, and then applies the journal, so a crash at any point leaves the counts as of one checkpoint or the next. Applying the journal happens after the commit lock is let go, with only the counter paused, and the segments are synced once before the lock is taken, so a checkpoint holds commits up for less time than before. On open the journal is applied if it matches the published checkpoint, and the counts are brought forward by replaying the generations recovered from the log. If the counter ever fails it marks the file for rebuild, and the next open recounts everything from the retained generations. A full scan of the directory on open also releases pages that no committed generation refers to, such as pages spilled by a transaction that was cut short by a crash. Pages spilled by a transaction that aborts or conflicts are handed back straight away. Reader pins are now checked against the retained history after they are taken, and trimming puts a generation back if a reader pinned it at the same time, so a reader can't hold a generation whose pages were released. Leaves now set a header flag when any of their values points at another page, and decoding checks it. The counter reads only the header of an uncached leaf without the flag, which is most of them, so counting the commits replayed after a crash costs little more than replaying them. liveness() reports the per-segment counters instead of walking the trees, and hstore check compares the counts against a full walk. Storage format goes to 5.
Compaction no longer walks every retained tree to find live pages. It picks victims from the per-segment live byte counts that the reference counts keep up to date, and moves pages by reading through the victim itself: every page header whose number still maps to that spot in the directory is copied to the active segment, and the directory entries are repointed every 4 MiB after a sync, as before. The work is proportional to the size of the victim instead of the size of the database, and the thread allocates nothing beyond the moved pages. Pages that a transaction has spilled but not yet committed are moved like any other page, since they are in the directory too. That makes the bulk-load fence unnecessary, so a transaction that loaded data before a compaction now commits instead of retrying. A segment is only chosen once every page allocated in it has been written. compact() takes a checkpoint first so that pages which became free since the last one count as free, and background maintenance now compacts one segment at a time whenever one falls below compaction_live_ratio rather than after a fixed amount of writing. It also checkpoints after every checkpoint_wal_mb of pages written, since that is what releases dead pages and a bulk load writes a lot of pages for very little WAL.
ReferenceCountTest runs random work over several seeds: inserts and removals, edge deletes, bulk loads, aborted loads, and branches that are written to and sometimes dropped. It checks the counts against a full walk of the trees after checkpoints and after reopening. Other cases: pages a pinned reader can still reach wait until it closes, aborted and replaced loads hand their pages back, pages spilled by a transaction cut short by a crash are released on open, the counts survive a crash with or without a half-finished checkpoint, and counts marked for rebuild are rebuilt. PageStoreTest covers a segment with an unwritten page not being settled, and evacuation moving only the pages the directory still points at. TreeTest checks that a leaf's header flag says whether it points at other pages. Compaction and crash tests now also check the counts after recovery.
maintenance.md replaces the liveness walk with the reference counts: how the counter follows commits, when a dead page is released, how the counts survive a crash and how they are rebuilt, and how compaction now reads through a victim instead of walking the trees. The bulk-load fence is gone from the compaction and transactions docs. pages.md documents pages.ref and its journal byte for byte, and the WAL and recovery doc adds the journal to the checkpoint steps, the counting of replayed generations to recovery, and the order in which redo applies a new branch. The overview, logging, docker and development pages pick up the new files, thread, log lines and format 5. The directory listing and the hstore check example are fresh output from the native build.
The lock was only released by close(). If the constructor threw, for example in the checkpoint at the end of recovery, the same process could not open the database again.
…points A rebuild writes the counts of the recovered generation straight into pages.ref, but the catalog still names the older checkpoint until the open publishes its own. If the open crashed in between, the next one trusted the file and counted the replayed commits a second time. A branch dropped since the checkpoint was then dropped twice, the pages it shared with main were marked dead, and they were released. The file now stays marked for rebuilding until that checkpoint is published. A new crash point, CHECKPOINT_CATALOG, sits just before a checkpoint publishes its catalog.
If a checkpoint failed after publishing its catalog, for example while syncing the WAL or applying the journal, the engine kept running with that journal not yet applied. The next checkpoint overwrote it, and a crash before that checkpoint published left pages.ref a checkpoint behind the catalog. The open then released every page written in between as an orphan. The next checkpoint now applies the published journal first. If publishing the catalog itself fails, it isn't known whether the catalog reached the disk, so the counts are marked for rebuilding. The crash tests only inject faults on the test thread, so a background checkpoint can't take the fault meant for the test.
…nk header A missing pages.ref was created empty and trusted, so the open released every page in the directory. It is now marked for rebuilding when the directory already has pages. The header of a new pages.ref or pages.dir is forced as soon as it is written, and a header that is still all zeros, left by a crash before that, is treated as a new file instead of an unknown format.
A spill writes its pages before it logs them, so for a moment a segment could look settled while the PageRef records of its newest pages weren't in the WAL yet. Compaction could then move those pages and retire the segment, and a recovery that replayed the late records would point the pages back at the deleted segment. Spilled pages now count as unwritten until their records are appended.
evacuate skipped a directory entry that pointed into the victim without a valid page header there, and the victim was retired and deleted anyway, so the page was lost for good. After moving, the victim's live total now has to be zero. If it isn't, the pass fails and the victim goes back to SEALED.
Only runtime exceptions were caught, so an OutOfMemoryError while walking a tree ended the counter thread. Its queue then filled up and every commit blocked on it. Errors are now handled like other failures: the counts are marked for rebuilding and the queue keeps draining.
Pages are really released now, so a long check could walk a generation whose pages were freed under it and report corruption that isn't there.
# Conflicts: # docs/storage/maintenance.md # engine/src/main/java/io/hstore/engine/maintenance/Compactor.java # engine/src/main/java/io/hstore/engine/page/PageDirectory.java
# Conflicts: # engine/src/test/java/io/hstore/engine/page/PageStoreTest.java
# Conflicts: # docs/architecture/overview.md # docs/operations/configuration.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 #80. Phase 3 of the page directory work: the engine counts references to every page as it commits, and compaction no longer walks the trees.
Why
After #80 compaction moved pages instead of rewriting trees, but it still found the live pages by walking every retained tree. At scale 4 that walk allocated about 1 GB, and its cost grew with the size of the database rather than with what was being cleaned. It also meant background compaction only ran every 256 MB of writing, and bulk loads that overlapped a compaction had to retry.
What changes
data/segments/pages.refholds one count per page number: how many branch roots, branch nodes and leaf values point at it. A background thread (hstore-references) updates the counts after each published commit by comparing the roots before and after. The commit path only puts the pair of generations on a queue. A node that a commit copies cancels out against its old version, so only the pages a commit really added or dropped get written.pages.refonly changes at checkpoints. Changed counts and released entries go topages.ref.journalfirst, the catalog is published, and then the journal is applied, so after a crash the counts match one checkpoint or the next. On open the journal is applied if it belongs to the published checkpoint, the counts are brought forward over the generations replayed from the WAL, and a scan of the directory releases pages that no committed generation refers to (pages a crash cut off from their transaction). If counting ever fails, the file is marked and the next open rebuilds the counts from the retained generations.PageStore.evacuatereads through the victim and copies every page whose directory entry still points at that spot, repointing the entries every 4 MiB after a sync as before. A segment is only picked once every page allocated in it has been written. Work is proportional to the victim.compaction_live_ratio, one segment per pass. Checkpoints also run aftercheckpoint_wal_mbof page writes and when a million counts have changed, so dead pages are released during bulk loads and the in-memory change map stays small. A checkpoint now syncs the segments before it takes the commit lock and applies the journal after letting it go.hstore checkcompares the counts with a full walk, andliveness()returns the live counters instead of walking (the Studio segment view called it once per segment).NullPointerException. That bug is on main too.Testing
ReferenceCountTestruns random work over three seeds (inserts and removals, edge deletes, bulk loads, aborted loads, branches written to and sometimes dropped) and checks every count against a full walk after checkpoints and after reopening. It also covers pages a pinned reader can still reach waiting until it closes, aborted, replaced and duplicate loads giving their pages back, pages spilled by a transaction cut short by a crash being released on open, the counts surviving a crash with and without a half-applied checkpoint (new crash pointCHECKPOINT_JOURNAL), and counts marked for rebuild being rebuilt.examples/clinical-claims.hqlandhstore checkreports105 pages in use, 423 waiting for older readerswith the counts matching a full walk. SIGKILL partway through a 20,000-insert load, reopen replayed 1,395 commits and the counts matched. One more write trimmed the history,COMPACT;emptied and deleted the old segment, andcheckpassed again.Timing
On a fresh Azure D8ds_v5 (8 vCPU, 32 GiB, local SSD). The same compaction probe as #80: load scale 4, delete 20% of the edges and remove 20,000 members, reopen, then run a full
compact()while a second thread commits single updates. Node cache 64 MiB, two runs each:Disk stays lower during the load because background compaction now runs whenever a segment is mostly dead, instead of every 256 MB of writes. The counter thread used about 7 s of CPU and allocated about 3 GB over the whole load and churn (roughly 600 large commits), and about 0.15 s and 2 MB during the 2,000 to 3,500 small commits of the compaction phase.
The HStore benchmark, #80 against this branch, default cache, throughput ratio (medians of 3 runs at scale 1 and 2 at scale 4). Runs of the same setup varied by up to 10 to 20% on many rows:
ingest.nodesingest.edgesread.incidenceread.membersread.twohoplatency.readwrite.updatelatency.commitmixed.readchurn.deletechurn.removerecoverThe costs, plainly:
mixed.read, readers running alongside a writer, is 8 to 18% lower. The counter and the more frequent background compaction compete with the readers for CPU. This is the main thing I'd like to bring down next.recover(opening after a SIGKILL halfway through loading) takes about 55 ms instead of 34 at scale 1 and about 330 ms instead of 200 at scale 4, for counting the replayed commits and scanning the directory. Before the leaf flag it was ten times slower than Compact by moving pages instead of rewriting trees #80.latency.commitis 5 to 10% lower, inside the spread at scale 4.pages.ref.