Skip to content

fix(history): publish the pruning boundary before reclaiming behind it - #12954

Merged
svlachakis merged 20 commits into
masterfrom
perf/pruner-range-delete
Aug 23, 2026
Merged

fix(history): publish the pruning boundary before reclaiming behind it#12954
svlachakis merged 20 commits into
masterfrom
perf/pruner-range-delete

Conversation

@svlachakis

@svlachakis svlachakis commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Pruning does not finish today. On a node syncing mainnet from genesis with History.Pruning=Rolling, observed passes look like this:

Pruning historical blocks up to #11894880 (11894842 estimated) ...
Block pruning operation timed out at #39. Deleted 1 blocks.

One to five blocks per pass, against a backlog the node itself estimates at ~12M. That is not slow, it is non-terminating - and identical at PruningTimeoutSeconds of 0, 2 and 60, because the timeout was never the binding constraint.

Why

Two independent causes.

The work is enormous. Per block: LoadLevel, a full body read and RLP decode via FindBlock, a second FindBlock inside UpdateBlocksDeletePointer on every iteration (though the wire announcement is throttled to % 10000), one tombstone per transaction into the tx index, and a metadata write. Clearing 19M blocks means reading roughly as much as it frees. The body is read only to enumerate transactions for the index.

The boundary is an outcome, not a decision. _blocksDeletePointer advances behind the deletion, one block at a time - and it is consensus-visible, feeding eth/69 BlockRangeUpdate and the RPC capability floor. So a starved pruner leaves the node announcing blocks it means to delete while the disk grows.

What this changes

The boundary moves first, in one metadata write. It is a policy decision from head and retention, so nothing can starve it. Raise-only, so a lower cutoff never walks it back onto data already gone.

The keys stop being visible in bounded steps. DeleteRange over the number-prefixed keys of blocks, receipts and access lists. A range tombstone costs the same whatever it spans, so a chunk is a handful of operations that read nothing.

And the disk actually comes back. A tombstone frees nothing on its own: it does not count towards pending-compaction bytes, and these column families keep RocksDB's 30-day periodic compaction default. Measured on a 2.1 GB column with half of it tombstoned - 0 bytes returned after 30 seconds. So each chunk also unlinks the SST files lying entirely inside its range, which for keys written in ascending block-number order is nearly all of them, and hints for the boundary files. The unlink is metadata-only. It is best effort by contract: the keys are already gone durably, so a failure costs the timing of the space returning and nothing else - it must not abort a pass that has already published a boundary, still less take the node down.

The announced boundary and the reclaim cursor are separate, persisted values. This is the load-bearing part. Everything the reclaim touches has already been declared absent, so a reclaim that is slow, cancelled or lost to a crash leaves the node honest and merely fat - it resumes from the cursor. Both the gate and the resume point read the cursor, never the boundary.

Stale transaction-index entries are swept, not left. Range reclaim cannot address them - they are keyed by transaction hash - so a bounded, resumable, cancellable walk drops the ones naming blocks that are gone. It is skipped while the pruning boundary is still below the Receipt.TxLookupLimit horizon, because there the existing per-block path still has the body it needs and does the work at no read cost.

Which side of that horizon a node sits on changed with #12808, so concretely, at a mainnet head of ~25.9M against the default TxLookupLimit of 2,350,000:

