Skip to content

WIP: feat(nds): implement on-disk gc - #1201

Draft
emturner wants to merge 11 commits into
emturner@working-tipfrom
emturner@disk-gc-fullstack
Draft

WIP: feat(nds): implement on-disk gc#1201
emturner wants to merge 11 commits into
emturner@working-tipfrom
emturner@disk-gc-fullstack

Conversation

@emturner

@emturner emturner commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

What

Why

How

Manually Testing

make all

Regressions

Tasks for the Author

  • Link all Linear issues related to this MR using magic words (e.g. part of, relates to, closes).
  • Eliminate dead code and other spurious artefacts introduced in your changes.
  • Document new public functions, methods and types.
  • Make sure the documentation for updated functions, methods, and types is correct.
  • Add tests for bugs that have been fixed.
  • Explain changes to regression test captures when applicable.
  • Write commit messages in agreement with our guidelines.
  • Self-review your changes to ensure they are high-quality.
  • Complete all of the above before assigning this MR to reviewers.

@emturner
emturner changed the base branch from main to emturner@working-tip September 4, 2026 12:07
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown

Durable storage space metrics for revision b780fe8, at pull request
scale. The scale itself lives in durable-storage/Makefile.

Metric Value Unit
dead_node_bytes_per_commit 1.48 MiB
dead_node_fraction 37.4 %
repo_bytes_per_commit 7.23 MiB
checkpoint_shared_fraction 99.0 %
checkpoint_shared_fraction_steady 99.0 %
Full report
repository: /tmp/nix-shell.HXU3Wt/.tmpQn9gd6
shape: 1 database(s) x 100000 keys x 32 B values, 10 commits x 1000 modified keys
prepopulated 100000 keys in 0.5s
committing the base state...
base state committed as bbcc53be48c9f9285a3607046839524e6d2152561d2e95d9e760e6a665d72d44 in 1.9s

 commit    blob MiB    live MiB    dead MiB   dead %   value MiB    disk MiB  commit ms  commits MiB     new MiB  shared %  rewrote MiB
      0        24.8        24.8         0.0     0.0%        15.4        95.3          0         16.7           -         -            -
      1        26.3        24.8         1.5     5.6%        15.4        95.5        136         16.9         0.2     99.0%          0.0
      2        27.7        24.8         2.9    10.6%        15.4        95.7        132         17.1         0.2     99.0%          0.0
      3        29.2        24.8         4.4    15.2%        15.4        95.9        131         17.3         0.2     99.0%          0.0
      4        30.7        24.8         5.9    19.2%        15.4        96.1        132         17.5         0.2     99.0%          0.0
      5        32.2        24.8         7.4    22.9%        15.4        96.2        129         17.7         0.2     99.0%          0.0
      6        33.7        24.8         8.9    26.3%        15.4        96.4        132         17.9         0.2     99.1%          0.0
      7        35.1        24.8        10.3    29.4%        15.4        96.6        135         18.0         0.2     99.1%          0.0
      8        36.6        24.8        11.8    32.3%        15.4        96.8        134         18.2         0.2     99.1%          0.0
      9        38.1        24.8        13.3    34.9%        15.4        97.0        136         18.4         0.2     99.1%          0.0
     10        39.6        24.8        14.8    37.4%        15.4       167.6        138         18.6         0.2     99.1%          0.0

over 10 commit(s):
  dead node data grew by 14.8 MiB, 1.48 MiB per commit
  Merkle store grew by 14.8 MiB, 1.48 MiB per commit
  59689 node(s) written, 5968 per commit, averaging 260 B stored per node
  108081 edge(s) written, 1.81 per node, 7.4 MiB, adding 50.1% to the Merkle store
  history pins 18.5 MiB (0.0 MiB Merkle, 18.4 MiB values, 0.1 MiB other), growing 0.2 MiB per commit
  0.0% of what the history pins is Merkle node data; the rest is values. Nodes live in the repository-wide store, outside the commit directories, so this is whatever the blob column family still holds for other users of it
  sharing: 99.0% of the bytes checkpoints pinned were already on disk (174.7 MiB of 176.3 MiB), adding 0.2 MiB in 1 new file(s) per commit
  levels at the last commit:
    L0: 11 file(s), 18.4 MiB
  37.4% of the Merkle store is now dead (14.8 MiB of 39.6 MiB)
  repository occupies 167.6 MiB across 11 commit directories (283.4 MiB apparent, and hard links save 193.4 MiB)

