feat(FlatDB): long finality support with persisted snapshots#12142
Conversation
The planner only emits little-endian Uniform key slots of width {2,4,8}; a 3-byte
slot is always BE and dispatched to UniformBE, so the LE 3-byte path was
unreachable. Remove Uniform3LE and its exclusive helpers (FloorScan24Le,
ScalarTail24Le, BinarySearch3LE, Pack24LeMask512) and the `3 =>` case in
BTreeNodeReader's LE dispatch; fix a stale comment reference in
HsstPackedArrayBuilder.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ound HsstRefEnumerator.Current returned a one-field KeyValueEntry wrapping a Bound. Remove the wrapper and expose the value range directly as a Bound via a renamed CurrentValueBound property; update the scanner and tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The HSST builder carried a writer-side read-back capability — the IByteBufferWriterWithReader<TReader,TPin> interface (OpenReader / DisposeActiveReader) layered on IByteBufferWriter — so the B-tree index builder could re-read the just-written data section to recompute separators. That design was replaced by carrying first-keys forward in CurrentLevelFirstKeys, so no read-back ever happens in production; OpenReader was only exercised by a single test. Delete the interface and collapse the now-redundant <TWriter,TReader,TPin> writer-side trio to <TWriter> (plain IByteBufferWriter) across HsstBTreeBuilder, HsstBTreeMerger, PersistedSnapshotBuilder/Merger and the value-merger structs. Strip ArenaBufferWriter's mmap-view / buffer-pinning machinery (OpenViewDelegate, GCHandle pinning, PromoteBufferForActiveReader) and PooledByteBufferWriter's WriterReader. The read path (WholeReadSessionReader, IArenaWholeView, WholeReadSession) is untouched. No behavioral change: the removed path was never invoked in production. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Derive allSameLen once as `minLen == maxLen` (before slot widening) instead of tracking it per iteration, and drop the unused `disablePrefix` parameter (no caller ever set it). Behavior-preserving. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Previously the planner widened minLen/maxLen up to a SIMD slot before computing
the common-prefix strip, which let the prefix and slot inflate beyond what the
actual separators justify. Drop the pre-strip widening; compute lcp on the raw
separator lengths and snap the post-strip residual (effMaxLen) to a {2,4,8} slot
via WidenedSlotWidth(effMaxLen, keyLength - lcp). Net effect: tighter slots/
prefixes for varying-length leaves; layouts still round-trip. Update the
LayoutPlanner test expectations to the post-strip values.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…or directly HsstRefEnumerator was a thin ref-struct that only stored the reader so callers wouldn't pass it to each MoveNext. Every consumer already holds the reader, so the wrapper just duplicated it and added an indirection layer. Callers now use HsstEnumerator<TReader,TPin> directly, threading their existing reader through MoveNext/CopyCurrentLogicalKey and reading CurrentValue instead of Current.ValueBound. Encapsulation is preserved: HsstEnumerator keeps the key private behind CopyCurrentLogicalKey, which is what KeyValueEntry was hiding. The default == Empty no-op-reset contract the scanner relies on moves onto HsstEnumerator's comment. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The planner had a single production consumer (the builder's index phase). Move MaxCommonKeyPrefixLen, the result struct (Plan -> LayoutPlan), the planner (Compute -> ComputeLayout) and WidenedSlotWidth onto HsstBTreeBuilder.Index.cs as internal members and delete BTreeNodeLayoutPlanner.cs. Tests reference the TWriter-independent statics via a concrete-instantiation alias; FORMAT.md updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tOnCurrentPage Address review comments on HsstBTreeBuilder: - Inline the single-use AddCore into its only caller Add, dropping the extra method and its ref/param threading. - Remove the Buffers ref-property wrapper; use the _buffers ref field directly. - Rename FlushPendingNotOnCurrentPage -> FinalizePendingNotOnCurrentPage (it seals stranded pending descriptors, it does not flush bytes). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the pooled byte[] with a NativeMemoryList<byte> held at Count == Capacity, so AsSpan() exposes the whole backing buffer and the writer slices the free tail with its own cursor. Grow-by-reconstruct mirrors the previous rent-a-bigger-buffer behavior. No NativeMemoryList API changes required. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- MaxIntermediateBytes references PageLayout.PageSize instead of a bare 4096. - Drop redundant pre-loop Clear()s in BuildIndex (the level loop clears). - Use BCL MemoryExtensions.CommonPrefixLength; delete the hand-rolled helper. - Inline the single-use bufsSingleton ref local. - MinIntermediateChildren 16 -> 4. - Always pad before each intermediate (drop the first-node guard). - Fold the cross-entry-LCP derivation into a single ComputeLayout that takes children + commonPrefixArr; delete the delegating overload and ComputeCrossEntryLcp. Tests drive it via a NodeWithCrossLcp helper. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…s.Container Move HsstBTreeBuilderBuffersContainer into HsstBTreeBuilderBuffers as a nested Container class and delete the standalone file; update all usages. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ader The useSpanReader fast path now pins the address bound via PinBuffer and feeds the pin's buffer to a SpanByteReader, matching the existing PinBuffer + using convention. Removes the bespoke zero-touch span accessor on ArenaByteReader. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- ArenaByteReader: use PageLayout.OsPageSize, and delegate the per-page touch loop to ArenaReservation.TouchRangePopulate (coalesces the madvise). - ArenaFile: tighten CreateWriteStream and OpenWholeView to internal (only in-assembly callers). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… class Move the page-eviction ring, background drain, dispatch, and counters out of ArenaManager into a nested EvictionDispatcher. The manager holds a single _evictor; QueueEviction and the Evictions* counters delegate to it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e loop Use a NativeMemoryList<byte> (Count==Capacity) like ArenaBufferWriter, and since trie-node RLP is bounded well below the buffer size, copy each value in one shot instead of the chunking loop. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move IPageEvictionHandler and TouchOutcome into PageResidencyTracker and PunchHoleOutcome into PosixReclaim as nested types; qualify all references. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ract DoCompactSnapshot/DoCompactPersistable are only invoked by the compactor's own background batch worker (and tests on the concrete type); the sole interface consumer uses Enqueue. Drop both from the interface, keep them public on PersistedSnapshotCompactor, and remove the no-op overrides from the null impl. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Group the inline 8-way clock cache's slots, meta, bit-packing constants, and lookup/insert/lock logic into a private struct. A struct keeps the Vector512 slots inline on the snapshot (no heap alloc, 64-byte alignment); TryGet does the lock-free scan + on-disk verify, Insert the spin-locked clock install. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Combine each bucket's To-keyed ConcurrentDictionary, block-ordered SortedSet, and running memory/count totals into one SnapshotBucket (12 fields -> 3). Lock discipline is unchanged: dictionary lock-free, ordered set + totals under the catalog lock, totals read via Interlocked. Global Metrics aggregates stay at the call sites; public surface is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Trim the XML doc and inline comments to their contract + non-obvious rationale (282 deletions / 125 insertions, comment-only — no code changes). Also drop the stale class remark claiming separators are recomputed from a data-section reader; the builder buffers first-keys in CurrentLevelFirstKeys and does no read-back. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…column The contiguous trie-RLP run for a base persisted snapshot was stored in the SnapshotCatalog entry (18 bytes, BlobRange.None for every compacted/persistable entry) — redundant with the snapshot's own on-disk metadata. Store it instead as a new `blob_range` key in the metadata HSST (column 0x00, sorts first), read back by the PersistedSnapshot ctor exactly like ref_ids. The metadata column is written last in PersistedSnapshotBuilder.Build, after every blobWriter.WriteRlp, so the run is final there; BlobArenaWriter.Complete only flushes and sets Frontier, so the range is byte-identical to the old repository computation. CatalogEntry drops BlobRange (entry 119 -> 101 bytes); catalog version bumped v7 -> v8 (wipe-and-resync). PersistenceManager and the fadvise paths are unchanged — they read the now-metadata-backed property. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A small CreateReader() factory shared by the scan and merge paths (readers are ref structs and can't be cached as fields). WholeReadSessionView implements it; IHsstMergeSource now extends it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Scanner is now PersistedSnapshotScanner<TSource,TReader,TPin> with all nested enumerables/enumerators over TReader/TPin instead of hardcoding WholeReadSessionReader. A ForWholeRead(session, snapshot) factory keeps the two call sites clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
NWayMergeSnapshots<TWriter,TView,TReader,TPin> replaces the WholeReadSession-bound entry; a generic ViewMergeSource and TailDispatchEnumeratorFactory<TReader,TPin> thread the reader through every column helper and value merger. Compactor and the test helper pass the existing WholeReadSessionView types. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…the reader The ref_ids metadata walk no longer bakes in ArenaByteReader; the reservation's reader is supplied at the boundary. The cache/warmup-bound point-query paths stay on ArenaByteReader by design. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment-only sweep across Nethermind.State.Flat (prod + tests): drop restate-the-code and over-verbose comments, condense the rest, and keep the why/invariant/layout/spec rationale. Also corrects a few stale comments (BTreeNodeWriter ValueSize flag bits 4-5, PackedArray stride/summary-depth, PersistedSnapshotReader/Repository doc references). Net -121 comment lines; no code changes (non-comment lines byte-identical), build clean, suite green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ReadBlobRange and every ref_ids walk (construction lease-walk, catch rollback, CleanUp, PersistOnShutdown) each re-walked the HSST root to find the metadata column. Resolve the scope once at construction into _metadataScope and seek keys within it, mirroring NWayMetadataMerge's scoped-reader pattern. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
0c8720a to
0a76eea
Compare
|
@claude please check the following review 🔴 Critical C1 — A lease keeps the mapping mapped; it does NOT protect page contents from concurrent reclaim. This is the load-bearing defect and it recurs three ways:
C2 — ArenaManager.MarkDead disposes the file mid-sequence → use-after-free in CleanUp. MarkDead calls file.Dispose() when DeadBytes >= Frontier (ArenaManager.cs:260), but ArenaReservation.CleanUp then keeps operating on that file — TryPunchHole/FadviseDontNeed/ForgetTrackerRange (ArenaReservation.cs:213-217) then a second Dispose (line 221). A concurrent transient lease-holder (TouchWarmPages, ArenaManager.cs:444) means this reservation isn't guaranteed to be the last lease, so MarkDead's Dispose can drive 1→0 and run CleanUp (unmap + delete) while the reservation is still syscalling the same handle. MarkDead's own docstring says its "sole job is set/dict/metric mutation" — honor that; drop the manager lease after the reservation finishes its file-side work. C3 — SortedTable readers trust on-disk values with incomplete bounds checks → silent wrong hit. These read from mmap'd, possibly-corrupt/truncated files:
C4 — Torn read of _currentPersistedStateId — CONFIRMED. (evidence above). Guard it with a Lock exactly as _lastCommittedStateId is, or publish an immutable boxed StateId via Volatile.Read/Write. Also remove the lock-free write at PersistenceManager.cs:72. 🟠 High
🟡 Medium (selected)
⚪ Low / cleanup Zero-alloc on the read fall-through is otherwise strong (stackalloc key bufs, ref-struct enumerators, no LINQ/closures on lookup), with residual new byte[] per resolved RLP (PersistedSnapshot.cs:214,291 — pool the >256B account temp); metrics Timer.Dispose() doesn't drain the in-flight callback vs _pageTracker.Dispose() (ArenaManager.cs:456-461); DRY across the 5 near-identical probe loops in PersistedSnapshotStack; CompactRange size/flag-arg (SRP); dead pageCount local (ArenaManager.cs:293); SmallRefCountingDisposable terminal CAS should Debug.Assert rather than silently skip CleanUp (:114-117). |
|
Claude finished @svlachakis's task in 7m 55s —— View job Verification of the submitted reviewI traced every claim against the actual code on TL;DR: The reviewer found real bugs — but the four items marked "Critical" (C1a/b/c, C2) are all false positives that rest on a misreading of 🔴 Critical
🟠 High
🟡 Medium (all confirmed unless noted)
Bottom line
I only verified the review here (no code changed). If you'd like, I can open a follow-up commit implementing the confirmed fixes (C3a, C4, H1, H5, H6 are small and localized). |
Confirmed against the code review on flat/simpler-long-finality: - C3(a): SpanByteReader.TryRead threw in Slice instead of returning a clean miss when output.Length > _data.Length — the (_data.Length - output.Length) int subtraction underflowed and widened to a huge ulong past the guard. Check the length first, mirroring PinBuffer. - C4: publish _currentPersistedStateId (a 40-byte StateId struct) as an immutable boxed reference accessed via Volatile, so query threads never observe a torn (BlockNumber, StateRoot) pair while the persistence worker updates it. - H1: fold the ArenaFile.Frontier write into the lock-held OnWriteCompleted so it is serialized against MarkDead's DeadBytes-vs-Frontier survival read (it was written off-lock in ArenaWriter.Complete). - H5: dispose the allStateIds ArrayPoolList on the early-exit path in ConvertCompactedRange (ownership only transfers to the compactor on the EnqueueAsync handoff). - WholeReadSession: release the reservation lease if OpenWholeView throws in the ctor; make Dispose idempotent via Interlocked. - ProcessCompactBatch: guard each per-item DoCompactSnapshot so one failure no longer aborts the whole batch and stalls the persisted tier (mirrors RunBoundaryCompactor). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@claude reevaluate |
…3b/c) From the code review (robustness hardening): - C3(b): reject a front-coding common-prefix (cp) that exceeds the previous record's key length — it would reconstruct the key from stale buffer bytes (a silent wrong key). Added to the forward scans in DataBlockReader, IndexBlockReader, and SortedTableEnumerator; a restart record (cp == 0) always passes. - C3(c): bounds-check each restart offset against the block's records region [recordsStart, RecordsEnd) before dereferencing it, in DataBlockReader and IndexBlockReader. All routed to SortedTable.ThrowCorrupt (wipe-and-resync) rather than a silent wrong hit or an ArgumentOutOfRange throw. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Claude finished @asdacap's task in 3m 40s —— View job Re-evaluation — status after the follow-up fix commitsI re-traced every previously-confirmed finding against the current tree (
✅ Confirmed landed & correct
🟠 Still open — three items remainH4 — conversion-skipped state still removed from memory (not fixed) H6 — catalog writes not durable and version-less (not fixed) CompactRange leak on IO/ctor throw (confirmed Medium, not fixed) 🟡 One inconsistency with your own stated preferenceYou commented "It should throw on any corruption with clear message." — the C3 hardening now does that everywhere except one site: (Note: the intentional split — a read that runs off the end → clean miss, a structurally-inconsistent value inside an already-read record → throw — is otherwise applied consistently; this one site is on the wrong side of it.) VerdictAll Critical and most High findings are resolved and verified correct. Remaining before merge: H4 (state-in-neither-tier — the most consequential of the three, a correctness bug reachable on a lease race), H6 (catalog durability/versioning), the CompactRange native-memory leak, and the one These are review findings only — I haven't changed code. Say the word and I'll implement any subset (H4 + CompactRange + the enumerator throw are one-liners; H6 needs a version byte + synced write). |
|
Catalog write use rockcsdb so it inherit rocksdb WAL. Convert is suppose to remove both base and compacted. THe compacted one will be replaced with persisted variant. Its also not necessary for correctness. Base compactionmust not fail though. |
…ruption throw - CompactRange: a throw from reservation.Fsync(), _catalog.Add(...), or the PersistedSnapshot ctor between arenaWriter.Complete() and the `using` left the reservation (its Complete() lease) and mergedBloom leaked — the finally disposes only the read sessions, so repeated disk-full / IO errors exhaust native memory. Wrap the commit region in try/catch that disposes both. Safe on the ctor-internal-throw path: the ctor releases only its own lease (not this Complete() one) and BloomFilter.Dispose is idempotent. - SortedTableEnumerator.TryAdvanceToNextDataBlock: a value-changed length past the u48 max is proven corruption inside an already-read record; throw via SortedTable.ThrowCorrupt instead of returning a miss that silently truncates the walk, matching IndexBlockReader.SeekCeiling. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| /// smaller per-instance footprint. Prefer it for types that exist in large numbers and whose lease | ||
| /// counts are rarely contended across cores. | ||
| /// </summary> | ||
| public abstract class SmallRefCountingDisposable(int initialCount = 1) : IDisposable |
There was a problem hiding this comment.
This is close to a copy of RefCountingDisposable, and I think the two have already diverged: the in-loop current <= NoAccessors check here fixes a hazard the base still has. If I'm reading RefCountingDisposable.ReleaseLeaseOnce correctly, an over-release racing a legitimate last release can retry with current == 0 and win the CAS 0 → -1 through the subtract path, the real last releaser's NoAccessors → Disposing CAS then fails and CleanUp is silently skipped.
Since this is the trickiest lock-free code in the PR, I think it would be worth either sharing the state machine between the two (parameterised over the counter storage), or at least porting this guard back to the base, otherwise the next fix lands in only one of the copies.
There was a problem hiding this comment.
Good catch — that base hazard is real. Ported the guard to RefCountingDisposable in 7e60f40: the release loop now re-validates current <= NoAccessors on every CAS retry. Kept the two classes separate rather than parameterising over the counter storage — the fields differ (cache-line-padded vs inline long) and unifying would put a virtual ref-accessor on the lease hot path.
— posted by asdacap's agent (Claude Fable 5)
| /// bloom). Also owns the detailed metrics recorded around the probe loops: each | ||
| /// <c>*_persisted_snapshot</c> hit label and the per-key-kind skip-time observations. | ||
| /// </remarks> | ||
| public sealed class PersistedSnapshotStack( |
There was a problem hiding this comment.
Since these loops are on the per-account/per-slot read path: _snapshots[i] is indexed twice per iteration (bloom + lookup). Might be worth hoisting a local PersistedSnapshot snap = _snapshots[i]; ?
There was a problem hiding this comment.
Done in 5c55b6f — all five probe loops hoist the snapshot into a local.
— posted by asdacap's agent (Claude Fable 5)
|
|
||
| namespace Nethermind.State.Flat.PersistedSnapshots.Storage; | ||
|
|
||
| public unsafe interface IArenaManager : IDisposable |
There was a problem hiding this comment.
ArenaReservation only uses the reclaim side of this interface (PageTracker, QueueEviction, ForgetTrackerRange, MarkDead, TryPunchHole), but it also gets Initialize, CreateWriter, Open and Dispose. I think splitting the reclaim callbacks into a narrower interface (e.g. IArenaReclaim) and having the reservation take that would keep the read side away from the lifecycle surface. Not a blocker.
There was a problem hiding this comment.
Done in 7e60f40 — the reclaim callbacks moved to a new IArenaReclaim (which IArenaManager extends), and ArenaReservation now takes the narrow interface.
— posted by asdacap's agent (Claude Fable 5)
There was a problem hiding this comment.
Correction: the split was reverted in 17a5996 at the author's request — ArenaReservation stays on IArenaManager.
— posted by asdacap's agent (Claude Fable 5)
| Span<byte> key = stackalloc byte[KeySize]; | ||
| WriteKey(key, entry.To, Depth(entry)); | ||
| EntryBytes value = new(entry); | ||
| _db.Set(key, MemoryMarshal.AsBytes(new Span<EntryBytes>(ref value)).ToArray()); |
There was a problem hiding this comment.
PutSpan would avoid the .ToArray() copy here?
There was a problem hiding this comment.
Done in 5c55b6f — PutSpan now.
— posted by asdacap's agent (Claude Fable 5)
| return false; | ||
| } | ||
| int bLenInt = checked((int)b.Length); | ||
| Span<byte> buf = bLenInt <= 256 ? stackalloc byte[256] : new byte[bLenInt]; |
There was a problem hiding this comment.
I think the new byte[bLenInt] fallback is unreachable in practice? slim account RLP tops out around ~110 bytes, well under the 256-byte stackalloc. If it stays as a safety net maybe ArrayPool.Shared.Rent would match better with the pattern used elsewhere?
There was a problem hiding this comment.
Done in 5c55b6f — since the fallback is unreachable for well-formed data, an oversized value now throws as corruption (SortedTable.ThrowCorrupt) instead of allocating.
— posted by asdacap's agent (Claude Fable 5)
| GC.SuppressFinalize(this); | ||
| } | ||
|
|
||
| ~PageResidencyTracker() => Dispose(); |
There was a problem hiding this comment.
Maybe better would be dropping the finalizer? It calls NativeMemory.AlignedFree(_slots) on memory that TryTouch/ContainsPage read lock-free, and a finalizer can run mid call once this is no longer referenced so a missed Dispose becomes a potential native use-after-free rather than a plain leak.
Since ArenaManager always disposes the tracker explicitly, the finalizer only seems to add the worse failure mode.
Am I maybe missing something?
There was a problem hiding this comment.
You are right — dropped the finalizer in 5c55b6f; a missed Dispose is now a plain leak instead of a potential native use-after-free.
— posted by asdacap's agent (Claude Fable 5)
- PersistedSnapshotStack: hoist the per-iteration snapshot into a local in the five newest-first probe loops so the list is indexed once, not twice. - SnapshotCatalog.Add: write the entry via PutSpan instead of Set(..ToArray()), dropping a per-add array copy. - PersistedSnapshot.TryGetAccount: a slim account RLP cannot exceed the 256-byte stackalloc, so treat an oversized value as corruption and fail loudly via SortedTable.ThrowCorrupt rather than heap-allocating for it. - PageResidencyTracker: drop the finalizer. It freed native memory that TryTouch/ContainsPage read lock-free, turning a missed Dispose into a use-after-free instead of a plain leak; the owning arena manager always disposes explicitly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adapt master's Dispose_GivesUpWaiting_ReaderOutlivesInFlightWarmup test (added in #12237) to this branch's ReadOnlySnapshotBundle ctor, which takes a required PersistedSnapshotStack. The test wants no persisted snapshots, so it passes PersistedSnapshotStack.Empty(). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…erged test Master's Dispose_GivesUpWaiting_ReaderOutlivesInFlightWarmup (#12237) built a ReadOnlySnapshotBundle with the pre-branch 3-arg ctor. This branch requires a PersistedSnapshotStack; the test wants none, so pass Empty(). This adaptation was intended for the preceding merge commit but was left unstaged, so the merge landed with the unbuildable call site. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
wurdum
left a comment
There was a problem hiding this comment.
The small nitpick for AI agent fix because PersistedSnapshot:TryGetAccount already has these defensive range checks to ensure no bit-rot can pass unspotted. Feel free to ignore, though
DataBlockReader.cs (SeekCeilingCore, around line 153):
long valueStart = keyStart + suffixLen;
if (valueStart + rec.ValueLength > end) // end = blockStart + header.RecordsEnd
SortedTable.ThrowCorrupt($"data record at byte {pos} declares value length {rec.ValueLength} overrunning the records region");ResolveTrieRlp (PersistedSnapshot.cs:305):
internal byte[] ResolveTrieRlp(Bound localBound)
{
if (localBound.Length != NodeRef.Size)
SortedTable.ThrowCorrupt($"trie-node value length {localBound.Length} != NodeRef size {NodeRef.Size}");
NodeRef nodeRef = default;
Span<byte> nr = MemoryMarshal.AsBytes(new Span<NodeRef>(ref nodeRef)); // full 6 bytes now that length is checked
ArenaByteReader reader = _reservation.CreateReader();
if (!reader.TryRead(localBound.Offset, nr))
SortedTable.ThrowCorrupt($"trie-node NodeRef at [{localBound.Offset}, {localBound.Offset + localBound.Length}) runs past the reservation");
return ReadBlobArenaRlp(nodeRef.BlobArenaId, nodeRef.RlpDataOffset);
}TryGetSlot (PersistedSnapshot.cs:232):
int len = checked((int)b.Length);
if (len > PersistedSnapshotTags.RlpSlotValueBufferSize)
SortedTable.ThrowCorrupt($"slot RLP length {len} exceeds the {PersistedSnapshotTags.RlpSlotValueBufferSize}-byte maximum");
Span<byte> buf = stackalloc byte[PersistedSnapshotTags.RlpSlotValueBufferSize];
Span<byte> raw = buf[..len];
if (!reader.TryRead(b.Offset, raw))
SortedTable.ThrowCorrupt($"slot value at [{b.Offset}, {b.Offset + b.Length}) runs past the reservation");TryGetAccount (PersistedSnapshot.cs:221) — honor the result (guard already present):
if (!reader.TryRead(b.Offset, rlp))
SortedTable.ThrowCorrupt($"account value at [{b.Offset}, {b.Offset + b.Length}) runs past the reservation");
LukaszRozmej
left a comment
There was a problem hiding this comment.
Trim comments - especially in tests?
It feels like there is some potential to de-duplicate parts of code?
Extremely complex - maintenance increase :(
Do we need to do our own Arena's? (Maybe yes)
| internal readonly struct DataRecordHeader(byte commonPrefix, byte suffixLength, byte valueLength) | ||
| { | ||
| internal readonly byte CommonPrefix = commonPrefix; | ||
| internal readonly byte SuffixLength = suffixLength; | ||
| internal readonly byte ValueLength = valueLength; | ||
| } | ||
|
|
||
| /// <summary>Fixed 3-byte prefix of an index record: the front-coded key (cp, suffixLen) then the | ||
| /// number of little-endian low-order value bytes stored in <see cref="ValueChangedLength"/>. Layout | ||
| /// <c>[cp][suffixLen][valChangedLen][keySuffix][valChanged]</c>; the value (a data-block byte offset) | ||
| /// keeps the high bytes of the previous record's value and overwrites only its low bytes, reset against | ||
| /// 0 at a <c>cp == 0</c> restart (see <see cref="BlockBuilder.AddChangedPrefixValue"/>).</summary> | ||
| [StructLayout(LayoutKind.Sequential, Pack = 1)] | ||
| internal readonly struct IndexRecordHeader(byte commonPrefix, byte suffixLength, byte valueChangedLength) | ||
| { | ||
| internal readonly byte CommonPrefix = commonPrefix; | ||
| internal readonly byte SuffixLength = suffixLength; | ||
| internal readonly byte ValueChangedLength = valueChangedLength; | ||
| } |
There was a problem hiding this comment.
Done in 7e60f40 — both are readonly record structs now (same sequential Pack=1 layout).
— posted by asdacap's agent (Claude Fable 5)
| /// the block-relative records-end and restart count. Read by reinterpreting the leading bytes (the | ||
| /// <c>u32</c> fields are little-endian on disk, matching the host on supported targets).</summary> | ||
| [StructLayout(LayoutKind.Sequential, Pack = 1)] | ||
| private readonly struct Header |
There was a problem hiding this comment.
same struct in DataBlockReader?
There was a problem hiding this comment.
Different size. These struct are direct copied so need to be different.
| throw new ObjectDisposedException(nameof(BlobArenaManager)); | ||
|
|
||
| ushort? chosen = null; | ||
| List<ushort>? toRemove = null; |
There was a problem hiding this comment.
Some opportunities for pooling across the PR. ArrayPoolListRef/ArrayPoolList and PooledHashSet/PooledDictionary
There was a problem hiding this comment.
Pooled the flagged scratch list with ArrayPoolList in 7e60f40, matching PersistedSnapshotBucket.PruneBefore. The read/merge hot paths already run on pooled/native collections (NativeMemoryList, ArrayPoolList, arena buffers); the remaining plain collections are cold one-offs (startup load, shutdown, per-compaction bookkeeping).
— posted by asdacap's agent (Claude Fable 5)
| ulong p0 = MemoryMarshal.Read<ulong>(encoded); | ||
| ulong p1 = MemoryMarshal.Read<ulong>(encoded[8..]); | ||
| ulong p2 = MemoryMarshal.Read<ulong>(encoded[16..]); | ||
| ulong p3 = MemoryMarshal.Read<ulong>(encoded[24..]); |
There was a problem hiding this comment.
Deduped in 7e60f40 — the three key functions now share a Fold32 XOR-fold helper.
— posted by asdacap's agent (Claude Fable 5)
| _enableDetailedMetrics = enableDetailedMetrics; | ||
|
|
||
| // Must run before any background worker or read can access the persisted tier. | ||
| persistedSnapshotLoader.Load(); |
…hots - RefCountingDisposable: port the in-loop release guard from SmallRefCountingDisposable — re-validate `current <= NoAccessors` on every CAS retry so an over-release racing the legitimate last release can no longer win the 0 -> -1 CAS through the subtract path and silently skip CleanUp. - PersistedSnapshotBloomBuilder: extract the shared Fold32 XOR-fold used by SlotKey and both StatePathKey overloads. - Block: collapse DataRecordHeader/IndexRecordHeader to readonly record structs (same sequential Pack=1 layout). - IArenaManager: split the reclaim-side callbacks (MarkDead, TryPunchHole, ForgetTrackerRange, QueueEviction, PageTracker) into IArenaReclaim; ArenaReservation now takes the narrow interface, keeping the read path away from the manager's lifecycle surface. - BlobArenaManager.CreateWriter: pool the stale-file scratch list with ArrayPoolList, matching PersistedSnapshotBucket.PruneBefore. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…IArenaManager Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Master's #12339 added the write-time HintWarmSlot guard against StorageCell.IsHash after #12351 had removed the member, so the merged tree did not compile. Same resolution as the standalone master fix (#12355): a cell is never hash-mode anymore, hint unconditionally. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
|
||
| _compactSize = cs; | ||
| _compactSize = config.CompactSize; | ||
| _maxCompactSize = config.PersistedSnapshotMaxCompactSize; |
There was a problem hiding this comment.
PersistedSnapshotMaxCompactSize is taken raw here while CompactSize (line 23) goes through ValidateCompactSize() + an explicit power-of-2 check. It gets no >= CompactSize, power-of-2, or int-range validation.
If PersistedSnapshotMaxCompactSize < CompactSize (e.g. 16 vs 32), GetPersistedSnapshotCompactSize caps at 16 and can never reach CompactSize(32), so both IsCompactSizeBoundary (== _compactSize) and IsLargeCompactionBoundary (> _compactSize) return false for every block. That violates the ICompactionSchedule doc's promise that the two predicates "together cover every persistence boundary," and no persisted-snapshot boundary is ever detected.
Secondary variants of the same gap: a value > int.MaxValue overflows the (int)GetPersistedSnapshotCompactSize(...) casts in PersistedSnapshotCompactor; a non-power-of-2 value between CompactSize and 2×CompactSize makes the ==/> classification inconsistent.
Suggest extending ValidateCompactSize to also validate PersistedSnapshotMaxCompactSize (power-of-2, >= CompactSize, <= int.MaxValue - 8).
There was a problem hiding this comment.
Done in 0aa4ac5 — ValidateCompactSize now also rejects a PersistedSnapshotMaxCompactSize that is below CompactSize, not a power of 2, or past int.MaxValue - 8, with a parameterized regression test in CompactionScheduleTests. The one test that relied on the invalid geometry (offset-alignment window regression) moved to maxCompactSize = 64; its alignment (16) stays below CompactSize either way, so the exercised window is unchanged.
— posted by asdacap's agent (Claude Fable 5)
| // write Disposing(-1) via the subtract below, spuriously and bypassing the dedicated | ||
| // NoAccessors -> Disposing CAS, leaving the genuine last-releaser's CleanUp unrun. Mirrors | ||
| // SmallRefCountingDisposable.ReleaseLeaseOnce. | ||
| if (current <= NoAccessors) |
There was a problem hiding this comment.
This current <= NoAccessors guard was added to ReleaseLeaseOnce, but the sibling TryAcquireLease above still only rejects Disposing (current == Disposing), so it can acquire at count 0 — unlike SmallRefCountingDisposable.TryAcquireLease, which rejects current <= NoAccessors with a comment explaining the revival hazard.
This isn't a use-after-free: CleanUp (mmap close + File.Delete) is gated on the exclusive 0 -> Disposing CAS, which a revival at 0 makes fail, so the mapping stays valid while any lease is held. But ArenaFile/BlobArenaFile extend this base, and ArenaManager.Open's doc explicitly promises the ctor "surfaces an InvalidOperationException" if the file "has already started its CleanUp." In the count-0 window Open instead succeeds, handing back a live reservation on a file the manager has already TryRemove'd from _arenas and ReportRemoved()'d — an orphaned, untracked-but-live reservation plus transient metric skew, contradicting the documented contract.
The asymmetry (base TryAcquireLease has no mirror guard while the sibling and consumers assume it) reads as an oversight — suggest aligning the base with SmallRefCountingDisposable.TryAcquireLease.
There was a problem hiding this comment.
Fixed by the latest master merge (632dbb2): #12335 upstreamed the shared RefCountingLease state machine, and its TryAcquire rejects current <= NoAccessors inside the retry loop — both RefCountingDisposable and SmallRefCountingDisposable now delegate to it, so the base can no longer acquire at count 0 and the ArenaManager.Open contract holds.
— posted by asdacap's agent (Claude Fable 5)
| Bound newestTable = new(0, newest.Length); | ||
|
|
||
| // Metadata keys (column 0xFF) are emitted in ascending name order so the streaming builder's | ||
| // strict-ascending invariant holds: from_block < from_hash < noderefs < to_block < to_hash < version. |
There was a problem hiding this comment.
Nit: this comment lists ... < to_hash < version, but no version metadata key is ever emitted by MergeMetadata, and it omits that blob_range (which sorts first) is intentionally dropped here. Doc-only — the actual emitted set is from_block, from_hash, noderefs, to_block, to_hash.
There was a problem hiding this comment.
Fixed in 0aa4ac5 — dropped the phantom version key and noted that blob_range (which sorts first) is intentionally not carried over, since a merged snapshot references blobs via its ref-id records rather than a contiguous run.
— posted by asdacap's agent (Claude Fable 5)
# Conflicts: # src/Nethermind/Nethermind.Core/Utils/RefCountingDisposable.cs # src/Nethermind/Nethermind.Core/Utils/SmallRefCountingDisposable.cs
…Size A max below CompactSize caps GetPersistedSnapshotCompactSize under both boundary predicates (== / > CompactSize), so IsCompactSizeBoundary and IsLargeCompactionBoundary never fire and no persisted-snapshot boundary is ever detected; a non-power-of-2 value misclassifies boundaries the same way and a value past int.MaxValue - 8 overflows the compactor's int casts. ValidateCompactSize now rejects all three. The offset-alignment regression test moves to maxCompactSize=64 — its alignment (16) stays below CompactSize either way, so the exercised window geometry is unchanged. Also corrects the merger metadata-order comment: there is no version key, and blob_range is intentionally not carried over. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Summary
Adds a long-finality storage tier to
Nethermind.State.Flatso the in-memorysnapshot ring (today bounded by
MaxReorgDepth, default 256) can be extended to~90,000 blocks (configurable via
LongFinalityMaxReorgDepth) without theper-snapshot RAM cost growing linearly. Snapshots that fall off the in-memory
tail are converted into immutable, mmap-backed persisted snapshots stored in
arena files, then logarithmically merged ("Linked" compaction) so the snapshot
count stays bounded.
The new tier is opt-in (
FlatDb.EnableLongFinality = falseby default) and sitsunderneath the existing in-memory snapshot ring; the persistence pipeline,
RPC/sync surface and consensus paths are unchanged.
Motivation
MaxReorgDepth(~256). Every in-memorysnapshot keeps its diff in RAM, so raising that ceiling is proportionally
expensive in heap.
reorg window (long-finality CL designs, archive-lite serving) need a way to
keep many more snapshots cheap to retain and cheap to read.
means cold snapshots cost only file-descriptor + index metadata until queried,
and a
PageResidencyTrackerper arena drivesmadvise(DONTNEED)so theresident set is bounded by a configured page-cache budget rather than by total
snapshot bytes on disk.
High-level design
Mirrors the in-memory
SnapshotpipelineA
PersistedSnapshotis the on-disk twin of an in-memorySnapshot: same(From, To)semantics, sameStateId-keyed lookups, same hierarchicalcompaction policy. The in-memory compactor keys merge decisions off the
lowest-set-bit of the block number, giving 1×, 2×, 4×, …,
CompactSize× layers;the persisted-tier compactor uses the same lowest-set-bit rule
(
CompactionSchedule) so the persisted-snapshot stack is just a continuation ofthe in-memory snapshot stack across the tier boundary. The number of live
persisted-snapshot layers is therefore logarithmic in the configured reorg
depth rather than linear. Reads fall through from the in-memory
Snapshottothe
PersistedSnapshotat the same block/root once the in-memory window hasrotated past it; eviction and compaction follow the same mirror.
Single-level SortedTable on-disk format
Persisted snapshots are not RocksDB-shaped. The original #11663 used a
purpose-built HSST (Hierarchical Static Sorted Table) with nested index
variants. This branch replaces HSST with a single-level
SortedTable: aflat, two-level block layout (fixed-size data blocks + a tail index block,
located via a fixed footer). Lookups are LevelDB-style — the index block
locates the data block, then
DataBlockReader.SeekCeilingbinary-searches thedata block's restart table and scans forward to the first key ≥ target. It is
written by a streaming builder that enforces globally-ascending key order, so
it never buffers the whole record set. This is dramatically simpler than the
multi-variant HSST while keeping the seek-cheap, mmap-friendly, point-queryable
properties.
Key layout. The table is a single ascending byte-sorted map; all
persisted-snapshot columns are folded into one keyspace by fully-verbose keys
whose leading column / sub-column tag bytes are stored as
255 − tag, soplain ascending byte order reproduces the columnar emission order (outer columns
and per-address sub-tags descend while the entity bytes ascend). Key shapes
(tag bytes shown as their stored
255 − tagvalue):FEcolumn under the20-byte address, split by a per-address sub-tag (
FDself-destruct <FEslot <
FFaccount) — self-destruct sorts ahead of the slots it filters sothe merger resolves the truncation barrier before streaming the slots.
33-byte fallback (32-byte path hash + length byte) — so short hot paths stay
compact.
00) sort below every real column, so the blob arenasa snapshot references iterate cheaply from the table start.
Delta encoding. Within a block, records are front-coded: each stores a
common-prefix length
cpagainst the previous key plus the changed key suffix.cp == 0is a restart (a full key), forced at least everyrestartIntervalrecords (and wherever adjacent keys share no leading byte); the restart table
indexes every restart for the binary search. The index block's values — u48
data-block byte offsets — are delta-coded the same way: each keeps the high
little-endian bytes of the previous offset and stores only the changed low
bytes, reset to 0 at each restart.
Trie RLP split out into separate blob arenas
The biggest size win comes from not storing trie-node RLP inside the
persisted-snapshot table. Each snapshot stores only compact
NodeRefs where theRLP would have lived; the RLP bytes themselves live in append-only blob arena
files addressed by those refs, mmap'd and tracked by a
PageResidencyTracker(with a punch-hole reclaim path). This shrinks the per-snapshot table by roughly
an order of magnitude vs. inlining RLP, and:
the RLP — compacted snapshots'
NodeRefs still point at the original blobs;file is deleted only when no live snapshot still references it), so long-lived
trie nodes survive arbitrarily many compactions without being copied.
In-memory bloom filters skip cold snapshots
Each persisted snapshot ships with a tiny in-RAM bloom filter
(
PersistedSnapshotBloomBitsPerKey, default 14.0 bpk) coveringaddress/slot/self-destruct keys plus state/storage trie-node paths. Lookups walk
the snapshot stack newest→oldest and skip any snapshot whose bloom says
"definitely not here" without touching its mmap'd table. Large-compaction blooms
are shared across the snapshots they contain to bound bloom RAM.
Multi-tier persisted snapshots + hierarchical compaction
Persisted snapshots are bucketed (base / small-compacted / large-compacted /
CompactSized) and merged hierarchically with
(To − From)doubling at eachlevel, bounded by
PersistedSnapshotMaxCompactSize. Compaction only rewritesSortedTables; blob arenas are inherited through the
NodeRefindirection. Large(
> CompactSize) merges run on a separate background worker so they don't blockthe per-block batch worker.
Lifecycle / GC
Snapshot → PersistedSnapshotconversion, trie nodes are marked persistedand pruned from the in-memory trie.
PageResidencyTrackerper arena drivesmadvise(DONTNEED)(and a backgroundkeep-warm pass) so the resident set tracks the configured page-cache budget;
reported to the GC and exposed as per-tier gauge metrics.
LongFinalityMaxReorgDepthis the hard tail — once exceeded, persistence intothe RocksDB tier is forced to bound memory.
Config surface
New / relevant keys on
IFlatDbConfig(opt-in feature, conservative defaults):EnableLongFinalityfalseLongFinalityMaxReorgDepth90000MaxReorgDepth256MinReorgDepth128MaxInMemoryBaseSnapshotCount128CompactSize32PersistedSnapshotMaxCompactSize1048576ArenaFileSizeBytes1 GiBPersistedSnapshotDedicatedArenaThresholdBytes1 GiBPersistedSnapshotArenaPageCacheBytes4 GiBPageResidencyTracker(0 disables it).PersistedSnapshotPunchHoleOnReclaimtruefallocate(PUNCH_HOLE)to free disk blocks on reclaim (auto-disabled if unsupported).PersistedSnapshotBloomBitsPerKey14.0ValidatePersistedSnapshotfalseObservability
bytes, bloom memory, per-tag reservation count/bytes, live persisted-snapshot
bloom count.
PageResidencyTrackereviction / refresh / inline-dispatch counters.Compatibility
EnableLongFinality=falsenone of thepersisted-snapshot code runs. RocksDB column-family layout is preserved.
SnapshotCatalogschema areversioned; pointing at an incompatible older directory trips a
"wipe and resync" error.
Performance
The SortedTable format targets the same envelope as the HSST design in #11663
(opt-in tier adds no measurable overhead when the persisted tier is idle; deeper
stacks cost the steady price of walking more snapshots + background bloom/table
writes + hierarchical compaction). Up-to-date numbers are produced by the
in-repo reproducible-benchmark workflow
(
.github/workflows/run-expb-reproducible-benchmarks.yml).Types of changes
Testing
Requires testing
If yes, did you write tests?
Notes on testing
Nethermind.State.Flat.Testcovers the SortedTable round-trips (builder /reader / hash index), persisted-snapshot build/read/merge/compact paths,
page-tracker eviction, bloom correctness, and the persistence/compaction/reorg
policies end-to-end. Current status after the master merge: 777 passed, 4
pre-existing skips.
ValidatePersistedSnapshot=truecross-checks every persisted snapshot againstthe source in-memory snapshot at conversion time.
EnableLongFinality=trueand sync mainnetpast a window larger than
MaxReorgDepth; confirm the persisted-snapshot countgrows then plateaus once compaction kicks in, the
PageResidencyTrackerresident bytes track the configured budget, and there are no
Invalid Block/Exceptionlines.Documentation
Requires documentation update
Requires explanation in Release Notes
(
FlatDb.EnableLongFinality). See config keys above."Remarks
meaningful only end-to-end (format + write path + read path + compactor +
lifecycle + observability). Splitting it further would introduce dead, unwired
code into
masterbetween PRs.