Skip to content

perf: ColumnsDB minor fixes - #12831

Merged
alexb5dh merged 11 commits into
masterfrom
perf/db-tweaks
Aug 19, 2026
Merged

perf: ColumnsDB minor fixes#12831
alexb5dh merged 11 commits into
masterfrom
perf/db-tweaks

Conversation

@alexb5dh

@alexb5dh alexb5dh commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Makes the read-ahead iterator pool lazy, as most columns don't use it, and prevents race condition in pool's timer during shutdown.

Adds missing disposal for columns and their readers.

Split from #12813 as merging criteria are different.

Changes

  • Makes the read-ahead IteratorManager lazy (so most DBs don't create it and related resources at all), and skips it entirely for a DB with read-ahead turned off.
  • Disposes the column DBs on ColumnsDb disposal. ColumnDb.Dispose existed but was never called, so every column leaked its IteratorManager (and the manager's Timer) for the process lifetime.
  • Disposes RocksDbReader in ColumnDb.Dispose. The reader owns two native ReadOptions handles it created itself.
  • Adds a ThrowIfDisposing check to DbOnTheRocks.GetWithIterator, matching the other read entry points, so a read that starts during disposal fails instead of touching a half-torn-down pool.
  • Adds DisposableLazy<T>, a Lazy<T> that cannot leak an undisposed value.
  • Guards IteratorManager's cleanup timer against disposal with a lock. OnTimer could previously clear iterators while Dispose was disposing them, causing unmanaged access violation or other issues.

Types of changes

What types of changes does your code introduce?

  • Bugfix (a non-breaking change that fixes an issue)
  • Optimization

Testing

Requires testing

  • Yes

If yes, did you write tests?

  • No

Documentation

Requires documentation update

  • No

Requires explanation in Release Notes

  • No

@claude

claude Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Claude finished @alexb5dh's task in 6m 41s —— View job


Review: perf: ColumnsDB minor fixes

The three real fixes here are sound and I verified each one against the surrounding code:

  • The leak is real and the fix is correct. ColumnDb.Dispose() genuinely had no caller — ColumnsDb never disposed its columns — so every column leaked an IteratorManager plus its 10s Timer for the process lifetime. Overriding ReleaseUnmanagedResources is the only available hook (DbOnTheRocks.Dispose isn't virtual), and the ordering is right: columns are released before base disposes _db.
  • No premature-disposal hazard from the new _reader.Dispose(). I checked every GetColumnDb consumer (BlobTxStorage, LogIndexStorage, SyncPointers, HistoryReader/Writer, ReceiptMigration, DebugBridge, the flat-DB persistences) and ReadOnlyColumnsDb/ReadOnlyDb — none dispose the returned IDb, and no DI lambda registers a ColumnDb as an owned instance. So destroying the two ReadOptions handles can't fire while the DB is still live.
  • The IteratorManager lock is correct. Dispose and OnTimer are properly mutually exclusive; Timer.Dispose() inside the lock is safe because a callback that hasn't yet reached TryEnter will observe _isDisposed afterwards, and OnTimer never waits on Dispose, so there's no deadlock. _columnDbs is a field initializer, so the ColumnsDb override is also safe on a partially-constructed instance.

Findings below; nothing blocks on correctness of the happy path.

Findings

# Severity Finding
1 Medium ThrowIfDisposing in GetWithIterator narrows but doesn't close the shutdown race — and disposing column managers makes it newly reachable for columns. A reader past the check can have Return() throw ObjectDisposedException from the disposed ThreadLocal, leaving a rented native Iterator undisposed and finalized after _db.Dispose(). Shutdown-only, so possibly acceptable — but it should be acknowledged rather than presented as fixed.
2 Medium DisposableLazy<T> ships with no tests. It's new, pure-managed, concurrency-sensitive code that needs no RocksDB instance to test, and the key invariant (dispose-without-create must not invoke the factory) is exactly the leak this PR fixes. AGENTS.md: "when fixing a bug, always add a regression test".
3 Low Behaviour change when ReadAheadSize=0: HintReadAhead reads previously still used a pooled (non-tailing) iterator with the TryCloseReadAhead sequential fast path; they now become plain point lookups. Almost certainly intended, but it's a behaviour change, not just an allocation saving.
4 Low <summary> on CreateLazyReadAheadIteratorManager describes the return value — belongs in <returns>. Suggestion posted.
5 Low DisposableLazy<T> placement/visibility: a general-purpose utility with no RocksDB dependency, added as a new public type in a plugin-facing assembly. Its companion DisposeIfCreated already lives in Nethermind.Core.Extensions.
6 Low ODE from DisposableLazy names DisposableLazy<IteratorManager>, not the DB/column — unhelpful in a shutdown log. The <remarks> also doesn't cover the created-concurrently-with-disposal window.
7 Low Missing blank line before public string Name { get; } in ColumnDb. Suggestion posted.

Adjacent observation (pre-existing, out of scope)

DbOnTheRocks.Dispose() calls the private InnerFlush(onlyWal), which issues rocksdb_flush on the DB handle — that flushes only the default column family. ColumnsDb.Flush overrides the public method precisely because per-CF flushing "is required for WriteFlags.DisableWAL writes: they have no WAL entry, so unless their memtable is flushed they are lost on restart" — but that override is bypassed on the shutdown path. Only bites with FlushOnExit=Full (the default is WalOnly), and it predates this PR, so it's not a blocker. Flagging it because you're in this code.

Not verified

I could not run dotnet build or the test suite — the sandbox denied the build command. The review is static only; the type change from IteratorManager to DisposableLazy<IteratorManager> touches only the three construction sites I checked (ColumnDb, DbOnTheRocks, RocksDbReader) and ColumnsDb.ColumnDbSnapshot doesn't pass an iterator manager, so I'd expect it to compile, but CI should confirm.

Review checklist
  • Read PR diff
  • Read surrounding code (DbOnTheRocks, ColumnDb, ColumnsDb, RocksDbReader)
  • Verify no consumer disposes a ColumnDb early (GetColumnDb call sites, DI ownership, ReadOnlyDb)
  • Analyze IteratorManager lock for deadlock / missed-tick / finalizer safety
  • Analyze DisposableLazy create-vs-dispose races
  • Check disposal ordering vs _db.Dispose() and flush-on-exit
  • Build / run tests — blocked (command not permitted in this sandbox)
  • Post review
· branch [`perf/db-tweaks`](https://github.com/NethermindEth/nethermind/tree/perf/db-tweaks)

Comment thread src/Nethermind/Nethermind.Db.Rocks/DbOnTheRocks.cs
Comment thread src/Nethermind/Nethermind.Db.Rocks/DbOnTheRocks.cs Outdated
Comment thread src/Nethermind/Nethermind.Db.Rocks/DisposableLazy.cs
Comment thread src/Nethermind/Nethermind.Db.Rocks/DisposableLazy.cs
Comment thread src/Nethermind/Nethermind.Db.Rocks/ColumnDb.cs
@github-actions

Copy link
Copy Markdown
Contributor

EXPB Benchmark Comparison

Run: View workflow run

superblocks

Scenario: nethermind-flat-superblocks-perf-db-tweaks-delay0s

Client Processing (SSE)

Metric PR Master (cached) Delta
AVG (ms) 914.94 901.54 +1.49%
MEDIAN (ms) 863.6 868.9 -0.61%
P90 (ms) 1078.2 1095.1 -1.54%
P95 (ms) 1249.2 1211.3 +3.13%
P99 (ms) 2909.9 2995.5 -2.86%
MIN (ms) 579.4 584.4 -0.86%
MAX (ms) 2909.9 2995.5 -2.86%
K6 TTFB
Metric PR Master (cached) Delta
AVG (ms) 1615.87 1713.77 -5.71%
MEDIAN (ms) 1120.35 1135.38 -1.32%
P90 (ms) 3291.23 3499.92 -5.96%
P95 (ms) 3726.51 3612.05 +3.17%
P99 (ms) 4268.97 5021.37 -14.98%
MIN (ms) 684.10 678.81 +0.78%
MAX (ms) 5038.78 8756.71 -42.46%

realblocks

Scenario: nethermind-flat-realblocks-perf-db-tweaks-delay0s

Client Processing (SSE)

Metric PR Master (cached) Delta
AVG (ms) 22.05 21.11 +4.45%
MEDIAN (ms) 19.2 18.4 +4.35%
P90 (ms) 36.4 35.3 +3.12%
P95 (ms) 43.9 41.7 +5.28%
P99 (ms) 75.7 66.2 +14.35%
MIN (ms) 0.3 0.3 +0.00%
MAX (ms) 200.4 188.2 +6.48%
K6 TTFB
Metric PR Master (cached) Delta
AVG (ms) 26.98 25.02 +7.83%
MEDIAN (ms) 22.38 21.55 +3.85%
P90 (ms) 40.68 39.46 +3.09%
P95 (ms) 47.99 45.74 +4.92%
P99 (ms) 82.67 71.35 +15.87%
MIN (ms) 0.75 0.88 -14.77%
MAX (ms) 812.02 477.17 +70.17%

fusaka

Scenario: nethermind-flat-fusaka-perf-db-tweaks-delay0s

Client Processing (SSE)

Metric PR Master (cached) Delta
AVG (ms) 30.13 30.85 -2.33%
MEDIAN (ms) 27.5 28.6 -3.85%
P90 (ms) 46.5 48.3 -3.73%
P95 (ms) 57.1 57.1 +0.00%
P99 (ms) 78.8 78.7 +0.13%
MIN (ms) 4.9 5.3 -7.55%
MAX (ms) 331.7 350.4 -5.34%
K6 TTFB
Metric PR Master (cached) Delta
AVG (ms) 36.84 38.30 -3.81%
MEDIAN (ms) 33.21 34.02 -2.38%
P90 (ms) 56.36 56.64 -0.49%
P95 (ms) 64.97 64.10 +1.36%
P99 (ms) 96.64 99.47 -2.85%
MIN (ms) 6.14 7.10 -13.52%
MAX (ms) 526.18 844.06 -37.66%

@alexb5dh
alexb5dh marked this pull request as ready for review August 18, 2026 12:04
@claude

claude Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Claude finished @alexb5dh's task in 5m 59s —— View job


Re-review: perf: ColumnsDB minor fixes

Re-reviewed at ae8ca86, including the two commits added since my last pass (2bff431 tests, fa3910a read-options disposal, ae8ca86 doc wording). No Critical, High, or unaddressed Medium findings. From my side this is good to merge.

Previous findings — status

# Prior severity Status
1 Medium Acknowledged. The comment above ThrowIfDisposing (DbOnTheRocks.cs:1161) now states plainly that the check is not atomic and that a concurrent dispose/read can still reach a disposed native DB. That is the right call — the hot Get path deliberately has no check at all, so adding synchronisation here would be the wrong trade. Documented rather than pretended-fixed is what I was asking for.
2 Medium Resolved. DisposableLazyTests.cs covers exactly the invariants that matter, including the one that guards the leak (Dispose_does_not_create_value) and a real race test. Detail below.
3 Low Not addressed (PR description unchanged). Only affects an explicit ReadAheadSize=0; default config is unaffected. Non-blocking.
4 Low Not addressed (<summary> still describes the return value). Non-blocking.
5 Low AcknowledgedDisposableLazy<T> can't be internal while RocksDbReader's ctors are public. Fair.
6 Low Acknowledged — full type name plus stack is enough to locate the owner. <remarks> extended.
7 Low Fixed.

The new commit (fa3910a, DB read-options disposal) checks out

This is the one part I hadn't seen before, so I traced ownership of every ReadOptions in the assembly:

  • _defaultReadOptions / _hintCacheMissOptions / _readAheadReadOptions are created by CreateReadOptions() as fresh instances and never handed out — only used at DbOnTheRocks.cs:1070 and :1318 internally. DbOnTheRocks._reader gets its own pair via the factory overload (_ownsReadOptions = true) and is disposed separately at Dispose():1619. ColumnDbSnapshot owns its shared pair and destroys it itself. So no double-free path exists, and Dispose() is idempotent via the Interlocked.CompareExchange on _isDisposing.
  • The added ? on DestroyReadOptions is load-bearing: _readAheadReadOptions is genuinely null when ReadAheadSize == 0, and it also makes the call safe if Init throws before the fields are assigned.
  • Ordering is correct, and this matters because iterators pin the read options: ColumnsDb.ReleaseUnmanagedResources disposes columns (→ each column's IteratorManager, → its iterators) → base_iteratorManager?.Dispose()_db.Dispose() → only then the three ReadOptions handles. No iterator outlives the options it was created from.
  • Widening the shutdown use-after-free surface: DbOnTheRocks.Get (:781) has no ThrowIfDisposing, so a read racing disposal now passes two freed handles (_db.Handle and readOptions.Handle) into rocksdb_get_pinned instead of one. Same window, same outcome (AV either way) — not a new class of hazard, and covered by the ThrowIfDisposing comment. Noting it only so it's on the record.

I also re-confirmed, against the call sites added since my last pass (BlockDiffsStore, ProcessedTransactionsDbCleaner), that nothing disposes a ColumnDb obtained from GetColumnDbReadOnlyColumnsDb.Dispose only disposes its ReadOnlyDb wrappers, whose Dispose is _memDb.Dispose(), and nothing registers a column as an owned DI instance. So the new DestroyReadOptions in ColumnDb.Dispose still cannot fire while the DB is live.

On the tests

Good tests — dedicated threads released by a Barrier rather than pool work items, and ExceptionDispatchInfo to preserve the failing stack. Two things I checked specifically:

  • Value_race_with_Dispose_never_leaks's invariant (DisposeCount == factoryCalls) is not flaky: both reachable interleavings satisfy it exactly (create-then-dispose → 1/1; dispose-then-ODE → 0/0), so it can't intermittently fail on a loaded runner.
  • One interleaving the suite doesn't cover, and which contradicts Value_after_Dispose_returns_value_when_already_created: reader B can observe IsValueCreated == false, block on _lock behind reader A's factory call, and then find _disposed == true — so Value throws ObjectDisposedException even though a live value exists. That's the safe direction (throw rather than hand out a value about to be disposed), so no change needed; just noting the contract is "throws if creation hadn't started before disposal", not "throws if not created".

Block-scoped namespace matches the majority style in Nethermind.Db.Test (8 of 14 files), so that's consistent, not a deviation.

Remaining nits (non-blocking, take or leave)

  • The reworded <remarks> on DisposableLazy<T> says "Reading an already or newly created value skips the lock" — a newly created value does not skip the lock; the creating thread holds it. The intent ("a value whose creation started before disposal is still handed out after it") is right, just phrased as if the fast path covers both. Also which creation startedwhose creation started, and reject using object after disposalreject use after disposal.
  • RocksDbReader.Get evaluates _iteratorManager.Value before calling GetWithIterator, so ThrowIfDisposing runs after the lazy has already been forced. During shutdown that can construct a fresh IteratorManager (with its Timer) purely to throw a line later. Harmless — DisposableLazy still disposes it — but the check would do slightly more good one frame up.

Benchmarks

superblocks and fusaka are flat-to-better; realblocks shows +4.45% AVG / +14.35% P99, which is above the ~1.8% run-to-run CV that AGENTS.md records for flat-realblocks. Mechanically I can't construct a path to a real 4% regression from this diff: on the read path the only change is _iteratorManager.Value (two null checks against a Lazy<T> whose Value/IsValueCreated are both _state is null tests, trivially inlined) on the HintReadAhead branch only, and the default ReadAheadSize is non-zero so no behaviour changes. Comparison is also against a cached master from a different run. Worth one re-run to confirm it's noise, not worth blocking on.

Not verified

Build and test execution were blocked again — dotnet build src/Nethermind/Nethermind.Db.Test/Nethermind.Db.Test.csproj -c release requires approval in this sandbox, so DisposableLazyTests was reviewed by reading, not by running. Static analysis says it should compile: I grepped every reference to IteratorManager, _readAheadReadOptions, and DisposableLazy across src/Nethermind and the only ones are the three construction sites in this diff, so the visibility narrowing of _readAheadReadOptions to private breaks nothing. CI should confirm.

Review checklist
  • Read updated diff (63eb084..HEAD)
  • Verify responses to prior findings 1–7
  • Audit new fa3910a read-options disposal for double-free / UAF / ordering
  • Trace every ReadOptions owner in the assembly (DbOnTheRocks, RocksDbReader, ColumnDbSnapshot, GetViewBetween, ReadFullEnumerationBatch)
  • Re-verify no GetColumnDb consumer or DI registration disposes a ColumnDb (incl. new BlockDiffsStore, ProcessedTransactionsDbCleaner)
  • Review DisposableLazyTests for coverage and flakiness
  • Re-check IteratorManager lock (deadlock, missed tick, Timer.Dispose inside lock)
  • Check namespace/style conventions for the new test file
  • Build / run tests — blocked (command not permitted in this sandbox)
  • Post review
· branch [`perf/db-tweaks`](https://github.com/NethermindEth/nethermind/tree/perf/db-tweaks)

@alexb5dh
alexb5dh merged commit 1042d84 into master Aug 19, 2026
631 of 633 checks passed
@alexb5dh
alexb5dh deleted the perf/db-tweaks branch August 19, 2026 14:08
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