catching up: collecting 0 commit(s) behind the tip, over a store nothing has collected before

collection: removed 9 database commit(s), 9 manifest(s), and 30444 Merkle node(s) with 82740 edge(s)
  7.5 MiB of node data deleted
  examined 153675 node(s) to find them
  took 4521 ms: 1 ms over the commits, 4128 ms sweeping nodes, 392 ms compacting
  peak memory 157 MiB, up 22 MiB over the sweep
  repository went from 167.6 MiB to 88.2 MiB, freeing 79.4 MiB
  retained history now occupies 18.5 MiB
  Merkle store held 39.6 MiB before collecting, of which 24.8 MiB was reachable from the last commit

These figures are for catching a regression before it merges. Compare them with
the longer run on the default branch, which measures more commits.

@codecov

codecov Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.01888% with 357 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (emturner@working-tip@8fa5eee). Learn more about missing BASE report.

Files with missing lines Patch % Lines
durable-storage/src/merkle_store.rs 81.99% 83 Missing and 27 partials ⚠️
durable-storage/src/storage/in_memory.rs 53.57% 50 Missing and 2 partials ⚠️
durable-storage/src/repo.rs 84.67% 19 Missing and 19 partials ⚠️
durable-storage/src/merkle_store/slots.rs 87.36% 19 Missing and 17 partials ⚠️
durable-storage/src/collect/mod.rs 94.64% 16 Missing and 13 partials ⚠️
durable-storage/src/collect/sweep.rs 78.16% 3 Missing and 16 partials ⚠️
durable-storage/src/gc_space/scenario.rs 45.71% 16 Missing and 3 partials ⚠️
durable-storage/src/persistence_layer.rs 85.50% 9 Missing and 1 partial ⚠️
durable-storage/src/avl/node.rs 88.33% 2 Missing and 5 partials ⚠️
durable-storage/src/gc_space/measure.rs 86.04% 1 Missing and 5 partials ⚠️
... and 10 more
Additional details and impacted files
@@                   Coverage Diff                   @@
##             emturner@working-tip    #1201   +/-   ##
=======================================================
  Coverage                        ?   90.07%           
=======================================================
  Files                           ?      159           
  Lines                           ?    35178           
  Branches                        ?    35178           
=======================================================
  Hits                            ?    31686           
  Misses                          ?     2387           
  Partials                        ?     1105           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@emturner
emturner force-pushed the emturner@working-tip branch from 3b3b26d to c927784 Compare September 7, 2026 16:59
@emturner
emturner force-pushed the emturner@disk-gc-fullstack branch from a636fb7 to 8929ec2 Compare September 7, 2026 16:59
emturner and others added 11 commits September 8, 2026 10:06
Committing rewrote every node resolved in memory, changed or not, because the only
thing that stopped the recursion was a child that had never been loaded - the whole
tree for a state just built, and everything touched since for one checked out. The
bytes were identical to what was already stored, but they are new writes as far as
RocksDB is concerned, so each commit flushed them into fresh files that no earlier
checkpoint shares.

A node now records which store its body reached, and committing skips it and the
subtree beneath: changing a descendant changes every hash above it, so an unchanged
node cannot have a changed descendant. invalidate_hash clears the memo, so it is
dropped by exactly the mutations that change what a node hashes to, and loading
records the store read from, so a checked-out tree does not rewrite what it touches.

It records the store rather than a bare flag because copy_database hands the
destination a copy of the source store and the same in-memory nodes. A node still
dirty then owes a copy to both, and a flag let the first commit satisfy the
obligation and the second skip it, leaving a commit referring to nodes that never
reached its store. Its low bit records whether the value data went too, so a commit
that must write it is not skipped on the strength of one that need not.