Mode Boundary Sweep
Rolling, default RetentionEpochs=82125 head − 2,628,000 skipped, per-block path still reaches the bodies
Rolling, mainnet floor 33024 head − 1,056,768 runs - #12808 lowered the chainspec floor from 82125, so this is newly legal
UseAncientBarriers, mainnet 24,600,000 (raised by #12808) runs

In the last two the body at head − TxLookupLimit has already been reclaimed, so PruneOldTxIndex cannot enumerate it and the sweep is the only thing bounding the column. The gate self-corrects as the chain grows: once head passes 26,950,000 the fixed barrier falls back below the horizon and the sweep stands down again.

Access list pruning no longer hangs off the block pass finishing. Worth being precise: PruneBlockAccessLists already checked the token per iteration, so the outer guard was redundant and removing it is a simplification, not a fix. Access lists were starved for the same reason blocks were - the whole pass never got to run.

Progress cannot reach zero. The scheduler stamps its deadline at enqueue, so a pass that waited behind other work arrives already cancelled - on a busy node, potentially every pass. All three passes therefore honour the token only after a unit of work rather than before one, and the sweep runs last because it is the only one whose cost its range does not bound. A pass that arrives spent takes a reduced chunk instead of a full one, since the one case it exists for is also the one case it runs alongside block processing.

Operator-visible changes

Two metric series are renamed, because their unit changed and leaving the old names would have made them lie. A range is dropped in one operation and never learns how many of its heights held a block, so both now count heights reclaimed:

Old series New series
nethermind_blocks_pruned nethermind_block_heights_reclaimed
nethermind_block_access_lists_pruned nethermind_block_access_list_heights_reclaimed

A dashboard or alert on an old name renders as "no data" rather than as an error, so this needs a changelog line. nethermind_transaction_index_entries_pruned is new.

Three members are removed from public interfaces, all of them with no production caller left: IBlockTree.DeleteOldBlock, OnNewOldestBlockArgs.isFinalUpdate and the SyncServer announcement throttle it fed, which could no longer be reached now the boundary moves in one jump.

No configuration is added or changed. The chunk size and the sweep budget are constants.

Tests

The bounds are the whole safety story, so they are pinned from both sides: lower inclusive, upper exclusive, neighbours untouched, an empty range a no-op, the tombstone surviving a reopen, every hash at every covered height going - including ones no chain level lists, which the per-block loop leaves behind forever - and the column-family path, which is the one receipts actually take.

At the pruner: the block at the boundary survives while the one below it goes, genesis and the sync pivot are never touched, a reclaim backlog survives a restart, a database holding a boundary with no cursor starts level with it rather than at genesis, and both the reclaim and the sweep still move when the budget is already spent.

Seven were written by breaking the fix first and confirming the test went red - the disk being returned, the Receipt.CompactTxIndex=false sweep, the cursor reaching disk, the backlog surviving a restart, the no-cursor migration, and the two spent-budget guarantees. Several others caught real defects while being written: a gate and a resume point still reading the boundary instead of the cursor, either of which would have looked like working pruning on a disk that never shrank.

On a real database

Since the above was written, this ran to completion on a mainnet archive node: a 1.9 TB database, syncing at ~95% and processing at ~1000 MGas/s throughout, so the pruner was competing for its budget the whole time rather than running on an idle box.

The boundary published on the first pass at 18:03:49. The reclaim cursor cleared the entire ~21.6M height backlog and reported 0 remaining at 18:18:10 - 14 minutes 21 seconds. Passes after that take 256 heights each, keeping pace with the boundary as it moves.

Before After
database total 1.9 TB 1022 GB
blocks/ 599 GB 142 GB
receipts/ 374 GB 223 GB

Roughly 900 GB returned, and the net figure understates it: flat/ and flatHistory/ grew by 7 GB over the same window because the node was still syncing.

Some notes from the run.

The space comes back from the bottom level. blocks/ lost 76% of itself on a database large enough that its block column is overwhelmingly bottom-level, which answers in the aggregate what the caveat above could not. What du still cannot separate is how much the file unlink returned directly versus how much the compaction hint returned shortly after - that would need SST-level counters. Either way the mechanism works end to end, and on master these tombstones would not exist to be compacted at all.

The reduced chunk is not a corner case. Five passes in six took the reduced 100,000 rather than the full 1,000,000, meaning the token was already cancelled before the loop began - the same state in which the old path managed single-digit blocks per pass. The occasional full chunk landed when the scheduler had real time to give it. Fourteen minutes for the whole backlog is what that floor buys on a node that never stops processing.

Heights reclaimed are not bytes reclaimed. The first 11M heights predate December 2020 and gave up only ~200 GB between them; the remaining 10M gave up ~700 GB. An operator watching du against the block counter early in a backlog will think it has stalled. blockAccessLists/ is 37 MB on this node because access lists are recent, so its cursor spends its first twenty-odd million heights crossing empty space.

A pass under load, and the last one. The count in parentheses on the first line is the heights added since the previous published boundary, not the outstanding backlog - the backlog is the remaining figure on the line below:

18:12:10 | Pruning historical blocks up to #21982560 (256 estimated) and block access lists up to #24497504 (11818750 estimated).
18:12:11 | Reclaimed historical blocks #12578754 to #13578753, 8403806 remaining.
18:12:11 | Historical block reclaim interrupted at #13578754; the boundary is already published at #21982560 and the next pass resumes from here. Reclaimed 1000000 blocks.
18:12:11 | Block access list reclaim interrupted at #13678754. Reclaimed 100000 access lists.

18:18:10 | Pruning historical blocks up to #21988192 (256 estimated) and block access lists up to #24503136 (2424382 estimated).
18:18:10 | Reclaimed historical blocks #21978754 to #21988191, 0 remaining.
18:18:10 | Block access list reclaim interrupted at #22178754. Reclaimed 100000 access lists.

Adds IRangeRemovableKeyValueStore alongside the other capability
interfaces, implemented over RocksDB's range tombstone and mirrored in
MemDb so callers can be tested without a real database. The cost does
not scale with how many keys the range holds, which only pays off where
the keys are ordered along the dimension being dropped - the block
stores are, being keyed by number then hash.

The tombstone goes through a write batch rather than the database
directly, so it reaches the WAL and a crash cannot resurrect a range its
owner has already stopped serving.

Nothing calls this yet.

The tests pin the bounds, which is the whole safety story: the lower one
inclusive, the upper one exclusive, neighbours on both sides untouched,
an empty range a no-op, the tombstone surviving a reopen, and every hash
at every covered height going - including the ones no level record
lists, which a per-block loop leaves behind forever.
Pruning could not finish. Each pass read two full block bodies and wrote
one tombstone per transaction, so clearing a 19M-block backlog meant
reading roughly as much as it freed - and observed passes managed one to
five blocks each, which does not terminate, it merely runs forever.

The boundary a pruning node announces is now a policy decision rather
than an outcome of disk throughput: it moves to the cutoff in one
metadata write, and the disk is given back behind it in bounded steps.
Removing a range costs the same whatever it spans, so a step is a
handful of operations reading nothing at all.

Splitting the announced boundary from a reclaim cursor is what makes an
interrupted pass safe. The boundary can never be starved, and everything
the reclaim touches has already been declared absent - so a reclaim that
is slow, cancelled or lost to a crash leaves the node honest and merely
fat, resuming from the persisted cursor. The gate and the resume point
both read that cursor, not the boundary; they part company the moment a
pass is cut short, and only the cursor knows where the disk stands.

The transaction index is deliberately left alone. Enumerating it is what
forced the body read per block, and its entries are an accelerator, not
the record: one resolving to a block that is gone already answers not
found. Reclaiming them wants a compaction filter, which is not here yet.

Access list pruning no longer hangs off the block pass finishing. That
gate meant it never ran at all, since the block pass was always cut
short, so access lists grew without bound.

Tests pin the boundary from both sides, that an interrupted pass leaves
no gap behind, and that genesis and the sync pivot survive. The first
two each caught a real defect: a gate and a resume point still reading
the boundary instead of the cursor, which would have looked like working
pruning on a disk that never shrank.
@claude

claude Bot commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Claude finished @svlachakis's task in 8m 8s —— View job


Review: PR #12954 — publish the pruning boundary before reclaiming behind it

  • Read the diff against origin/master
  • Review DeleteRange implementations (RocksDB, MemDb, SnapshotableMemDb, ColumnDb)
  • Review HistoryPruner boundary/cursor state machine
  • Review store-level range deletes (blocks, receipts, access lists)
  • Check tests and repo rules adherence
  • Post review

The core design is right and the diagnosis is convincing. Replacing an O(blocks) read-then-delete walk with O(1) range tombstones is the correct fix for a pass that reads as much as it frees, and separating the announced boundary from the reclaim cursor — so an interrupted pass leaves the node honest-and-fat rather than dishonest-and-thin — is the load-bearing insight. The half-open bounds are correct in both directions: GetBlockNumPrefixedKey writes the number big-endian, so [num‖0³², (num+n)‖0³²) covers exactly every hash at every height in range, and the DbOnTheRocksTests pin that from both sides. Routing through a WriteBatch so the tombstone reaches the WAL is the right call, and MemDb/SnapshotableMemDb both materialise Keys before iterating, so the remove-while-enumerating is safe.

Two findings block merge, both in the seams between the new range paths and the deferred-write machinery that the old per-block paths handled explicitly.

Critical

1. _pendingCanonical.Clear() permanently drops the deferred tx-index write for head blocksPersistentReceiptStorage.cs:900

That dictionary is not a cache; PersistDeferredCanonical treats presence in it as a cancellation ledger and returns without writing when an entry is absent. Clearing it turns every queued PendingCanonicalEntry — all of them canonical blocks at/near head, none in the pruned range — into a silent no-op, with no retry. eth_getTransactionByHash and eth_getTransactionReceipt then return null forever for every transaction in those blocks. The remark justifying the line ("the pending overlay would write them back") is answered by the PR's own margin argument: nothing pending can be inside the range, so there is nothing to cancel.

High

2. The boundary is only announced when the boundary block's body is on diskHistoryPruner.cs:399

UpdateBlocksDeletePointer fires NewOldestBlock — the sole driver of eth/69 BlockRangeUpdate via SyncServer.OnNewRange, and of the public OldestBlockHeader — only when FindBlock(pointer) is non-null. In master that ran per block, so a missing body cost one skipped announcement and self-healed immediately. As a single shot for a multi-million-block jump, a null there means the boundary is persisted and the reclaim erases everything below it while peers are never told. Realistic under UseAncientBarriers on a node whose ancient bodies were never backfilled. FindHeader is both reliable (headers aren't pruned) and cheaper.

Medium

3. The column-family range delete is untestedColumnDb.cs:132. Receipts prune through ColumnDbrocksdb_writebatch_delete_range_cf; the new RocksDB tests all use the default CF and the pruner tests use MemDb. Nothing in the PR exercises the native CF call. Given "the bounds are the whole safety story", that path deserves the same bounds test.

4. Deferred-overlay safety is documented, not enforcedBlockStore.cs:124-147. DeleteRange is public on IBlockStore/IBlockAccessListStore with no guard, and a tombstone followed by a pending write resurrects the entry at a higher sequence number, silently. Delete handles this via _pending.Remove(hash, …). The BAL margin is the thinner of the two, being driven by BalRetentionEpochs, which a config can floor at MinBalRetentionEpochs. RemoveReceiptsRange also skips _writeLock, which the per-block path relies on.

5. Dead code left in placeHistoryPruner.cs:374. _deletionProgressLoggingInterval is assigned and never read. More significantly, IBlockTree.DeleteOldBlock (five implementations) and IReceiptStorage.RemoveReceipts(Block) now have no production caller — the latter's own comment says pruning was the only one. AGENTS.md: "If a change makes existing code unused, remove it."

Low

  • Metrics change units silentlyHistoryPruner.cs:431-442. BlocksPruned += to - from counts heights, not blocks, so a sparse range over-reports by orders of magnitude. BlockAccessListsPruned double-counts when the block pass is cancelled, because the _balsDeletePointer sync sits inside the try after the loop and the return skips it — moving it to the finally also stops the BAL pass re-issuing tombstones over the same range.
  • SimulateDictionaryBlockStore.DeleteRange iterates per heightlink. The only implementation whose cost isn't independent of the span, contradicting the interface doc.
  • Interface surface. IBlockStore.DeleteRange, IBlockTree.DeleteOldBlockRange and IBlockAccessListStore.DeleteRange are abstract, so out-of-tree implementers break, while IReceiptStorage.RemoveReceiptsRange got a throwing default. Worth being consistent — AGENTS.md flags added public members as a plugin-breakage source.
  • DbOnTheRocks.RemoveRange allocates two arrays per call via .ToArray(). Cold path, so fine, but the surrounding code pins spans.
  • The finally in PruneBlocksAndReceipts is now near-redundant: SaveDeletePointers() already runs per chunk, so the only case it covers is the pre-loop return, which has nothing to save.

Points I checked and found sound

Chain-level records were never deleted by the old path either, so leaving them is not a regression. BlockExists still resolves correctly against range-deleted data. Genesis and the sync-pivot clamps hold, and the tests pin both. MemDb.RemoveRange's Bytes.BytesComparer.Compare resolves to the SequenceCompareTo overload, matching RocksDB's bytewise ordering. Leaving the tx index is well-argued and the read path already answers correctly for a pruned block. Making the BAL pass unconditional is a genuine bug fix independent of the rest.


· branch perf/pruner-range-delete

Comment thread src/Nethermind/Nethermind.Blockchain/Receipts/PersistentReceiptStorage.cs Outdated
Comment thread src/Nethermind/Nethermind.History/HistoryPruner.cs Outdated
Comment thread src/Nethermind/Nethermind.History/HistoryPruner.cs Outdated
Comment thread src/Nethermind/Nethermind.Db.Rocks/ColumnDb.cs
Comment thread src/Nethermind/Nethermind.Blockchain/Blocks/BlockStore.cs Outdated
Comment thread src/Nethermind/Nethermind.History/HistoryPruner.cs
…rom riding on the reclaim

Clearing the pending-canonical map alongside the receipts cache was a
data-loss bug. That map is a cancellation ledger, not a cache: the
deferred writer skips any entry missing from it, so clearing it dropped
the transaction-index write of every block queued near the head, and
those transactions would have stopped resolving by hash permanently.
Nothing queued can be inside a pruned range, so there was nothing there
to cancel in the first place.

The boundary announcement hung on finding the block BODY at the
boundary. Per-block that cost one skipped announcement out of thousands
and healed on the next iteration; as a single jump it means a
snap-synced node with no ancient bodies reclaims the range while its
peers are never told - the exact inversion of the property this is built
on. Headers are never pruned, so it announces off the header, and skips
a body read and decode per boundary move.

The access list pointer now keeps pace per chunk instead of after the
loop, where a cancelling return skipped it and left the access list pass
re-issuing tombstones over a range already reclaimed - and
double-counting them.

Range removal cannot know how many of its heights held a block, so the
two counters say heights in their descriptions rather than quietly
changing what they measure. Coverage follows the production path: the
receipts store resolves a column, and no test exercised the
column-family call. The simulate store no longer counts through a span
it does not hold, and the legacy-key remark says why a hash colliding
into a pruned range is accepted rather than guarded.
@svlachakis

Copy link
Copy Markdown
Contributor Author

@claude review again

@claude

claude Bot commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Claude finished @svlachakis's task in 8m 12s —— View job


Re-review: PR #12954 — publish the pruning boundary before reclaiming behind it

  • Read the current diff against origin/master
  • Verify the prior Critical/High findings are actually fixed
  • Check the Medium/Low follow-ups
  • Look for new issues introduced by the fixes
  • Post review

Both blocking findings from the last round are properly fixed, and the fixes are the right ones rather than the minimal ones.

  • _pendingCanonical.Clear() is gone, and the replacement remark records why the map is a cancellation ledger and not a cache — which is the part that stops someone re-adding the line by symmetry with _receiptsCache.Clear() next to it. Verified against PersistDeferredCanonical's early return.
  • FindHeader(_blocksDeletePointer, TotalDifficultyNotNeeded) replaces FindBlock(...)?.Header, so the NewOldestBlock announcement no longer depends on a body the reclaim is about to erase.
  • RemoveRange_OnAColumn_HoldsTheBoundsAndLeavesOtherColumnsAlone covers the real production path (ColumnsDb<ReceiptsColumns>rocksdb_writebatch_delete_range_cf), and asserting every height of the bystander column is the assertion that catches a tombstone escaping its CF.
  • The BAL sync moved into the per-chunk step, which was a real defect, not just a counter — the cancelling return did skip it.
  • The metric descriptions now say they count heights, which is the honest fix.
  • SimulateDictionaryBlockStore.DeleteRange iterates what it holds.
  • The legacy-key clause is in, correctly characterised as accepted rather than guarded.

Two deferrals I'm accepting as reasoned calls, not oversights: leaving IBlockTree.DeleteOldBlock / IReceiptStorage.RemoveReceipts(Block) in place (both now have zero production callers — I checked — but culling five implementations of a plugin-facing interface really is a separate change), and documenting rather than enforcing the deferred-overlay margin.

One new finding blocks merge. It is in the seam the previous round didn't reach: the other implementations of the new interface members.

High

IReceiptStorage.RemoveReceiptsRange's throwing default breaks Receipt.StoreReceipts=falseIReceiptStorage.cs:37

Only PersistentReceiptStorage overrides it. NullReceiptStorage and InMemoryReceiptStorage inherit throw new NotSupportedException() — and NullReceiptStorage is not test-only surface: BlockTreeModule binds it as the node's IReceiptStorage whenever !receiptConfig.StoreReceipts, in the same module that registers IHistoryPruner unconditionally. Nothing couples HistoryPruner._enabled to StoreReceipts.

So with --History.Pruning=Rolling --Receipt.StoreReceipts=false: the boundary publishes, DeleteOldBlockRange drops the first million blocks, RemoveReceiptsRange throws, and _blocksReclaimCursor = to on the following line never runs. Every pass repeats the same chunk and throws again — access lists never reclaimed, cursor pinned at chunk one, a NotSupportedException per pass. That is the same non-terminating shape this PR exists to fix, on a config that works on master (NullReceiptStorage.RemoveReceipts is a deliberate no-op there). Implement the member on both non-persistent stores rather than softening the default, and pin it with a [TestCase] across all three so the next implementation is caught at test time.

Low

  • BlockAccessListsPruned now under-counts to zero on the common pathHistoryPruner.cs:437. The block pass still reclaims access lists and still claims the ground by advancing _balsDeletePointer to to, but no longer counts it; the BAL pass then starts above that ground, so nobody counts it. An operator checking that the "access lists were never pruned at all" fix landed reads 0 after a pass that dropped 12M heights of them. += to - max(from, _balsDeletePointer) before the advance is honest without reintroducing the overlap.
  • Two comments now assert the opposite of the codeHistoryPruner.cs:454-478. The XML doc on PruneBlockAccessLists says the pointer moves first; line 274-277 says the BAL pass "publishes its own boundary before reclaiming". It reclaims at 474 and advances at 476. Harmless — _balsDeletePointer feeds only a metric, so there's no promise to break — but that's the fact worth writing down, and a reader trusting the current wording concludes publish-first is enforced twice when it's enforced once.
  • The live sync-pivot guard is gone from the reclaimHistoryPruner.cs:402. limit = _blocksDeletePointer was clamped against whatever the pivot was when published, possibly in an earlier process; master re-tested it per block. _minDeletableBlockNumber still floors start, so genesis is covered both ways — the pivot isn't. Narrow (TryUpdateSyncPivot is monotone), but lowering Sync.PivotNumber in config with no UpdatedPivotData persisted reaches it silently. ulong.Min(_blocksDeletePointer, _blockTree.SyncPivot.BlockNumber) restores it.
  • LINQ in SimulateDictionaryBlockStore.DeleteRangelink. coding-style.md rules it out where a foreach works.
  • Stray blank line at HistoryPruner.cs:441, left by the loop rewrite.
  • IReceiptStorage.RemoveReceiptsRange's NotSupportedException carries no message, unlike the three store-level ones which name the offending type.

Verified sound this round

_blocksReclaimCursor load: reclaimVal is null → _blocksDeletePointer is right, because master's loop only advanced the pointer after deleting, so everything below a migrated boundary is genuinely gone; _lastSaved = ulong.MaxValue correctly forces the first write. ShouldPruneHistory's new _blocksReclaimCursor < _blocksDeletePointer clause reads pre-load defaults (1, 1) on the very first call, which is false and therefore harmless, and it is re-evaluated after TryLoadDeletePointers. _balsDeletePointer >= _blocksReclaimCursor holds across the migration and both passes, so the block pass's BAL advance never claims ground it didn't clear. DbOnTheRocks.RemoveRange uses the WAL-enabled WriteOptions, not _noWalWrite, so the durability claim holds, and its disposal guard matches Remove. MemDb.Keys (.ToArray()) and SnapshotableMemDb.Keys (.ToArray() under _versionLock) both materialise, so remove-while-enumerating is safe. _retainedBodies is capped at 1024 near-head entries evicted by watermark, so not dropping them in the range path is correct. _chainLevelInfoRepository is still used by BlockExists. All five IBlockTree implementations, both IBlockAccessListStore and all three non-mock IBlockStore implementations carry the new members; the BAL db is registered unconditionally as a real database, so BlockAccessListStore.DeleteRange can't hit its NotSupportedException in production.

I could not run dotnet build or the test suites in this environment (command approval), so the compile and the reported suite results are taken from your report rather than reproduced.

--- · branch perf/pruner-range-delete

Comment thread src/Nethermind/Nethermind.Blockchain/Receipts/IReceiptStorage.cs
Comment thread src/Nethermind/Nethermind.History/HistoryPruner.cs Outdated
Comment thread src/Nethermind/Nethermind.History/HistoryPruner.cs Outdated
Comment thread src/Nethermind/Nethermind.History/HistoryPruner.cs Outdated
Comment thread src/Nethermind/Nethermind.Facade/Simulate/SimulateDictionaryBlockStore.cs Outdated

@benaadams benaadams left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two additional findings from local review; existing unresolved threads already cover the other requested items.

Comment thread src/Nethermind/Nethermind.History/HistoryPruner.cs Outdated
Comment thread src/Nethermind/Nethermind.Blockchain/Receipts/PersistentReceiptStorage.cs Outdated
… the build

The throwing default was reachable in production. NullReceiptStorage is
what Receipt.StoreReceipts=false binds as the node's receipt storage,
and the pruner runs regardless of it, so Rolling plus receipts-off would
delete a chunk's blocks, throw on its receipts, leave the cursor where
it was, and repeat the same chunk every pass - the non-terminating shape
this branch exists to remove, on a configuration that works on master.
Both non-persistent stores implement it now; the default still throws so
the next store added has to decide rather than silently skip receipts,
and a test over every implementation catches that at build time. The
in-memory one is keyed by hash throughout, so it reads the height off
the receipts themselves.

The access list counter went one step too far and reported nothing at
all on the common path, where the block pass claims the ground and the
access list pass then finds none left. It counts what each pass newly
claims, which double-counts neither.

The reclaim limit is re-clamped against the live sync pivot rather than
the one that happened to be current when the boundary was published,
possibly in an earlier process: the boundary is durable, the pivot is
not monotonic in config.

Two comments claimed the access list pass publishes a boundary first. It
does not, and does not need to - that pointer is local bookkeeping,
announced to nobody - so they say what the code does.

Also drops the LINQ that broke the build, and with it the assumption
that a filter for 'error CS' sees every build error: style rules are
errors here, so it never did.
…erlay holes

Leaving the transaction index to the existing TxLookupLimit cleanup did
not work, and the reason is structural: that path needs the block body
to enumerate a block's transactions, and with default settings history
pruning removes bodies at 1,056,768 blocks deep while the lookup horizon
only reaches 2,350,000. By the time it arrives the body is gone, so it
cleans nothing. Range reclaim had removed the pruner's own per-block
cleanup, so entries would have accumulated without bound where they
previously did not - a regression, not a deferred improvement.

They are swept instead: the index is keyed by transaction hash so it
cannot be dropped by range, but each value carries the block number,
which is enough to decide staleness without reading anything else. A
bounded slice per pass, resuming from a persisted cursor and starting
over once the column has been walked, since the boundary moves on. No
native callbacks, and the same shape as the rest of the reclaim.
Entries in the legacy hash-valued form carry no number and are left
rather than guessed at.

The two metadata writes were also ordered wrongly. If the boundary
reached disk first and the process died, a restart would find no cursor
key, read that as level with the boundary, and treat an unreclaimed
backlog as finished - disk never returned, silently, permanently. The
cursor goes first now and the order is documented as load-bearing.

The deferred-write overlay is drained for the range under its own lock
rather than argued about in a comment. The margin reasoning held, but a
public range method should not rest on what its only caller happens to
do today.
Publishing a boundary and issuing range tombstones frees nothing: a tombstone does not count
towards pending-compaction bytes, and the blocks and receipts column families are left at
RocksDB's 30-day periodic compaction default, so the space can stay occupied for weeks. Measured
on a 2.1GB column with half of it tombstoned: 0 bytes returned after 30 seconds.

IRangeRemovableKeyValueStore gains ReclaimRange, implemented over rocksdb_delete_file_in_range_cf
plus rocksdb_suggest_compact_range_cf. Block-numbered keys are written in ascending order, so a
reclaimed range owns whole SST files and unlinking them is metadata-only; files straddling either
bound are left to the compaction hint. It runs outside the deferred-write overlay lock, touching
only keys already declared absent.

Fixes a regression introduced earlier on this branch: with Receipt.CompactTxIndex=false every
transaction-index value is a 32-byte block hash, which the sweep refused, and the per-block path
that used to delete those regardless of value form is gone - so a column that was pruned became
one that grows without bound. The hash form now resolves its height through the header.

The sweep is also cancellable per entry, skipped while the boundary is still below the
TxLookupLimit horizon (the shipping default, where the per-block path already covers it), and
commits in 4096-key slices under the canonical writer's lock rather than one unbounded batch. Its
budget rises to 500k, correcting a pass-rate estimate that was wrong by ~51x.

Remaining review findings: volatile publication of the oldest-block header, and no null on lock
timeout, so a pruned node cannot advertise full history; the cheap pruning gate trusted only once
the pointers have loaded; InMemoryReceiptStorage records heights instead of reading them off
receipts that never carry one; three copies of the range-key arithmetic collapsed into
KeyValueStoreExtensions; the dead IBlockTree.DeleteOldBlock and the unreachable isFinalUpdate
announcement throttle removed; BlocksPruned renamed to BlockHeightsReclaimed so a dashboard on the
old name fails visibly rather than silently changing scale; and the [GaugeMetric] this branch had
taken from PruningCutoffBlocknumber restored.

Four regression tests, each proven red without its fix: the disk being returned, the
CompactTxIndex=false sweep, a backlog surviving a restart, and a database holding a boundary but no
cursor starting level with it rather than at genesis.
@svlachakis

Copy link
Copy Markdown
Contributor Author

@claude review again

@claude

claude Bot commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Claude finished @svlachakis's task in 10m 4s —— View job


Re-review: PR #12954 — publish the pruning boundary before reclaiming behind it

  • Read the current diff against origin/master
  • Verify prior findings (RemoveReceiptsRange default, BAL metric, pivot clamp, LINQ) are fixed
  • Review the two newest commits (tx-index sweep, crash/overlay holes, disk reclaim)
  • Check for new issues introduced by the fixes
  • Post review

Everything outstanding from the last two rounds is fixed, and two of the fixes go further than what was asked — the deferred-overlay margin is now enforced rather than documented (DeferredWriteOverlay.RemoveRange cancels queued writes in range under the same lock Remove uses), and ReclaimRange implements the DeleteFilesInRange the PR body listed under "Not in this PR". @benaadams' write-ordering hole is genuinely closed: SaveDeletePointers writes the cursor first, and on a first load with no cursor key _lastSavedBlocksReclaimCursor = ulong.MaxValue forces the cursor out at its old value before the boundary moves, so a crash between the two independent writes cannot produce boundary-without-cursor. The tx-index sweep answers the other half of his comment, and its gate lands on the right side of both shipping and minimum-retention configs (traced below).

One finding blocks merge, in the new reclaim path.

High

A failed best-effort reclaim aborts the pass and pins the cursor on the same chunk foreverKeyValueStoreExtensions.cs:164-177

ReclaimRange is documented "best effort … leaving the rest to compaction", but it throws, and HistoryPruner.cs:401-406 runs all three reclaims before _blocksReclaimCursor = to. When the disk fills — the situation pruning exists for — rocksdb_delete_file_in_range fails with an IO error, HandleFatalDbError matches it and fast-shuts the node down, over keys already tombstoned durably. On any other message the exception escapes instead, the cursor never advances, and every pass re-runs the identical chunk and throws again: the non-terminating shape this PR removes, reintroduced through the one call the interface says may give up. BlockStore.cs:135-140 also puts the reclaim ahead of _blockCache.Clear(), so that throw leaves the cache serving blocks whose keys are gone.

Note the asymmetry this exposes: DeleteBlockNumberRange deliberately checks capability before the empty-range guard so VerifyReclaimSupported's (0,0) probe reaches it — ReclaimBlockNumberRange checks the empty guard first, so the probe covers RemoveRange and not ReclaimRange. The publish-only-what-you-can-reclaim guarantee holds for half the operation.

Medium

  • The tx-index sweep's TxLookupLimit gate is untested in both directionsPersistentReceiptStorage.cs:905-911. The comparison is correct: on shipping defaults (RetentionEpochs=82125 → 2.628M retained vs TxLookupLimit=2.35M) the sweep short-circuits and the per-block path still finds a body at the horizon; at minimum retention (33024 epochs → 1.06M) the boundary sits above the horizon and the sweep is the only mechanism. Reversed, one direction walks for nothing and the other grows the index forever, and neither shows up as anything but disk. HistoryPruner.SweepTransactionIndex / LoadTxIndexSweepCursor and the next ?? [] cursor convention are also uncovered.
  • ReceiptStorageRangeRemovalTests.cs:37 fails dotnet format whitespace --verify-no-changeslink. Sixteen spaces where its siblings are at twelve; code-lint will fail. Two stray blank lines from the rename in ReadOnlyBlockTree.cs:211 and StatelessBlockTree.cs:212 while you're there.
  • The metric rename is an operator-visible breakMetrics.cs:19-25. nethermind_blocks_prunednethermind_block_heights_reclaimed: existing dashboards go blank rather than wrong, which is quieter. Probably the right call, but it wants a changelog line, since the PR body says "none added, none changed".

Low

  • Four small interop notes on ReclaimRangeDbOnTheRocks.cs:1204-1218: the unsafe block has no safety-invariant comment (robustness.md requires one); throw new RocksDbException(errPtr) inline rather than the file's own [DoesNotReturn] local-function pattern used at four other sites; rocksdb_delete_file_in_range is include_end = true, safe here only because no live key equals toExclusive‖0³²; and the unlink can make a handful of edge keys inside the range visible again, which the <remarks> currently reads as ruling out.
  • TxIndexSweepEntriesPerPass = 500_000 is documented as "sized to outpace the arrival rate", but with PruningTimeoutSeconds = 2 the cancellation token binds long before the budget does. No bug — progress persists through the cursor — but the constant isn't what governs the rate.
  • DbOnTheRocks.RemoveRange still allocates two arrays per call via .ToArray(), now sitting directly above a ReclaimRange that pins spans properly.
  • BlockStore.DeleteRange puts its empty-range guard at the bottom, after the delete and reclaim, so that the probe reaches the capability check. Deliberate, and the <remarks> doesn't say so.

Verified sound this round

SaveDeletePointers cursor-then-boundary ordering, including the first-publish case and the already-level case. TryLoadDeletePointers's reclaimVal is null → _blocksDeletePointer migration, and Reclaim_on_a_database_with_no_cursor_starts_level_with_the_published_boundary pinning it. DeferredWriteOverlay.RemoveRange faithfully mirrors Remove — non-value-conditional TryRemove under _lock, and Persist re-checks _pending under the same lock, so a queued write in range is cancelled and not resurrected; RemoveReceiptsRange therefore runs its DB delete under _writeLock while the reclaim stays outside it, which is the right split. _pendingCanonical correctly left alone, with the reason recorded. VerifyReclaimSupported's (0,0) probe reaches every capability check on the RemoveRange side and mutates nothing. PointsBelow distinguishes the two index encodings unambiguously (Rlp.Encode(ulong) is ≤ 9 bytes, a hash is 32) and CompactTxIndex defaults to true, so the per-entry FindHeader is off the default path. RocksdbSortedView.MoveNext seeks with an inclusive lower bound, matching the "re-reads the last key once" comment; the iterator pins its SuperVersion, so the sliced commits can't disturb the walk. No deadlock between _pruneLock and _writeLock — the only reverse edge is OldestBlockHeader's bounded Monitor.TryEnter. ReclaimRange's exclusive upper bound is pinned by a real SST-size assertion, and the CF test asserts every height of the bystander column. RemoveReceipts(Block) now has zero production callers, as acknowledged.

I could not run dotnet build or dotnet format in this environment (command approval), so the whitespace finding is from reading the file and the compile of the new native interop is taken from your report rather than reproduced.

--- · branch perf/pruner-range-delete

Comment thread src/Nethermind/Nethermind.Core/KeyValueStoreExtensions.cs
@svlachakis

Copy link
Copy Markdown
Contributor Author

@claude review again

@claude

claude Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Claude finished @svlachakis's task in 6m 4s —— View job


Re-review: PR #12954 — publish the pruning boundary before reclaiming behind it

  • Read the newest commit (adf1ab7) and the current diff
  • Verify the new test discriminates against the truncating bound
  • Check the corrected <summary> matches the code
  • Look for new issues
  • Post review

adf1ab7 closes last round's finding, and the test is the right one rather than a nearby one. I worked the discrimination through instead of taking the commit message for it: with truncation the bound becomes the 7-byte 00 00 00 00 00 00 01, which sorts below BlockKey(256) because it runs out first, so begin > end and rocksdb_delete_file_in_range selects nothing; with the borrow it is 511‖FF³², which contains all 256 files. That is the maximal-signal case for the row that had no coverage — the whole chunk kept, not a fraction of it.

The <summary> correction is accurate too. "Largest key strictly below exclusive of the same length" is what TryLargestBoundBelow now computes, the inline comment three lines below no longer contradicts it, and the "undershoots" clause is attached to the set it actually gives up (a shorter key that is a prefix of exclusive extended with zeroes), which no in-tree caller writes.

Nothing blocks merge. Eleven rounds in, every Critical and High raised across them is fixed, and every Medium is fixed or acknowledged with a reason I accepted at the time. Build solutions, Code Lint and Code formatting are all green on this commit; Nethermind tests is still queued, so the new test has not run in CI yet.

Low

1. The three reclaim tests now make incompatible claims about L0DbOnTheRocksTests.cs:517

ReclaimRange_LeavesAKeySittingOnTheExclusiveBound justifies its target_file_size_base=1024 + Compact() with "the unlink skips L0". The two disk-return tests do the opposite — disable_auto_compactions=true and nothing but flushes — so every file they assert is unlinked is an L0 file. If the L0 claim held, neither could go green, and both are load-bearing.

The likelier reading is that the L0 line is the wrong rationale for the setup it explains: one flush puts [0x01] and [0x02] in the same SST, and a file straddling the bound is never entirely inside the range whatever level it sits at — the small target size plus the compaction is what splits them. One clause on that comment, not a code change. Worth doing because that claim is the kind that gets copied into the next test's setup, where it would make it silently vacuous.

2. The new test pins one side of its propertyDbOnTheRocksTests.cs:521-534. Nothing is written at or above 512, so it says "enough went" and cannot say "nothing beyond went". The borrow can't overshoot, but one BlockKey(512, 0xAA) written before the loop plus an assertion that it survives would make it two-sided at exactly the boundary where the two candidate bounds differ. Same comment: 256 flushes are 32× what the property needs (the truncated bound loses to begin > end however many files there are), and Assert.That(before, Is.GreaterThan(0)) is the one assertion here with no message where its sibling has one.

Standing carry-overs

Unchanged, listed so they stay a decision rather than an oversight:

  • SchedulePruneHistory's doc (HistoryPruner.cs:178) still describes the pre-dfaa3a2 cancellation contract — a cancelled pass now still commits a minimum chunk in each reclaim loop and examines ≥4,096 index entries, and BackgroundTaskScheduler.DisposeAsync awaits it.
  • IReceiptStorage.SweepTransactionIndex's default is a silent no-op while RemoveReceiptsRange's throws, four lines apart, with no note that the difference is deliberate.
  • DbOnTheRocks.RemoveRange still allocates two arrays per call via .ToArray(), directly above a ReclaimRange that pins spans and stackallocs its bound. Eighth mention, cold path, entirely your call.
  • VerifyReclaimSupported fires only when the boundary advances, so a pass carrying only reclaim backlog skips the probe. Harmless — a published boundary implies it passed once — but the doc reads as a per-step guarantee.

Verified sound this round

adf1ab7 touches two files and adds no production code — the only non-test hunk is the <summary>. TryLargestBoundBelow still cannot overshoot or underflow: length is bounded by destination.Length before the copy, the all-zero case returns false after mutating a buffer the caller never reads, out length is always exclusive.Length on success so inclusiveBound[..boundLength] and toLength agree, and MaxReclaimBoundLength = 128 clears the 40-byte callers with room (a longer key reclaims nothing rather than guessing, the safe direction). The half-open contract holds: every key in a selected file is ≤ (T−1)‖FF³² < T‖0³², so include_end cannot reach a retained key. The empty-span guard is still outside the try, keeping VerifyReclaimSupported's (0,0) probe side-effect-free, and ObjectDisposedException is still inside it, so a pass racing shutdown cannot log at Error; RemoveRange — the irreversible half — still escalates through HandleFatalDbError while ReclaimRange swallows, with both the tombstone-first precondition and the swallow rationale on the method rather than on the constant. ChunkStep, the pass ordering from 018ebf9, the cursor-then-boundary write order, the sweep-cursor clause in ShouldPruneHistory and the metric split are all unchanged from the rounds that verified them.

I could not run dotnet build, dotnet format or the test suites here (command approval), so the findings above are from reading the code; the compile and formatting gates are taken from the green CI checks on this commit, and the test-behaviour arithmetic is derived rather than observed.


· branch perf/pruner-range-delete

Comment thread src/Nethermind/Nethermind.Db.Test/DbOnTheRocksTests.cs
Comment thread src/Nethermind/Nethermind.Db.Test/DbOnTheRocksTests.cs
The endpoint test justified its compaction with "the unlink skips L0", which the two disk tests
contradict: they flush with auto-compaction off, so every file they assert is unlinked is an L0
file, and they are green. The real reason that test needs the compaction is narrower and sufficient
- one flush puts both keys in the same SST, and a file straddling the bound is never entirely
inside the range whatever level it sits at.

Also adds the assertion message the precondition line was missing.
@claude

claude Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Claude finished @svlachakis's task in 2m 28s —— View job


Re-review: PR #12954 — publish the pruning boundary before reclaiming behind it

  • Read the newest commit (88ffc6c) and the current diff against the PR base
  • Verify last round's finding (the false L0 claim in the reclaim tests)
  • Check CI on this commit
  • Look for new issues
  • Post review

88ffc6c closes last round's finding, and it closes it the way the evidence actually supports rather than the way that was convenient. The replacement rationale on ReclaimRange_LeavesAKeySittingOnTheExclusiveBound"one flush puts both in the same SST, and a file straddling the bound is never entirely inside the range" — is level-independent and sufficient, so it no longer contradicts the two disk tests that unlink L0 files and are green. The missing assertion message is in.

The part of your reply I'd single out is the experiment rather than the fix: you removed the compaction hint and observed both disk tests still green, which is what makes the L0 claim false as a measurement and not as a reading. That's the right standard, and it's also why the PR body's new caveat is the honest place to land — the unlink is demonstrated to free space on an L0 layout, not on the level-partitioned bottom level this targets. Stating that, plus naming the first-pass signal to watch (boundary jumping while du doesn't follow), is worth more than a test that would have to fake a bottom level to say anything.

Nothing blocks merge. Thirteen rounds in, every Critical, High and Medium raised across them is fixed or acknowledged with a reason I accepted at the time. CI is green on this commit — Build solutions, Code Lint, Code formatting, Nethermind tests (Flat DB), Nethermind extra test variants, Nethermind/Ethereum tests, Integration tests (E2E), Stateless execution tests and Sync PR Gate (Hoodi) all passed, so the new reclaim tests have now actually run rather than being taken from a report.

Standing carry-overs

Unchanged, listed so they stay a decision rather than an oversight. None of them is a reason to hold this.

  • SchedulePruneHistory's doc still describes the pre-dfaa3a2 cancellation contract (HistoryPruner.cs:178). "Cancelled when timeout elapses or process is exiting, to avoid long pruning operations during shutdown" — a cancelled pass now still commits a MinimumReclaimChunkBlocks chunk in each reclaim loop and examines ≥4,096 index entries, and BackgroundTaskScheduler.DisposeAsync awaits it. It is a master comment, so leaving it is defensible on conservatism grounds; it's just the only statement about the token the code now contradicts.
  • The surviving sentence of the deleted RemoveReceipts comment. "Under deferral the removal runs under the shared lock (via the overlay) so a queued write cannot interleave and resurrect the data" is still true and isn't derivable at the call site. Partly mitigated by the note one frame down in RemoveReceiptsCore.
  • IReceiptStorage.SweepTransactionIndex's default is a silent no-op while RemoveReceiptsRange's throws, four lines apart, with no note that the difference is deliberate.
  • DbOnTheRocks.RemoveRange allocates two arrays per call via .ToArray(), directly above a ReclaimRange that pins spans and stackallocs its bound. Ninth mention, cold path, entirely your call.
  • VerifyReclaimSupported fires only when the boundary advances, so a pass carrying only reclaim backlog skips the probe. Harmless — a published boundary implies it passed once — but the doc reads as a per-step guarantee.
  • ReclaimRange_LeavesAKeySittingOnTheExclusiveBound pins one side. It asserts the bound key survives, which also holds if the unlink selected nothing. Your reason for not making the disk tests two-sided (adding a key at 512 changes the L0 file selection enough that the threshold stops discriminating) applies here too, and two one-sided tests whose layout you understand beats one two-sided test whose layout you don't.

Verified sound this round

88ffc6c touches one file and changes only a comment and an assertion message — no code, and no other file. The three reclaim tests now make consistent claims: the two disk tests run pure-L0 with disable_auto_compactions=true, the endpoint test's target_file_size_base=1024 + Compact() is justified by file splitting rather than by level, and none of the three asserts anything the others contradict. TryLargestBoundBelow still cannot overshoot or underflow — length is bounded before the copy, the all-zero case returns false after mutating a buffer the caller never reads, out length is always exclusive.Length on success so inclusiveBound[..boundLength] and toLength agree, and MaxReclaimBoundLength = 128 clears the 40-byte callers with room. The half-open contract holds: every key in a selected file is ≤ (T−1)‖FF³² < T‖0³². The empty-span guard is outside ReclaimRange's try (so the (0,0) probe stays side-effect-free) while ObjectDisposedException is inside it (so a pass racing shutdown can't log at Error), and RemoveRange — the irreversible half — still escalates through HandleFatalDbError. TryLoadDeletePointers loads the sweep cursor before _hasLoadedDeletePointers = true, so ShouldPruneHistory's sweep clause can never read an unloaded cursor behind a loaded flag, and the clause's own comment is honest that it schedules resume and not start. SaveDeletePointers' cursor-then-boundary order, the reclaimVal is null → _blocksDeletePointer migration, and the _balsDeletePointer ≥ _blocksDeletePointer floor all hold; on a crash-resume the block loop still range-deletes access lists over the resumed chunks even though the metric guard suppresses the count, so no BAL ground is skipped. ChunkStep is reduced only on the iteration where reclaimed == 0 and both loops return at their bottom check immediately after. Checked freshly this round: RocksDbWriteBatch.Dispose is idempotent via _isDisposed, so a throw inside CommitSweepSlice's StartWriteBatch cannot double-commit through the finally.


· branch perf/pruner-range-delete ·

Comment thread src/Nethermind/Nethermind.Blockchain/Receipts/PersistentReceiptStorage.cs Outdated
0 means index forever and ulong.MaxValue means index nothing; under either
the per-block path never removes an entry, so an operator on one of them has
asked for the index to be left alone. The horizon check happened to stand the
sweep down for 0 at any realistic head, since head - 0 is head and the pruning
boundary is always below it, but ulong.MaxValue fell straight through and
swept. Refusing both by name makes it a decision rather than arithmetic.
@svlachakis

Copy link
Copy Markdown
Contributor Author

@claude eeview again

@claude

claude Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Claude finished @svlachakis's task in 3m 15s —— View job


Re-review: PR #12954 — publish the pruning boundary before reclaiming behind it

  • Read the newest commit (31a1b40) and the current diff
  • Verify the TxLookupLimit sentinel fix and its tests
  • Check CI on this commit
  • Look for new issues
  • Post review

31a1b40 is right, and your correction of the reproduction is the more useful half of it. I worked both sentinels through independently:

  • 0head - limit == head, and the pruning boundary is always strictly below head, so retainedFromBlock <= head - limit already held. The old arithmetic did stand the sweep down, exactly as you said; @benaadams' repro reached the deletion only because the fixture leaves _blockTree.Head unset, giving head = 0 and making head > limit false. The pruner cannot be in that state — it needs a head to compute a cutoff at all (PruningIntervalHasElapsed dereferences Head!).
  • ulong.MaxValue — genuinely fell through at any head, because head > ulong.MaxValue is never true. Master never prunes there either (TryGetOldTxIndexBlock returns on newMain.Number <= limit, always true), so that one did change documented behaviour, and it is the case the new [TestCase] goes red without.

Refusing both by name rather than relying on the arithmetic is the right shape — the old form made "does the sweep run" depend on a subtraction that happened to work out, which is how the MaxValue hole got in. The head-short-of-limit fall-through is correct and now says why: the per-block path begins at head − limit and only moves forward, so the entries below the boundary are ones nothing else ever reaches.

Nothing blocks merge. Fourteen rounds in, every Critical, High and Medium raised across them is fixed or acknowledged with a reason I accepted at the time. CI on this commit: Code formatting, Spell check, Build tools, Stateless execution tests, Trivy, Dependency review green; Build solutions, Code Lint, Nethermind tests (Flat DB), Nethermind extra test variants, Nethermind/Ethereum tests, Integration tests (E2E) and Sync PR Gate (Hoodi) still running, so the two new cases have not reported yet.

Low

1. The 0 case pins the intent, not the code. As above, it passed before this commit too, so only the MaxValue case discriminates. That is not a reason to drop it — it is the case a future reader would most plausibly "simplify" back into the arithmetic — but it is worth knowing that of the five cases in SweepTransactionIndex_RunsOnlyOncePastTheLookupHorizon, one (MaxValue) guards this commit's code and one (4_000_000) guards the fall-through, while the 0 case guards the comment.

2. Rolling + TxLookupLimit=0 now has no mechanism at all. Neither the per-block path (TryGetOldTxIndexBlock returns immediately) nor the sweep (refused by name) touches the index, while the reclaim keeps erasing the blocks those entries name — so the column accumulates entries that can only ever answer "not found", with nothing bounding it. Matching master is the right call and retaining is the safe direction, but this is the one configuration where the PR's "stale transaction-index entries are swept, not left" is false, and the PR body's mode table doesn't have a row for it. One row, not a code change.

3. Unset is the same promise describes a state the node does not survive. True of TryGetOldTxIndexBlock (is not > 0ul catches null) and now of the sweep — but ShouldIndexTxs, on the insert path at line 185, does TxLookupLimit != 0ul (lifted !=, so true for null) and then .Value, which throws InvalidOperationException. So a null limit crashes on the first receipt insert rather than quietly indexing forever. Pre-existing on master and well outside this diff, so leaving it is right; the clause is just describing a promise nothing else keeps.

Standing carry-overs

Unchanged, listed so they stay a decision rather than an oversight. None is a reason to hold this.

  • SchedulePruneHistory's doc still describes the pre-dfaa3a2 cancellation contract (HistoryPruner.cs:178). A cancelled pass now still commits a MinimumReclaimChunkBlocks chunk in each reclaim loop and examines ≥4,096 index entries, and BackgroundTaskScheduler.DisposeAsync awaits it.
  • The surviving sentence of the deleted RemoveReceipts comment"under deferral the removal runs under the shared lock (via the overlay) so a queued write cannot interleave and resurrect the data" — is still true and isn't derivable at the call site. Partly mitigated by the note one frame down in RemoveReceiptsCore.
  • IReceiptStorage.SweepTransactionIndex's default is a silent no-op while RemoveReceiptsRange's throws, four lines apart, with no note that the difference is deliberate. Related: its <returns> says null means "at the end", and both gates now also return null meaning "refused" — harmless here (a stale cursor is cleared to [], which reads back as absent, so ShouldPruneHistory's clause cannot latch) but two meanings on one sentinel.
  • DbOnTheRocks.RemoveRange allocates two arrays per call via .ToArray(), directly above a ReclaimRange that pins spans and stackallocs its bound. Tenth mention, cold path, entirely your call.
  • VerifyReclaimSupported fires only when the boundary advances, so a pass carrying only reclaim backlog skips the probe. Harmless — a published boundary implies it passed once.
  • ReclaimRange_LeavesAKeySittingOnTheExclusiveBound pins one side, for the layout reason you gave last round, which I accepted.

Verified sound this round

31a1b40 touches two files. The guard sits after the cheap structural checks and before the horizon check, so the (retainedFromBlock == 0) and maxEntries <= 1 refusals are unchanged and still side-effect-free. All five [TestCase]s are consistent with the code at head 3_000_000: (1M, 2.5M) sweeps because 2.5M > 2M; (2M, 0.5M) refuses because 0.5M ≤ 1M; (4M, 2.5M) sweeps through the fall-through; both sentinels refuse by name. TestName on [TestCase] inside a [TestFixture(true)]/[TestFixture(false)] pair is fine — the five names are distinct within a fixture instance, and the instances are separate nodes — and none of the five touches _storage's receipt state, so running twice under useCompactReceipts is harmless. A config change from a real limit to a sentinel costs exactly one pass to discover: the refusal returns null, Bytes.AreEqual(cursor, null) is false, the pruner writes [], LoadTxIndexSweepCursor's { Length: > 0 } reads that as absent, and the sweep clause in ShouldPruneHistory goes false — no latch, no repeated scheduling. InMemoryReceiptStorage.RemoveReceiptsRange enumerates a ConcurrentDictionary while removing from it, which is safe by contract, and maintains _blockNumbers alongside _receipts and _transactions. Everything verified in earlier rounds is untouched: ChunkStep, the pass ordering from 018ebf9, SaveDeletePointers' cursor-then-boundary order, the reclaimVal is null → _blocksDeletePointer migration, TryLargestBoundBelow's borrow and its inability to overshoot or underflow, ReclaimRange's swallow with ObjectDisposedException inside the try and the empty-span guard outside it, and RemoveRange still escalating through HandleFatalDbError.

I could not run dotnet build or the test suites here (git fetch and build commands need approval), so the findings above are from reading the code and the arithmetic is derived rather than observed; the formatting and spell-check gates are taken from the green CI checks on this commit.


· branch perf/pruner-range-delete ·

@svlachakis
svlachakis merged commit cebda3d into master Aug 23, 2026
497 checks passed
@svlachakis
svlachakis deleted the perf/pruner-range-delete branch August 23, 2026 19:57
svlachakis added a commit that referenced this pull request Aug 23, 2026
The branch carried its own copy of the pruner redesign, which master has now
squashed. Conflicts resolved by keeping master's version of everything the PR
owns - the TxLookupLimit sentinel guard and its test cases most of all - and
the branch's retention layer on top of it: IPrunedReceiptRetention, the
self-describing receipt path, and RetainReceiptsAndReclaimTheRest with its
density guard. Verified no line of master's HistoryPruner was dropped.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants