Skip to content

fix(history): create the history pruner before the block processor starts - #13285

Open
kamilchodola wants to merge 5 commits into
masterfrom
fix/history-pruner-startup-trigger
Open

kamilchodola wants to merge 5 commits into
masterfrom
fix/history-pruner-startup-trigger

Conversation

@kamilchodola

@kamilchodola kamilchodola commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Changes

  • New built-in step StartHistoryPruner that calls IHistoryPruner.SchedulePruneHistory() once InitializeNetwork has completed.
  • StartHistoryPrunerTests asserting the step schedules a pass.

HistoryPruner only ever schedules a pruning pass from IBlockProcessingQueue.ProcessingQueueEmpty. BlockchainProcessor raises that once when its loop starts and afterwards only after a processed block. Nothing resolves the pruner until InitializeNetwork (via SyncServer) or RegisterRpcModules, both of which run after StartBlockProcessor, so the startup signal has no subscriber. On a live node the next processed block hides this. A node that receives no blocks (no CL attached yet, or an offline maintenance run with --History.Pruning enabled to trim an existing database) never prunes, and the skip is only visible at Trace level.

The step depends on InitializeNetwork, which is where the pruner used to be constructed: EraStep/EraEStep list InitializeNetwork as a dependent and themselves depend on ReviewBlockTree, so the first pass still starts after the block-tree fixer and any Era import, exactly as before. No existing step is modified.

Types of changes

What types of changes does your code introduce?

  • Bugfix (a non-breaking change that fixes an issue)
  • New feature (a non-breaking change that adds functionality)
  • Breaking change (a change that causes existing functionality not to work as expected)
  • Optimization
  • Refactoring
  • Documentation update
  • Build-related changes
  • Other: Description

Testing

Requires testing

  • Yes
  • No

If yes, did you write tests?

  • Yes
  • No

Notes on testing

Found while pruning a mainnet flat snapshot offline with --History.Pruning UseAncientBarriers and networking disabled: the node started cleanly and never logged Pruning historical blocks up to. EthereumStepsLoaderTests.BuildInSteps_IsCorrect covers the registration of the new step.

For an offline pruning run (no CL, no peers) also pass --History.PruningTimeoutSeconds 0: this step supplies the first pass only, and with the default 2 s deadline a pass stops at the next chunk boundary and waits for a ProcessingQueueEmpty signal that an idle node never receives. The delete pointer is published in full before the reclaim loop, so without the timeout disabled the node reports pruned history while most of the disk is still occupied. With the timeout disabled the single pass runs to completion.

Documentation

Requires documentation update

  • Yes
  • No

Requires explanation in Release Notes

  • Yes
  • No

…arts

HistoryPruner schedules a pass on IBlockProcessingQueue.ProcessingQueueEmpty,
which BlockchainProcessor raises once when its loop starts and then after every
processed block. Nothing resolved the pruner before InitializeNetwork or
RegisterRpcModules, both of which run after StartBlockProcessor, so the startup
signal was raised with no subscriber. A node that receives no blocks (no CL
attached, offline maintenance run with History.Pruning enabled) therefore never
pruned its backlog. Resolving the pruner from StartBlockProcessor makes it exist
before the first signal.
@claude

claude Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kamilchodola's task in 6m 0s —— View job


Review: fix(history): create the history pruner before the block processor starts

  • Gather context (diff, PR body)
  • Verify the root-cause claim against the code
  • Check DI/step ordering implications
  • Assess the regression test
  • Post findings (3 inline comments)

The diagnosis checks out

I traced the chain the PR describes and it holds:

  • BlockchainProcessor.RunProcessingLoop fires FireProcessingQueueEmpty() exactly once before entering the read loop (BlockchainProcessor.cs:349), then only after each processed block (:373) and on DecrementQueue (:283).
  • HistoryPruner subscribes to that event in its constructor, and only when historyConfig.Enabled() (HistoryPruner.cs:154) — it has no other trigger.
  • The root IBlockProcessingQueue maps to the main BlockchainProcessor (BlockProcessingModule.cs:102), so the subscription does target the instance StartBlockProcessor starts. Good — the fix isn't wiring up a different queue.
  • On master nothing resolves IHistoryPruner before InitializeNetwork (SyncServer.cs:83) / RegisterRpcModules, both of which are downstream of StartBlockProcessor. So the startup edge genuinely had no subscriber.

I also checked the construction-order hazard I expected to find and it turns out to be benign: LoadGenesisBlock runs after StartBlockProcessor, so _blockTree.Genesis can be null when the pruner is built — but BlockTree's constructor already sets Genesis from an existing DB (BlockTree.cs:166), and on a fresh DB (Genesis?.Number ?? 0) + 1 gives the same _minDeletableBlockNumber of 1. No issue.

Findings

# Severity Where Issue
1 Medium StartBlockProcessor.cs:13-18 The change moves the first pruning pass into the middle of startup, not just the subscription. It can now run concurrently with StartupBlockTreeFixer (which deletes chain levels) and with EraStep/EraEStep history import — both previously always completed before the pruner existed. An additive StartHistoryPruner step calling the existing IHistoryPruner.SchedulePruneHistory() after ReviewBlockTree avoids the question entirely, touches no existing file, and doesn't depend on the queue-empty edge. (detail)
2 Medium EthereumStepsManagerTests.cs:114-135 The test asserts the constructor signature, not the invariant. Autofac resolves ctor args before the ctor body, so it stays green if _ = historyPruner; — the line most likely to be removed by an IDE cleanup — is deleted. Also misplaced: it never uses EthereumStepsManager, and it splits Warmup_selects_head_before_pivot from its CreateWarmupEnvironment helper. (detail)
3 Low StartBlockProcessor.cs:15 CancellationToken _ renamed to cancellationToken while still unused — unrelated diff that now implies the step honours cancellation. (detail)
4 Low HistoryPruner.cs:283 Worth confirming the reported symptom is fully fixed: TryPruneHistory bails on _blockTree.SyncPivot.BlockNumber == 0. Your repro used UseAncientBarriers on a snapshot with a pivot set, but an offline pass over a DB synced from genesis with no SyncConfig.PivotNumber would still no-op at startup.

The added comment on the discard is a genuine why comment stating the invariant — that one is good and should stay whichever shape the fix takes.

Not verified

I could not run dotnet build or dotnet test in this environment (the commands need approval), so compilation and the new test are unverified by me. Everything above is from reading the code.

Verdict: the bug is real and correctly diagnosed, but I'd resolve finding 1 (or record why the startup overlap is safe) and strengthen the test in finding 2 before merging.
· fix/history-pruner-startup-trigger

Comment thread src/Nethermind/Nethermind.Init/Steps/StartBlockProcessor.cs Outdated
Comment thread src/Nethermind/Nethermind.Init/Steps/StartBlockProcessor.cs Outdated
Comment thread src/Nethermind/Nethermind.Runner.Test/Ethereum/Steps/EthereumStepsManagerTests.cs Outdated
Replace the IHistoryPruner injection into StartBlockProcessor with an additive
StartHistoryPruner step that calls SchedulePruneHistory after InitializeNetwork.
That keeps the first pass after the block-tree fixer and the Era import, where
the pruner used to be constructed, instead of racing them from the processor's
startup signal. StartBlockProcessor is back to its master form.
@kamilchodola

Copy link
Copy Markdown
Contributor Author

@claude re-review

@claude

claude Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kamilchodola's task in 5m 10s —— View job


Re-review: fix(history): create the history pruner before the block processor starts

  • Gather the updated diff
  • Re-check my three prior findings against the new shape
  • Verify StartHistoryPruner resolves in every configuration
  • Check ordering/blocking semantics of the new step
  • Assess the new test + BuildInSteps_IsCorrect
  • Post findings (3 inline comments)

Prior findings

# Prior finding Status
1 First pruning pass moved into the middle of startup, overlapping StartupBlockTreeFixer / Era import Resolved. StartBlockProcessor is back to its master form; the additive step runs after InitializeNetwork, i.e. after EraStep/EraEStep (dependents: [InitializeNetwork]) and therefore after ReviewBlockTree. Verified in EraStep.cs:11-14.
2 Test asserted the constructor signature, not the invariant Resolved. The step now calls SchedulePruneHistory() directly, so Received(1) is the invariant; EthereumStepsLoaderTests.BuildInSteps_IsCorrect genuinely pins registration — it diffs the assembly's non-optional steps against BuiltInStepsModule, so omitting the step would fail it (EthereumStepsLoaderTests.cs:36-56).
3 Unrelated CancellationToken _cancellationToken rename Resolved. File untouched. Note the new step's unused cancellationToken matches its closest sibling StartHistoryWalkVerification.cs:16, so it reads as house style there.

The shape is right: additive step, no existing file modified other than the registration list, IHistoryPruner is registered unconditionally in BlockTreeModule (via NethermindModule.cs:67) so the step resolves in every client, Execute returns immediately because SchedulePruneHistory is fire-and-forget, and the double trigger is safe (_currentlyPruning CAS).

New findings

# Severity Where Issue
1 Medium StartHistoryPrunerTests.cs / behaviour One scheduled pass ≠ a drained backlog. The pass is bounded by PruningTimeoutSeconds (default 2 s) and stops at a 100k–1M-height chunk boundary with "the next pass resumes from here"; on the very node this PR targets (no CL, no blocks) there is no next trigger, so an offline snapshot trim publishes the whole boundary but reclaims only the first chunk. PruningTimeoutSeconds=0 removes the deadline and the single pass then completes — that resolves it, but it needs saying in the PR body, because "start once with --History.Pruning UseAncientBarriers" reads as sufficient. (detail)
2 Low StartHistoryPruner.cs:20 The step calls SchedulePruneHistory() unconditionally, while the pruner's own trigger is gated on historyConfig.Enabled() and Pruning defaults to Disabled. The first pass does not short-circuit (the fast bail needs _hasLoadedDeletePointers && _stampsValidated), so every default-config node now runs pointer discovery + a metadata write + retention stamping at startup. Nothing is deleted, and the discovery path is already reachable via OldestBlockHeader — but mirroring the gate is one line. (detail)
3 Low StartHistoryPruner.cs:11-16 The summary promises ordering "past the block-tree fixer and the Era import", but the attribute only names InitializeNetwork, which does not depend on ReviewBlockTree. The guarantee is contingent on EraStep's dependents list. Adding typeof(ReviewBlockTree) to dependencies is free (already satisfied) and makes the doc true by construction. (detail)

Carried over for the record, unchanged by this PR: TryPruneHistory still returns early on _blockTree.SyncPivot.BlockNumber == 0 (HistoryPruner.cs:284), so a database synced from genesis with no pivot still won't prune from this trigger. Pre-existing, not a regression.

Comment quality: the XML <summary>/<remarks> on the new step state why the step exists and what would otherwise break — that's the good kind and should stay.

Not verified

dotnet build / dotnet test need approval in this environment, so compilation and the new test are unverified by me — though both new files use types already referenced from their projects (Nethermind.InitNethermind.History via BlockTreeModule; Nethermind.Runner.Test → via Module/PrunedReceiptRetentionModuleTests.cs), so nothing suggests a reference gap.

Verdict: the diagnosis and the new shape are both correct, and this is a clear improvement over the previous revision. I'd add the offline-run caveat (finding 1) to the PR body and, optionally, take the two one-line hardening suggestions.
· fix/history-pruner-startup-trigger

Comment thread src/Nethermind/Nethermind.Init/Steps/StartHistoryPruner.cs
Comment thread src/Nethermind/Nethermind.Init/Steps/StartHistoryPruner.cs Outdated
Comment thread src/Nethermind/Nethermind.Runner.Test/Ethereum/Steps/StartHistoryPrunerTests.cs Outdated
…BlockTree

Mirror the pruner's own constructor gate so a default-config node schedules no
pass, and declare the ReviewBlockTree dependency directly instead of relying on
the Era steps' dependent list.
@kamilchodola

Copy link
Copy Markdown
Contributor Author

@claude re-review

@claude

claude Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kamilchodola's task in 4m 1s —— View job


Re-review: fix(history): create the history pruner before the block processor starts

  • Gather the updated diff (3 commits, 3 files, +53/−0)
  • Re-check the three prior findings against the new shape
  • Verify the added ReviewBlockTree dependency resolves everywhere and adds no cycle
  • Verify the IHistoryConfig.Enabled() gate matches the pruner's own gate
  • Assess the parameterized test and the registration test
  • Post findings

Prior findings

# Prior finding Status
L-2 Step called SchedulePruneHistory() unconditionally while the pruner's own trigger is gated Resolved. if (historyConfig.Enabled()) now mirrors HistoryPruner.cs:127,145, so a default-config (Disabled) node does exactly what it does on master — no pointer discovery, no metadata write, no retention stamping at startup.
L-3 Documented ordering held only transitively through EraStep Resolved. typeof(ReviewBlockTree) is now declared directly, so the "past the block-tree fixer" half of the summary holds by construction. The Era half is still transitive (EraStep/EraEStep: dependencies: [ReviewBlockTree], dependents: [InitializeNetwork], EraStep.cs:11-14) — that's fine, InitializeNetwork is the right thing to name for it.
M-1 One scheduled pass ≠ a drained backlog on a node that never processes a block Still open — see below.

Checks on the new dependency edge, all clean:

  • ReviewBlockTree is a non-optional built-in step (BuiltInStepsModule.cs:28), so EthereumStepsManager's missing-dependency throw (EthereumStepsManager.cs:100) can't fire; dependency matching is by StepBaseType, so a plugin subclass still satisfies it.
  • No cycle: ReviewBlockTree → LoadGenesisBlock, InitializeNetwork → {LoadGenesisBlock, SetupKeyStore, InitializeBlockchain}; nothing depends on StartHistoryPruner.
  • No deadlock via ReviewBlockTree's internal ProcessingQueueEmpty wait (ReviewBlockTree.cs:71-76) — StartBlockProcessor doesn't depend on the new step, so the processor still starts and releases it.
  • EthereumStepsLoaderTests.BuildInSteps_IsCorrect genuinely pins the registration: it diffs the Nethermind.Init assembly's non-optional steps against BuiltInStepsModule, and StepInfoKey includes the dependency list, so both dropping the step and quietly changing its dependencies fail the test.
  • Test shape is right for the repo rules: input→expected pairs belong in [TestCase], not [Values], and Received(0)/Received(1) on a substitute is the actual invariant now that the step calls the method directly.

Remaining finding

# Severity Where Issue
1 Medium (doc-only) PR body / testing notes The motivating offline run is started by this step, not completed. SchedulePruneHistory enqueues one pass with a PruningTimeoutSeconds deadline (default 2, HistoryConfig.cs:12), and PruneBlocksAndReceipts returns at the first chunk boundary once the token trips (HistoryPruner.cs:610-615, chunk = 100k–1M heights) leaving "the next pass resumes from here". I re-confirmed there is no timer and no other caller of SchedulePruneHistory in the tree, so on the node this PR targets — no CL, no blocks — there is no next pass: the boundary is published in full (UpdateBlocksDeletePointer(target) at :572, before the reclaim loop) while only the first chunk is reclaimed, i.e. the node reports the history gone with the disk not yet freed. --History.PruningTimeoutSeconds 0 removes the deadline and the single pass runs to completion. Worth one line in the testing notes, because "start the node once with --History.Pruning UseAncientBarriers" reads as sufficient.

Small upside of this PR that's worth recording alongside it: because the reclaim cursor is persisted, a restart now resumes the reclaim one chunk at a time, which master couldn't do at all on an idle node.

Two things unchanged by this PR, for the record: TryPruneHistory still returns early when _blockTree.SyncPivot.BlockNumber == 0 (HistoryPruner.cs:284), so a DB synced from genesis with no pivot still won't prune from this trigger; and the Enabled() check now lives in two places (step + pruner ctor) — acceptable, since the point is to avoid the startup work, but a future pruning mode has to remember both.

Comment quality: the <summary>/<remarks> on the new step explain why it exists and what breaks without it — the good kind, keep them.

Not verified

dotnet build / dotnet test need approval in this environment, so compilation and the new test are unverified by me. Nothing suggests a gap: Nethermind.Runner.Test already compiles using Nethermind.History in Module/PrunedReceiptRetentionModuleTests.cs, and the [RunnerStepDependencies(dependencies: [...])] form matches StartHistoryWalkVerification.cs:13.

Verdict: the fix is correct and the shape is now right — additive step, one-line registration change, no existing behaviour touched, disabled nodes byte-for-byte as before. Only the offline-run caveat (finding 1) is outstanding, and it's a PR-body edit rather than a code change.
· fix/history-pruner-startup-trigger

@LukaszRozmej LukaszRozmej 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.

Review report

Scope: all three files against merge-base 221c144bffNethermind.Init/Modules/BuiltInStepsModule.cs (+1), Nethermind.Init/Steps/StartHistoryPruner.cs (new, 24 lines), Nethermind.Runner.Test/Ethereum/Steps/StartHistoryPrunerTests.cs (new). Read for verification only: HistoryPruner, IHistoryConfig/HistoryConfig, EthereumStepsManager, ReviewBlockTree, BlockTreeModule, and the RunnerStepDependencies attributes of the neighbouring steps.

The DI and ordering mechanics are sound. The step resolves lazily inside RunStep (after WaitForDependencies), so nothing is constructed early and no cycle is introduced; IHistoryPruner is a BlockTreeModule singleton already built by the RPC and sync graphs, so eager resolution here changes nothing. Ordering after ReviewBlockTree is genuinely guaranteed twice over — explicitly, and transitively via InitializeNetwork, which EraStep/EraEStep make a dependent of ReviewBlockTree; the explicit entry is redundant but reads as fair self-documentation. The Enabled() gate is necessary and correct (without it TryPruneHistory would load pointers and fire NewOldestBlock on a pruning-disabled node), and fresh/empty DBs are safe (SyncPivot == 0 and the ancient-bodies hold both short-circuit). No new race with sync or the block processor: the pass clamps to SyncPivot, _currentlyPruning serialises, and the semantics match the pre-existing ProcessingQueueEmpty trigger. Adding a step to BuiltInSteps is the established registration path (cf. StartHistoryWindowPruner via FlatHistoryModule), so the one-line edit to existing code is appropriate.