Also invalidates the hash in balance_factor_mut, so correctness does not depend on
rebalancing happening to run after something else already invalidated it.

What remains is compaction: the trigger counts level-zero files and every commit
forces a flush, so the base level is rewritten however little each commit wrote.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Compaction is unusually expensive when commits are retained. A checkpoint hard-links
the files live when it was taken, so rewriting a file ends sharing for every
checkpoint taken before it, and because keys are spread by hash a level-zero file
spans the whole key space and overlaps everything below - making each compaction a
rewrite of the entire base level.

Worse, the trigger counts files rather than bytes, and creating a checkpoint flushes
the memtable, so one level-zero file appears per commit however little it holds. At
RocksDB's default of four, compaction therefore ran every four commits regardless of
how much had been written, which is why storing only changed nodes did not help on
its own.

Raising the trigger makes the cadence coarser, and it helps reads too, so there is no
trade to balance: compacting often does not keep level zero small, because flushes
accumulate behind a compaction while it runs.

Bloom filters pay for the extra files. Both column families are looked up by whole
key and never scanned by range - blobs by content hash, values by their key - so a
filter lets a lookup skip a file outright, where before it consulted the index of
every file whose key range covered the key, which for hash-spread keys is most of
them. What the filters contribute on their own is unmeasured.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Collection needs to know which commits are newer than the one it is asked to
collect at. That cannot be read off the commits themselves: a commit id is the
registry root hash, so it says what a state contains and nothing about when it
was reached.

A repository now records each committed root in a journal beside the manifests,
as a sequence number that increases with every commit. Identity is untouched, so
committing the same state twice still produces the same id and the same manifest;
only the journal grows. Retention follows the recorded order rather than
ancestry, which keeps states whose provenance is unclear - one committed without
a parent is kept like any other. Collecting at a root keeps every root recorded
at or after it, and a root committed more than once keeps its highest position,
so re-committing an old state can only extend how long it survives.

Entries are fixed width and appended, so recording a commit does not rewrite the
journal and a write a crash cut short is recognisable by length alone. Such an
entry is dropped when read. The journal is written after the manifest, so an
interrupted commit leaves a manifest with no journal entry rather than a recorded
root with nothing behind it - and since the caller never received that commit id,
nothing can reference it and collection is free to reclaim it.

Nothing is deleted yet. This is the shared prerequisite for collecting either
half of a commit, and it sits on the repository trait so that the in-memory
backend records commits the same way the on-disk one does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fmv6auJua8dSB18iGy6rES
Collecting at a root drops the registry commits recorded before it, together
with the database commits none of the surviving registry commits still
reference. Database commits are content-addressed and therefore shared -
unrelated registry commits name the same one whenever their states agree - so
removal follows reachability rather than the order commits were made in.

This reclaims the value side of a commit. A commit directory is a set of hard
links, so unlinking the last directory referencing a file frees it, and values
are keyed by user key, so overwrites become obsolete versions compaction
discards. Merkle nodes are content-addressed and every version of a node is a
distinct live key, so no amount of directory removal reaches them; that needs
deletion of the node keys themselves and is the other half of the design.

Collection is safe to interrupt and repeat. It prunes the journal to the
retained roots, removes the manifests it dropped, then removes the unreachable
database commits, in that order, so everything retained is intact at each point
in between. Each step after the first enumerates what is present rather than
what the journal says should be, so a round left unfinished is picked up by the
next one. Pruning first is also what makes a stale target safe: a root an
earlier round already collected past is no longer in the journal, so collecting
at it is refused rather than taken as a floor that would retain commits whose
data has gone. Collection must not overlap a commit or another collection, and
that is the caller's responsibility for now.

The operations sit on the repository trait, so the in-memory backend collects
the same way the on-disk one does, and the space harness now drives the real
implementation rather than a copy of it - the reclaim figures it reports measure
what ships. The reachability logic the long test carried its own copy of is now
the one function.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fmv6auJua8dSB18iGy6rES
Node bodies lived in a blob column family inside each database's own instance.
A node reachable from several databases was stored once per database, and every
commit directory hard-linked the files holding every node its database had ever
written. Node data is the large majority of what a retained commit pins, so that
cost was paid again for each commit kept.

They now live in one store shared by the whole repository. Nodes are
content-addressed, so the same node written from anywhere is the same key and is
stored once. The store sits outside the per-commit checkpoints, so its
compaction churn is paid once rather than once per retained commit, and it gives
one place from which a node can be deleted - which no amount of directory
removal can do, and which collecting the Merkle side will need.

Measured at 20k keys over six commits: what the history pins falls from 13.0 to
3.9 MiB, all of it values and none of it Merkle; growth per commit from 0.7 to
0.1 MiB; sharing between consecutive commits from 82% to 93%; commit latency
from 57 to 21 ms. The number of nodes written is unchanged at 14119, which is
the signal that the same work is being done and merely stored once.

Nodes reach storage through their own operations rather than through the blob
ones. Those two had the same shape but want opposite things now: the PVM uses a
persistence layer as its own blob store and persists it by checkpointing, so its
blobs must stay in the instance, while nodes must leave it. Separating them
leaves that use untouched.

Since a commit directory no longer holds everything the state is made of,
checking one out takes the repository as well as the path. Committing syncs the
store before taking the checkpoint, so a commit never refers to a node that a
crash could take with it. The long tests' failure artifacts carry a checkpoint
of the store, without which they could only be replayed against the repository
that produced them.

RocksDB allows one writer per directory, so instances are shared per canonical
path and a repository handle holds its store open for as long as it lives.
Without that the store would close when the last database dropped and be
reopened by the next, which races: a handle stops being findable when its last
reference goes, but the directory lock it holds is released later.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fmv6auJua8dSB18iGy6rES
Collecting a node means knowing whether anything still holds it. Answering that
by traversing every retained root downwards costs the whole live set on every
round, which is the wrong shape: most nodes are not in question, and the ones
that are, are asked about individually.

As a node is stored, an edge is now recorded from each of its children to it, in
a `refs` column family of the Merkle store keyed by `child || parent`. Child
first, so every edge into one child is contiguous and its parents are found by
walking forward from its hash. Empty trees are never stored, so none is pointed
at.

Reverse edges rather than reference counts, because they are idempotent: writing
an edge twice, or removing it twice, leaves the same state, so a collection
interrupted part-way can simply be run again. A count incremented twice after a
crash is silently wrong for good.

Each edge carries a stamp, being how recently its child was proven reachable from
a root. Edges are written without one: the sequence number of the commit being
made is not settled while its nodes are being written, and an absent stamp is
below every floor, so it forces the walk that settles the question rather than
claiming anything. Filling them in is the sweep's job, and neither exists yet.

Edges piggyback on the dirty tracking that decides whether a node is written at
all, so a node already in the store re-records nothing.

Measured at 20k keys over six commits: 1.76 edges per node, adding 47.5% to the
Merkle store. That is a real cost and worth revisiting - but it falls on the
shared store rather than on retention, which is unchanged at 3.9 MiB of pinned
history, all of it values. In the per-database layout the same edges would have
been duplicated into every retained commit.

Nothing reads them yet, so behaviour is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fmv6auJua8dSB18iGy6rES
Removing commit directories can never reclaim node bodies. They are
content-addressed, so every version of every node is a distinct live key that
nothing deletes, and they live in a store shared by the repository rather than
in any commit directory. Left alone that store grows with the total number of
node writes over a repository's lifetime, not with the size of the state.
Collecting at a root now deletes them, and the edges that mentioned them.

Whether a node is still held is asked from the node upwards, by walking the
reverse edges, rather than by traversing every retained root downwards. A stamp
at or above the collection floor ends the walk at once: a retained root already
holds that child. Otherwise the walk climbs, and what it learns is written back
onto the edges that led to a live root, so the next collection reads the answer
rather than walking for it. Answers are memoised within a round, negative ones
too, since a dead node's children ask the same question next.

A stamp is only ever written where the node is provably reachable from the
stamped root. Nodes are immutable, so if a parent is reachable from a root and
refers to a child, the child is reachable from that root permanently. A stale
stamp costs a walk and an over-generous one retains garbage; neither can drop
something live. There are no cycles to guard against, a node's hash being
derived from its children's.

A database commit is held by every registry commit naming it, so it is retained
until the most recent of those goes; taking the highest sequence number is what
stops an older reference from deciding it.

Deleting a key only writes a tombstone, so collection compacts the store
afterwards - otherwise what it reports as freed would still be on disk.

Measured at 20k keys over six commits: 6772 nodes and 17233 edges removed, 1.7
MiB of node data, taking the repository from 17.6 to 14.9 MiB. The retained root
still checks out with every key readable, which is the property the tests hold
onto: subtrees shared between a dropped and a retained commit survive, because an
unchanged subtree keeps its hash and the retained root still reaches it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fmv6auJua8dSB18iGy6rES
Committing puts a database's values in a commit directory and syncs the nodes
they refer to, which is enough that a commit never outlives its nodes, but it
leaves the store as the only copy of them. A full commit takes another: a
checkpoint of the store into a numbered slot beside it. Recovery opens the most
recent slot. A slot is made by a checkpoint, so it is self-contained and needs no
write-ahead log, and hard-links rather than copies, so taking one costs almost
nothing.

Slots are numbered so that each name is fresh, and one is created by renaming a
finished directory to a name that does not exist yet, which is atomic and cannot
half-happen. A fixed name with a `CURRENT` pointer beside it would need the
pointer swapped separately, and renaming over an existing directory is not
available in any case: `rename(2)` fails with `ENOTEMPTY` on a non-empty target.
A directory left by an interrupted full commit is not a slot and the next attempt
replaces it.

Taking one is the caller's to schedule, not something committing does. It bounds
what a crash costs, and reaping the slot before it is what returns the disk that
compaction has since made redundant: a slot's hard links hold the files that were
live when it was taken, so the high-water mark is the live set plus about one
full-commit period of garbage. That makes the full-commit cadence the
reclamation cadence. Reaping never removes the last slot, so there is always
something to recover from.

The live store carries on unchanged across a full commit; whether to check
anything out again is the caller's decision, not this layer's.

Recovery copies a slot into place rather than moving it, so a recovery that fails
part-way leaves the slot intact, and refuses to run over a store that is still
there rather than mixing the two. It has to happen before anything opens the
store, since files appearing underneath an open RocksDB instance are not its own.

Leases, so that other processes can read a slot while reaping respects them, are
not here yet - nothing outside this process reads one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fmv6auJua8dSB18iGy6rES
Three things a collection needs before anything else depends on it.

Full commits are what other processes read, since the live store cannot be
opened by a second writer, and reaping was free to remove one while a reader
had it open. A reader now takes a lease on the slot it opens and reaping leaves
a leased slot for a later round. Leases are `flock`s, so the kernel releases one
held by a process that dies and a crashed reader cannot pin a slot forever, which
a lockfile checked by hand would allow. Several readers may hold the same slot;
only reaping needs it to itself, and it never waits for a reader. The lease is
taken beside the slot rather than inside it, so the remover can hold it across
the removal - which leaves a reader arriving at that moment able to take a lease
on a slot that has just gone, so opening one can still fail.

Collection is the longest thing the storage does, and a full commit or a reap
should not wait for one. It can now be asked to stop, and both halves check often
enough to stop promptly. Stopping is safe wherever it happens: what was removed
stays removed, each step leaves everything retained intact, and every step
enumerates what is present rather than what it expected, so the next round
finishes what the last left. A round that stopped says so, and stops before the
Merkle sweep rather than sweeping against a commit set that is still half
collected.

