Skip to content

Count page references and compact from the victim segment - #81

Merged
venkat1701 merged 18 commits into
mainfrom
feat/reference-counts
Oct 11, 2026
Merged

venkat1701 merged 18 commits into
mainfrom
feat/reference-counts

Conversation

@venkat1701

Copy link
Copy Markdown
Collaborator

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

  • Reference counts. data/segments/pages.ref holds 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.
  • Dead pages wait for readers. A page whose count reaches zero is marked with the generation it died in. A checkpoint releases it once no retained generation or open reader is older than that, which clears its directory entry and takes its bytes off its segment's live total. Readers now check after pinning that their generation is still retained, and trimming puts a generation back if it races with a pin, so a reader can't hold a generation whose pages were released.
  • Crash safety. pages.ref only changes at checkpoints. Changed counts and released entries go to pages.ref.journal first, 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.
  • Leaf flag. A leaf sets a header bit when any of its values points at another page, and decoding checks it. The counter reads only the header of an uncached leaf without the bit, which keeps counting the replayed commits after a crash about as cheap as replaying them.
  • Compaction reads the victim, not the trees. Victims are picked from the per-segment live counts. PageStore.evacuate reads 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.
  • No more bulk-load fence. Pages a load spills before commit have directory entries, so evacuation moves them like any other page, and a transaction that loaded data before a compaction now commits. Pages spilled by a transaction that aborts, conflicts or repeats an earlier request are marked dead straight away.
  • Maintenance. Background compaction runs whenever a segment is below compaction_live_ratio, one segment per pass. Checkpoints also run after checkpoint_wal_mb of 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 check compares the counts with a full walk, and liveness() returns the live counters instead of walking (the Studio segment view called it once per segment).
  • A separate commit fixes recovery of a branch created after the last checkpoint: redo applied its roots before the branch existed, and the next open failed with a NullPointerException. That bug is on main too.
  • Storage format goes to 5.

Testing

  • Full suite: 129 tests pass, and each commit builds and passes on its own.
  • New ReferenceCountTest runs 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 point CHECKPOINT_JOURNAL), and counts marked for rebuild being rebuilt.
  • Compaction and crash tests now check the counts after recovery too. The bulk-load test now commits instead of retrying, and fails if evacuation skips pages that aren't counted yet.
  • I broke the counter on purpose in eight ways (dropping a decrement, ignoring readers when releasing, skipping the journal on open, not counting replayed commits, leaking spilled pages, and so on). Each one fails at least one test.
  • Native image: loaded examples/clinical-claims.hql and hstore check reports 105 pages in use, 423 waiting for older readers with 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, and check passed 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:

#80, 4 GiB this branch, 4 GiB #80, 1 GiB this branch, 1 GiB
disk after load and churn, before compaction 1.4 to 1.5 GB 0.84 to 0.98 GB 1.5 to 1.6 GB 0.83 to 0.96 GB
full compaction 1.2 s 0.5 to 1.1 s 1.2 to 1.5 s 0.6 to 1.2 s
allocated on the compacting thread 1.0 GB 107 to 194 MB 0.9 to 1.1 GB 107 to 208 MB
disk after compaction 295 to 368 MB 261 to 372 MB 297 to 405 MB 246 to 376 MB
longest commit during compaction 21 to 22 ms 21 to 46 ms 23 to 32 ms 24 to 55 ms

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:

Workload scale 1 scale 4
ingest.nodes 0.97 1.01
ingest.edges 0.98 1.00
read.incidence 1.07 0.89
read.members 0.96 0.98
read.twohop 1.01 1.00
latency.read 0.94 0.94
write.update 1.02 1.02
latency.commit 0.95 0.90
mixed.read 0.92 0.82
churn.delete 0.89 0.96
churn.remove 0.97 0.89
recover 0.61 0.66
disk after load 197 MB vs 189 330 MB vs 1,039 to 1,072

The 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.commit is 5 to 10% lower, inside the spread at scale 4.
  • Disk at scale 1 is 8 MB bigger: the first chunk of pages.ref.

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
@venkat1701
venkat1701 changed the base branch from feat/compaction-by-moving to main October 11, 2026 09:19
# Conflicts:
#	docs/architecture/overview.md
#	docs/operations/configuration.md
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