I'd approve once F1 is addressed or consciously accepted with the <remarks> reworded.

Comment thread src/Nethermind/Nethermind.Init/Steps/StartHistoryPruner.cs
@benaadams

Copy link
Copy Markdown
Member

@claude review

@claude

claude Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Claude finished @benaadams's task in 2m 30s —— View job


Review: fix(history): create the history pruner before the block processor starts

  • Gather the current diff (3 files, +53/−0, merge-base 221c144b)
  • Verify @LukaszRozmej's F1 (pruning-interval gate) against HistoryPruner
  • Re-check DI / step ordering / the config gate
  • Assess the tests
  • Post findings (1 inline comment)

@benaadams — re-reviewing after @LukaszRozmej's pass. The short version: F1 is real and I confirmed it independently; everything else in the PR is in good shape.

F1 confirmed — the fix does not yet achieve its stated goal

The step schedules a pass, but the pass is still throttled by a gate keyed on block height:

step code
_pruningInterval = PruningInterval * SlotsPerEpoch = 8 * 32 = 256 HistoryPruner.cs:128, HistoryConfig.cs:11
PruningIntervalHasElapsed() => _pruningInterval == 0 || Head!.Number % _pruningInterval == 0 HistoryPruner.cs:533
ShouldPruneHistory() returns false on that, before looking at any owed work HistoryPruner.cs:514
consulted after pointer load + retention stamping, before every delete path HistoryPruner.cs:314

Head.Number is precisely the quantity that never moves on a node with no CL and no peers — the exact scenario in the PR body. git grep SchedulePruneHistory confirms the only two production triggers are OnBlockProcessorQueueEmpty and this new step, so there is no retry. Net effect on the target scenario: the pass loads and publishes the delete pointer and stamps receipt retention, then bails without deleting anything, unless the stored head happens to sit on a multiple of 256 (~0.4%).

That also makes the <remarks> — "a node that receives no blocks would never prune its backlog" — still true after the change in 255/256 cases. Details and a suggested one-line fix (_firstPassDone bypassing the gate for the process's first pass, no new public surface) are in the inline comment.

Worth noting the manual repro in the PR body ("with the timeout disabled the single pass runs to completion") is consistent with a head that happened to land on the boundary — it doesn't contradict this, and it's the kind of thing that only a head % 256 != 0 test would have caught.

Everything else re-checked and clean

  • RegistrationBuiltInStepsModule.cs:32 insertion is the established path; EthereumStepsLoaderTests.BuildInSteps_IsCorrect diffs assembly steps against this list and StepInfoKey includes dependencies, so both omission and a silent dependency change fail it.
  • Ordering[RunnerStepDependencies(dependencies: [typeof(InitializeNetwork), typeof(ReviewBlockTree)])] makes the "past the block-tree fixer" half hold by construction; the Era half is transitive via EraStep's dependents: [InitializeNetwork], which is fine. No cycle, and no deadlock against ReviewBlockTree's internal ProcessingQueueEmpty wait since StartBlockProcessor doesn't depend on the new step.
  • Config gateif (historyConfig.Enabled()) mirrors HistoryPruner.cs:127/145 exactly (Enabled() => Pruning != PruningModes.Disabled, IHistoryConfig.cs:41), so a default-config node is byte-for-byte as on master. This resolves my earlier L-2.
  • Additivity — one line of existing code touched, StartBlockProcessor untouched. Matches the AGENTS.md preference ordering.
  • Test[TestCase] over the three PruningModes with an expectedPasses column is the right attribute choice per the repo test rules (input→expected pairs, not [Values]), and Received(n) on the substitute is the real invariant now that the step calls the method directly. Its blind spot is exactly F1: substituting IHistoryPruner means it cannot see that the scheduled pass is a no-op.
  • Comments — the <summary>/<remarks> explain why the step exists and what breaks without it; that's the good kind and should stay, but the <remarks> needs to match reality once F1 is settled (either fixed, or reworded to promise only what it delivers).

Unchanged by this PR, for the record: TryPruneHistory still returns early on _blockTree.SyncPivot.BlockNumber == 0 (HistoryPruner.cs:284), so a DB synced from genesis with no pivot still won't prune from this trigger. Pre-existing.

Findings

# Severity Where Issue
1 High StartHistoryPruner.cs:21HistoryPruner.cs:533 The scheduled pass is gated on Head.Number % 256 == 0, which never becomes true on the idle node this PR targets. The stated goal is not achieved and the <remarks> overstates the guarantee. (detail)

My earlier M-1 (offline runs need --History.PruningTimeoutSeconds 0) is resolved — it's now in the PR body's testing notes. L-2 and L-3 are resolved in code.

Not verified

dotnet build / dotnet test need approval in this environment, so compilation and the new test are unverified by me. Nothing suggests a gap — Nethermind.Runner.Test already compiles using Nethermind.History, and the attribute form matches StartHistoryWalkVerification.cs.

Verdict: the diagnosis, the shape, and the DI wiring are all right, and this is strictly better than master. But as it stands the fix is inert on its own motivating scenario. I'd land the interval-gate bypass (here or in a follow-up) before merging, or reword the <remarks> so the step doesn't claim a guarantee it can't keep.
· fix/history-pruner-startup-trigger

Comment thread src/Nethermind/Nethermind.Init/Steps/StartHistoryPruner.cs
@kamilchodola

Copy link
Copy Markdown
Contributor Author

Addressed the startup interval finding in 3e67b48. The first eligible pruning pass bypasses the interval once; missing pivot and ancient-backfill holds preserve that pending pass. Later passes retain the existing interval gate.

Validation: History build passed with 0 warnings/errors; 55 HistoryPrunerTests and 3 StartHistoryPrunerTests passed, including the startup and hold regressions.

@claude re-review

@claude

claude Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kamilchodola's task in 3m 29s —— View job


Re-review: fix(history): allow the first eligible pruning pass at startup

  • Gather the updated diff (commit 3e67b48, 5 files)
  • Verify the interval-gate bypass against HistoryPruner
  • Check the "pending pass" preservation on missing-pivot / ancient-backfill holds
  • Assess the new regression tests
  • Post findings (1 inline comment)

F1 (the interval gate) is resolved, and the fix now works end-to-end

I traced the new flag through every exit of TryPruneHistory and it does what the commit message claims:

path flag preserved? correct?
Head is null / SyncPivot.BlockNumber == 0 (:286-288) yes — bail is upstream of any write
fast-path skip when nothing is owed (:288) yes — ShouldPruneHistory returns false on the work clauses, never clears
TryLoadDeletePointers() false — the ancient-backfill hold (:300) yes
ShouldPruneHistory(pending) false under the lock (:318) yes
pass actually eligible (:324) cleared, then the work runs

So the bypass is consumed by the first pass that has something to do, not merely by the first pass that is attempted — which is the distinction that makes it survive a node whose pointers can't load yet. ShouldPruneHistory still requires _enabled and still requires owed work; only PruningIntervalHasElapsed() is skipped (:520). A disabled node is unchanged.

Walking the motivating scenario: offline snapshot, --History.Pruning UseAncientBarriers --History.PruningTimeoutSeconds 0, no CL → StartHistoryPruner schedules → head non-null, pivot set → pointers load (SynchronizationEnabled: false takes the branch at :379-385) → ShouldPruneHistory(true) bypasses 10_000 % 256 != 0 → prunes. That was inert before this commit; it works now. The <remarks> on StartHistoryPruner is accurate as written again, and the updated SchedulePruneHistory XML doc matches the new contract.

Memory model is fine: the plain write at :324 is published by Monitor.Exit, and the Volatile.Read at :288 only ever errs toward entering the lock, where the value is re-read accurately. The field comment says exactly that, which is the good kind of comment — keep it.

Also re-checked and clean: no new lock ordering (the flag is read/written only inside _pruneLock or on the advisory fast path), no new allocation or hot-path cost, _currentlyPruning CAS still serialises passes, plugin subclasses inherit the behaviour, the flag is per-process so a restart re-arms it, and PruningInterval = 0 configs are unaffected.

Initial_pruning_pass_bypasses_interval_once is a good test — head 100, _pruningInterval 256, so the first pass pruning to #36 is only explainable by the bypass, and the second pass (cutoff moved to #68 via RetentionEpochs = 1) leaving #50 in place is only explainable by the gate reasserting. That pins both halves. Initial_pruning_pass_remains_pending_after_missing_sync_pivot likewise asserts on an actual deletion, so it pins its path.

Findings

# Severity Where Issue
1 Low HistoryPrunerTests.cs:546-559 Initial_pruning_pass_remains_pending_while_ancient_backfill_is_held asserts only on OnPruningPassStarting, which HistoryPruner calls at :313before the interval gate at :318. The assertion passes regardless of whether the flag survived the hold, and the new pruningInterval: 8 argument has no effect on the outcome; reverting the production change leaves this test green. Assert on something gated by ShouldPruneHistory instead (e.g. the persisted delete pointer advancing), as the sibling sync-pivot test does. (detail)
2 Low HistoryPruner.cs:93 private int _initialPruningPassPending = 1; sits directly under volatile bool _hasLoadedDeletePointers / _stampsValidated, which have identical access patterns. volatile bool would match them and drop the != 0 / Volatile.Read ceremony; the int form is only warranted for _currentlyPruning, which is actually CAS'd. Purely cosmetic — take it or leave it.

For the record, not findings:

  • The bypass is consumed before the work runs, so a first pass truncated by PruningTimeoutSeconds does not get a second interval-free pass. That's the pre-existing continuation semantics and it's harmless in the target scenario (an idle node has no next trigger at all), and the PR body already documents --History.PruningTimeoutSeconds 0 for it.
  • Does_not_prune_when_disabled losing its explicit PruningInterval = 0 is safe — the Disabled case short-circuits on _enabled before the interval is consulted.
  • TryPruneHistory still returns early on SyncPivot.BlockNumber == 0, so a DB synced from genesis with no pivot still won't prune. Pre-existing, unchanged, and now explicitly covered by a test.

Not verified

dotnet build / dotnet test need approval in this environment, so I could not independently reproduce the "0 warnings, 55 + 3 tests passing" run reported in the trigger comment — everything above is from reading the code. Nothing in the diff suggests a compile problem: Volatile is already in scope (used at :241), and the two new test-helper parameters are defaulted so existing call sites are unaffected.

Verdict: F1 is properly fixed and the mechanism is the minimal one — one field, no new public surface, no existing behaviour changed for disabled or interval-zero nodes. Both remaining findings are Low; only #1 is worth acting on, and it's test-only. No blockers from my side.
· fix/history-pruner-startup-trigger

Comment thread src/Nethermind/Nethermind.History.Test/HistoryPrunerTests.cs
@kamilchodola

Copy link
Copy Markdown
Contributor Author

Fixed the important regression-test gap in 71ce6df. The ancient-backfill test uses interval 256 (head 10000 is not a boundary), verifies the held pass leaves the delete pointer absent, and verifies the pointer actually advances after the hold is released. This checks work behind the pruning gate, not just the earlier retention callback. All 55 HistoryPrunerTests passed.

@claude re-review

@claude

claude Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kamilchodola's task in 2m 20s —— View job


Re-review: test(history): assert pruning resumes after ancient backfill

  • Gather the updated diff (commit 71ce6df, test-only, +14/−1)
  • Verify the reworked ancient-backfill test now pins the invariant
  • Re-check the _initialPruningPassPending mechanism end-to-end
  • Post findings

My last finding is resolved — the test now has teeth

I re-traced the reworked Initial_pruning_pass_remains_pending_while_ancient_backfill_is_held (HistoryPrunerTests.cs:546-572) and it observes work strictly behind the gate now:

pass what happens what the test asserts
1 TryLoadDeletePointers() returns false on the ancient-backfill hold → SkipLocalPruning at HistoryPruner.cs:300, flag untouched no OnPruningPassStarting, and HistoryPruningDeletePointer absent
2 marker set + pointer seeded to 1 → load succeeds, stamps taken, ShouldPruneHistory(pending: true) bypasses the interval → PruneBlocksAndReceiptsUpdateBlocksDeletePointer(target) (:578) pointer advanced to CutoffBlockNumber

The second assertion is only reachable past ShouldPruneHistory (:319), and the interval genuinely has not elapsed on that pass: pruningInterval: 256 × SlotsPerEpoch = _pruningInterval 8192, head is 10_000, 10000 % 8192 = 1808 ≠ 0. So:

  • revert _initialPruningPassPending → pass 2 bails at the gate, pointer stays 1, test fails
  • move the _initialPruningPassPending = 0 clear above the TryLoadDeletePointers bail → pass 1 consumes it, pass 2 bails, test fails

Both mutants that the previous version survived are now caught. Seeding the pointer to 1 is the right setup, too — without it TryLoadDeletePointers discovers the frontier at 9,000, which is already past the cutoff, so there'd be no owed work for ShouldPruneHistory to find and the assertion would be vacuous in the other direction.

Also re-checked and unchanged from my last pass: Does_not_prune_when_pruning_is_disabled_or_sync_pivot_is_missing actually gained teeth from the bypass (with the default PruningInterval = 8 and head 100 the interval never elapses, so pre-bypass the Disabled case would have been blocked by the gate rather than by _enabled); Initial_pruning_pass_bypasses_interval_once pins both halves via head 100 % 256 ≠ 0; the flag's memory model, lock discipline, and the Enabled()/ReviewBlockTree wiring in StartHistoryPruner are all as previously verified.

Findings

None at Medium or above. One carried-over cosmetic note, unchanged and entirely optional:

# Severity Where Issue
1 Low HistoryPruner.cs:93 private int _initialPruningPassPending = 1; sits next to volatile bool _hasLoadedDeletePointers / _stampsValidated, which have identical access patterns. A volatile bool would match them and drop the != 0 / Volatile.Read ceremony; the int form only earns its keep for _currentlyPruning, which is genuinely CAS'd. Take it or leave it.

Two things I looked at and decided are not findings:

  • The flag is cleared at :324 before the work runs, so a first pass aborted by an exception (e.g. VerifyReclaimSupported on an unsupported layout) consumes the bypass. That exception is deterministic and would recur on every pass anyway, so nothing is lost.
  • A first pass truncated by PruningTimeoutSeconds likewise doesn't get a second interval-free pass — pre-existing continuation semantics, and the PR body already documents --History.PruningTimeoutSeconds 0 for the offline case.

Comment quality: the field comment at :92 and the updated SchedulePruneHistory <summary> both state the contract rather than restating the code — keep them.

Not verified

dotnet test needs approval in this environment, so I could not reproduce the reported "55 HistoryPrunerTests passing" run myself — everything above is from reading the code. Nothing in the diff suggests a compile problem: Rlp, RlpReader.DecodeULong(), and IPrunedReceiptRetention.RetainedHeights all exist with the signatures the test uses, and Nethermind.Serialization.Rlp / System.Collections.Generic are already imported in the file.

Verdict: the regression-test gap is properly closed and the mechanism is verified end-to-end. Nothing blocking from my side — the only open item is a one-word cosmetic suggestion.
· fix/history-pruner-startup-trigger

@LukaszRozmej LukaszRozmej 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.

Re-reviewed all five changed files at 71ce6df. The previous interval-gate and ancient-backfill regression-test findings are resolved. Two remaining findings are inline. Verification was by source inspection; tests were not run.

{
public Task Execute(CancellationToken cancellationToken)
{
if (historyConfig.Enabled()) historyPruner.SchedulePruneHistory();

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.

[P2] The documented offline workaround still times out. This schedules one pass, but History.PruningTimeoutSeconds=0 becomes timeout: null in HistoryPruner.SchedulePruneHistory, and BackgroundTaskScheduler.TryScheduleTask replaces null with its two-second DefaultTimeout; Activity.Do then cancels at that deadline. An idle node can therefore still leave its backlog unfinished with no next trigger. The timeout mismatch predates this PR, but invalidates the PR body's claim that disabling the timeout lets this single pass run to completion. Correct the timeout handling with a real-scheduler regression test, or correct the completion claim; the existing capture-only scheduler test does not exercise deadline cancellation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Confirmed, and it's sharper than "still times out": IHistoryConfig.cs:35 documents "Set to 0 to disable the timeout", but 0nullBackgroundTaskScheduler.cs:225 timeout ?? DefaultTimeout = 2 s, i.e. identical to the PruningTimeoutSeconds default. The documented option is a no-op, so the PR body's offline workaround changes nothing.

Argues for fixing it here rather than only correcting the body: map 0 to a bounded far-future deadline instead of null (Activity.Do calls cts.CancelAfter, so it has to stay under ~24 days).

@Marchhill Marchhill left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Mechanism verified: the startup ProcessingQueueEmpty (BlockchainProcessor.cs:349) really has no subscriber — every IHistoryPruner consumer sits in InitializeNetwork or later, and with SynchronizationEnabled=false SyncServer isn't built at all, so the pruner was never constructed. The one-shot bypass is correctly scoped (consumed only under _pruneLock after ShouldPruneHistory passes, so a backfill hold or missing pivot doesn't spend it) and both new tests positive-control it — head 100 % 256 != 0, so pass 1 runs only via the bypass and pass 2 is throttled. History/Runner lanes green on 71ce6df.

Holding off approval on @LukaszRozmej's P2, which is worse than stated: PruningTimeoutSeconds=0 is documented as "disable the timeout" but resolves to the scheduler's 2 s default, so the workaround this PR prescribes for its own motivating scenario is a no-op. Evidence on that thread.

Minor — the title ("create the history pruner before the block processor starts") no longer describes the change: StartBlockProcessor is untouched and the new step runs after InitializeNetwork.

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.

4 participants