Reading a state while the nodes behind it are reclaimed used to be
indistinguishable from an inconsistent store. A store now records that it has
collected, and reports an absent node as collected rather than as missing commit
data. The two call for different responses: missing commit data means the storage
is inconsistent and there is nothing to be done, while a collected node means
checking the state out again resolves it. A store cannot know which of its absent
keys it deleted, so this says a collection is the likely explanation rather than
a certain one, and only ever in a store that has collected.

`libc` becomes a direct dependency for `flock`; it was already in the tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fmv6auJua8dSB18iGy6rES
A round considered every node in the store, whether or not there was any reason
to think it dead. The stamps on the edges give an early exit inside the walk, but
the walk was not the cost - the scan was, and it is paid in full however little
there is to reclaim. Measured at 9.5 microseconds and 250 bytes per node in the
store, which at a few hundred million entries is the better part of an hour and
tens of gigabytes for a round that might free almost nothing.

Each node is now listed under the commit it was last known to be held by, in a
column family keyed by sequence number and hash, the number first and big-endian
so that ordering by key is ordering by commit. A round reads only the entries
below its floor. A node a retained commit wrote, or one an earlier round proved
still held, sorts at or above the floor and is never looked at.

A candidate that turns out to be live is relisted under the newest retained root,
which lifts it out of the range future rounds scan until the floor passes that
root. Candidates are read in batches and settled as they are reached, and the
answers worked out for one batch are dropped with it, so a round holds a batch
rather than a store.

This needs the sequence number while the nodes are being written, so a commit now
reserves it before writing rather than taking it afterwards. A commit that then
fails leaves the number unused, which costs nothing: numbers order commits and
need not be contiguous. Asked of the store rather than the repository, so a
backend that records nothing can say so without callers knowing which kind of
repository they hold.

Measured at 200k keys, twenty commits, collecting five behind the tip: the round
that catches up on a store nothing has collected before examines 297,461 nodes in
6.4 s, and a later round over five commits of churn examines 34,823 in 0.81 s.
Across an eightfold range of store sizes the steady-state round grows 0.68 to
1.17 s - the tree-depth term, not the store - and the memory it needs does not
move at all.

Two things follow from this that did not before. Collection must run behind the
tip: collecting at the newest commit leaves no gap between the floor and the
newest retained root, so everything relisted falls below the next round's floor
and the live set is examined again every time. And compaction, which the round
does not force, is now the part that costs the store rather than the garbage, so
reclaiming disk belongs on the full-commit cadence.

A round that deletes anything also gives the store a new identity. A node is
skipped at commit time when it records being in the store, and after a deletion
that record is no longer evidence the body is there. Changing the identity makes
every such record stop matching, so the next commit writes what it touches. A
working state whose lineage a round had dropped would otherwise skip writing
nodes the round had just deleted and commit something referring to bodies that
are gone - and unlike a read, which errors, a commit would not have said so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deleting a node writes a tombstone; the bytes come back when compaction rewrites
the files without it. That rewrite costs the whole store rather than the garbage
and takes far longer than anything that would want to trigger it, so it is now a
background task started on its own.

Taking a full commit is a flush and a rename. Pairing it with a rewrite of the
store would make the cheap operation as slow as the expensive one, so it does
not, and neither does collecting. Starting a reclaim returns at once; a second
request while one is running is a no-op rather than a queued rewrite, since two
of them achieve nothing one does not.

The rewrite no longer excludes RocksDB's own compaction, which it did by
default. Disabling automatic compaction and waiting for what is already running,
for the length of a full-store rewrite, stalls writes behind it to no purpose:
the point is to reclaim disk, not to be the only thing doing so.

Reads, writes and commits carry on throughout, which the test holds onto: a full
commit and a further commit both go through while a reclaim is running, and the
commit made during it checks out afterwards.

The measurement harness still waits, because a run has to know what the rewrite
freed in order to report it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fmv6auJua8dSB18iGy6rES
@emturner
emturner force-pushed the emturner@working-tip branch from c927784 to 8fa5eee Compare September 8, 2026 09:06
@emturner
emturner force-pushed the emturner@disk-gc-fullstack branch from 8929ec2 to 0f555bb Compare September 8, 2026 09:06
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