Skip to content

perf(blockchain): serve chain height and L1 head from memory - #3921

Open
brbrr wants to merge 4 commits into
mainfrom
perf/blockchain-head-cache
Open

perf(blockchain): serve chain height and L1 head from memory#3921
brbrr wants to merge 4 commits into
mainfrom
perf/blockchain-head-cache

Conversation

@brbrr

@brbrr brbrr commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

No description provided.

@brbrr brbrr self-assigned this Aug 10, 2026
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Claude finished @brbrr's task in 6m 19s —— View job


Review: serve chain height and L1 head from memory

  • Read PR diff
  • Trace every writer of the chain-height and L1-head keys
  • Check the concurrency assumptions (sync loop, reorgs, p2p, sequencer, feeds)
  • Check --remote-db wiring in node.go
  • Review tests
  • Post findings

No blockers — the caching direction is sound and the invalidation ordering is right where it matters. Four inline comments; the first is the one I'd want addressed before merge.

Summary

Important

  • blockchain.go:447-452 — the single-writer invariant is documented but not enforced. Because cacheChainHeight() re-reads the DB after the commit, an overlapping Store and RevertHead can leave the cache permanently ahead of the DB, making Head() return ErrKeyNotFound until the next write. I verified the invariant holds today (store/revert are both serialized conc/stream callbacks, p2p/sync is read-only, statebackend is only reachable through Blockchain, sequencer mode disables the synchronizer), so this is latent rather than live — but a mutex around {backend write, cache refresh} is free here and makes it a property instead of a comment.

Nits

  • blockchain.go:205-209New does both startup reads even when caching is off, so a --remote-db follower pays two throwaway gRPC round trips; the cacheHeads check also lands after GetL1Head has already gone to the wire.
  • blockchain.go:404-410 — the write-side deep copy is well justified (l1/l1.go:456 really does hand over aliased felts), but readers now share the cached felt pointers; the new "callers must not mutate" contract deserves a doc comment.
  • blockchain_test.go:1065-1087 — the deep copy, the cache advancing across multiple blocks, and Finalise/StoreGenesis refreshing the cache are all untested; each would pass today if the behaviour were removed.

What I checked and found clean

  • Invalidation ordering. RevertHead nils the cache before the revert, so readers fall back to the DB for the whole window — correct, and the asymmetry with Store (which can only be stale-low, pointing at a block that still exists) is safe.
  • No bypassing writers. WriteChainHeight/DeleteChainHeight are confined to statebackend/block_ops.go, reachable only via Store/Finalise/RevertHead; WriteL1Head only via SetL1Head. No migration or the pruner touches either key, and cmd/juno db revert reconstructs the Blockchain each iteration.
  • Empty chain / genesis. A missing height leaves the cache nil (unknown, not zero), so Height() keeps returning ErrKeyNotFound — the reverted-to-empty case is covered by the new TestRevert subtests.
  • --remote-db. dbIsRemote is the only in-process case where another writer owns the DB (pebble's exclusive lock rules out the rest), so gating on it is the correct scope.
  • Write-path safety. verifyBlockSuccession still reads the height from the DB, so block-succession checks can't be fooled by a stale cache.
  • SetL1Head(nil) is not reachable — l1/l1.go:460 is the sole caller and always passes a non-nil head — so the new update.BlockNumber dereference is safe.

go build ./... and go vet ./blockchain/... pass. I could not run go test ./blockchain/ -race — the command needed approval in this environment — so the new tests are unverified by me; please confirm CI is green.
• branch perf/blockchain-head-cache

Comment thread blockchain/blockchain.go Outdated
Comment thread blockchain/blockchain.go Outdated
Comment thread blockchain/blockchain.go
Comment thread blockchain/blockchain_test.go
@brbrr
brbrr force-pushed the perf/blockchain-head-cache branch from 6a4d4f7 to 03c7cbb Compare August 10, 2026 10:22
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

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


Review: serve chain height and L1 head from memory

  • Read the updated PR diff
  • Re-check the concurrency / invalidation ordering
  • Re-check --remote-db wiring and the L1-head aliasing contract
  • Review tests
  • Build / vet
  • Post findings

Note on this run: the pushed tree is byte-identical to the revision I reviewed earlier (same blob SHAs for all three files, still one commit 03c7cbb), so the four earlier inline comments still stand unaddressed. I re-derived them independently rather than restating them, and this pass turned up three things the first one missed — posted as new inline comments.

Summary

No blockers. The direction is sound and the invalidation ordering is right where it matters.

Important

  • blockchain.go:109-113 (new) — the field doc claims "only this process advances them, from the sync loop", but that's not the invariant the cache actually needs. blockchain.New caches both heads in node.New (node/node.go:261), while migrateIfNeeded runs later in node.Run (node/node.go:772) and hands the raw db.KeyValueStore to four migrators (node/migration.go:46-76). So a second in-process writer of the database already exists — it just happens not to write these two keys. The real constraint is "db.ChainHeight/db.L1Height are only written through Blockchain", which is grep-checkable; the stated one isn't. A "revert the head to block N" migration — the obvious next one — would leave the startup cache permanently stale-high and Head() returning ErrKeyNotFound for the process lifetime. Fix is a reworded comment, optionally plus a cache refresh after migrateIfNeeded.
  • blockchain.go:447-452 (from the earlier pass, still open) — the single-writer invariant is documented but not enforced; because cacheChainHeight() re-reads the DB after the commit, an overlapping Store/RevertHead can leave the cache ahead of the DB durably. Latent, not live — I re-confirmed storeTask and revertTask are serialized (revertTask is called from inside storeTask on the same goroutine, sync/sync.go:399), p2p/sync never writes (only Height/SanityCheckNewHeight/state reads), and sequencer mode has no synchronizer. A mutex around {backend write, refresh} is free here.

Nits

  • blockchain.go:231-238 (new) — the !b.cacheHeads guard is checked after GetChainHeight, so remote-DB followers pay a wasted gRPC round trip at construction and on every write. Hoisting the guard fixes both this and half of the earlier New nit in one place; suggestion inline, with the "why re-read instead of block.Number" rationale folded into a doc comment.
  • blockchain.go:506 (new) — one getEvents call now reads "latest" from two sources: this line uses the cache, event_filter.go:161 still reads the DB. Benign today (the node stays self-consistent, and RPC "latest" is racy anyway), but worth noting that this PR doesn't remove any head read from the getEvents hot path — Events() still hits the DB every call.
  • blockchain.go:205-209, blockchain.go:404-410, blockchain_test.go:1065-1087 — from the earlier pass, still open: startup double round trip, the undocumented "readers must not mutate the cached felts" contract on L1Head(), and three untested behaviours (the SetL1Head deep copy, the cache advancing across blocks, Finalise/StoreGenesis refreshing it).

Verified clean

  • Writer inventory. core.WriteChainHeight/DeleteChainHeight are called only from blockchain/statebackend/block_ops.go:157,199,202, reachable only via Store/Finalise/RevertHead; core.WriteL1Head only from SetL1Head, whose sole caller is l1/l1.go:460. No migration, pruner, or p2p/sync path writes either key. cmd/juno db revert rebuilds the Blockchain each iteration and goes through RevertHead.
  • Atomicity. Both backends commit in a single batch — stateBackend.Store/Finalise via database.Write (statebackend.go:80,214), the deprecated one via database.Update (deprecated.go:73,104). So Store/Finalise returning an error genuinely means nothing committed, and skipping the refresh on those paths is safe.
  • Invalidation direction. RevertHead nils the cache before reverting, so readers fall back to the DB for the whole window. Store can only leave the cache stale-low, pointing at a block that still exists. The asymmetry is deliberate and correct.
  • Empty chain. A missing height caches nil (unknown, not zero), so Height() keeps surfacing ErrKeyNotFound — covered by the new TestRevert subtests.
  • Deep copy justified. l1/l1.go:456-458 really does build the L1Head with &maxFinalisedHead.L2BlockHash / &maxFinalisedHead.StateRoot, aliasing a StateUpdate that the feed subscribers and OnNewL1Head also see. Caching the caller's pointers would have been a live bug.
  • SetL1Head(nil). Not reachable (l1/l1.go:460 always passes non-nil). Worth knowing that the new update.BlockNumber deref does change behaviour — core.WriteL1Head(db, nil) marshals fine, so the old code survived a nil and the new code panics — but with one caller this is theoretical.
  • verifyBlockSuccession still reads the height from the DB inside the batch, so block-succession checks can't be fooled by a stale cache.
  • --remote-db scoping. dbIsRemote is the only in-process case where another process owns the DB; pebble's exclusive lock rules the rest out.
  • No value copies of Blockchain now that it holds atomic.Pointer fields — always used as *blockchain.Blockchain, and go vet's copylocks check is clean.

Build / test

go vet ./blockchain/... ./sync/... ./l1/... ./pruner/... ./migration/... — clean. go build ./... compiles every package; only cmd/juno fails to link, because the prebuilt Rust static libs (libjuno_starknet_rs, libjuno_starknet_compiler_rs) and libbz2 aren't present in this environment — unrelated to this PR.

I could not run go test ./blockchain/ -race — the command needed approval here — so the new tests are unverified by me. Please confirm CI is green.
• branch perf/blockchain-head-cache

Comment thread blockchain/blockchain.go Outdated
Comment thread blockchain/blockchain.go
Comment thread blockchain/blockchain.go Outdated
@codecov

codecov Bot commented Aug 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.50000% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.19%. Comparing base (4e32927) to head (263a9ea).
⚠️ Report is 11 commits behind head on main.

Files with missing lines Patch % Lines
blockchain/blockchain.go 91.30% 2 Missing and 2 partials ⚠️
node/node.go 0.00% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3921      +/-   ##
==========================================
+ Coverage   74.99%   75.19%   +0.19%     
==========================================
  Files         446      447       +1     
  Lines       40312    40290      -22     
==========================================
+ Hits        30233    30297      +64     
+ Misses       7967     7898      -69     
+ Partials     2112     2095      -17     
Flag Coverage Δ
jsonv2 77.39% <ø> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

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

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

@brbrr
brbrr force-pushed the perf/blockchain-head-cache branch from 03c7cbb to 1c2c1a8 Compare August 10, 2026 11:08
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Claude finished @brbrr's task in 11m 48s —— View job


Review: serve chain height and L1 head from memory

  • Read the pushed diff and diff it against the previously-reviewed revision
  • Re-trace writers of db.ChainHeight / db.L1Height
  • Re-check concurrency & invalidation ordering
  • Check node.go wiring
  • Review tests
  • Build / vet / test
  • Post findings

What changed since the last pass. Contrary to the previous run's note, this tree is not identical — four of the six earlier points were taken up: the New guard was hoisted into the struct literal so remote-DB followers no longer pay the startup round trips, Store/Finalise moved to defer b.cacheChainHeight() so the three write paths are uniform, L1Head() gained the "callers must not mutate" doc comment, and all three missing tests were written (TestCachedL1HeadIsIsolatedFromTheCaller, TestCachedHeightAdvancesWithEachStoredBlock, TestStoreGenesisCachesTheHeight) plus a remote-DB test. The field doc no longer claims "from the sync loop". Nice turnaround.

No blockers. Three new comments below, plus notes on what's still open and two earlier nits I'd now drop.

Summary

Important

  • blockchain_test.go:1188-1220 (new) — the startup priming in New (lines 204-209) is the one new code path with zero coverage. Every cache test builds over an empty database and lets the write path populate the cache, and the one test with a pre-populated database passes WithRemoteDatabase(), which skips priming by design. Delete both constructor lines and the whole suite stays green — yet that's the path carrying the win for a restarted node with a populated database, and the only place a read error is silently swallowed. Codecov's 4 partials on blockchain.go are these branches. Drop-in test in the comment.
  • blockchain.go:447-452 (earlier pass, still open) — the single-writer invariant is still comment-only. Because cacheChainHeight() re-reads the database after the commit, an interleaved Store/RevertHead leaves the cache durably ahead of the database and Head() returning ErrKeyNotFound until the next write. I re-verified independently that it's latent, not live, and tightened the proof: both revertTask call sites are conc/stream callbacks on the same verifiers stream (sync/sync.go:191 and sync/sync.go:364, the latter from inside storeTask on that goroutine), so store and revert are serialized; sync/sync.go:359 is the only Store caller in the tree and l1/l1.go:460 the only SetL1Head caller. A mutex around {backend write, refresh} is free and turns the comment into a property.

Nits

  • blockchain.go:225-230 (new)HeadState() was left out, and it's the biggest "latest" reader: the backend resolves the head itself (statebackend/statebackend.go:17, deprecated.go:22), and it's what every block_id: "latest" state query lands on via stateByBlockIDgetNonce, getStorageAt, getClass*, estimateFee, simulateTransactions, traceCall. With Events (event_filter.go:161) and SetRangeEndBlockToL1Head (event_filter.go:106) still on the database, the highest-volume RPC paths keep one head read per request. Fine as scope; please just say so in the description, since the title reads as "no more head reads".
  • blockchain_test.go:1293-1302 (new) — the two new TestRevert subtests pin the invalidation but not the refresh: both reverts land on an empty chain, where a nil cache and a refreshed one are indistinguishable. Drop defer b.cacheChainHeight() from RevertHead and everything still passes — the cache is just nil forever after the first revert, silently undoing the PR for a reorging node.
  • blockchain.go:109-113 (earlier, mostly addressed) — the reworded doc is much closer. The one case it still doesn't name is the in-process bypass: migrateIfNeeded runs in node.Run after blockchain.New primed the cache in node.New, and hands the raw db.KeyValueStore to four migrators (node/migration.go:46-76). None writes these two keys today (I re-checked: WriteChainHeight/DeleteChainHeight only at statebackend/block_ops.go:157,199,202; all five migrators only read the height), so it's latent — but "written elsewhere" reads as "another process", and the migrator case is in-process.
  • SetL1Headupdate.BlockNumber on line 428 is a nil deref, but only when cacheHeads is true; the remote path returns at line 422 first. Unreachable today (l1/l1.go:460 always passes non-nil, and core.WriteL1Head(db, nil) marshals fine, so the old code survived a nil). Worth knowing it's now a mode-dependent panic, which is a nasty shape if someone later adds a caller that clears the head on an L1 reorg.

Two earlier nits I'd now drop — I chased both further and they're smaller than they looked:

  • blockchain.go:239 (guard hoisted above the read in cacheChainHeight) is effectively moot. node.go:427 passes dbIsRemote as the synchronizer's readOnlyBlockchain, which routes to pollLatest and never stores — so a remote-DB node never reaches Store/Finalise/RevertHead at all, and New no longer calls cacheChainHeight when caching is off. There is no wasted round trip left to save. Still marginally cleaner as an early return, but not worth a push.
  • blockchain.go:506 (event filter reading "latest" from two sources) is weaker than stated: for to_block: "latest" the RPC layer overwrites e.toBlock from Height() anyway (rpc/v10/events.go:102 via setEventFilterRange), so the constructor's value is little more than a default, and the e.toBlock-vs-latest split in Events predates this PR.

Verified clean

  • Writer inventory. core.WriteChainHeight/DeleteChainHeight only at statebackend/block_ops.go:157,199,202, reachable only through Store/Finalise/RevertHead; core.WriteL1Head only from SetL1Head. stateBackend.Store/RevertHead/Finalise are called from exactly one place each, all inside blockchain.go (448, 531, 558). StoreGenesis routes through b.Finalise, so it inherits the refresh — which is why TestStoreGenesisCachesTheHeight genuinely holds.
  • Single Blockchain per database. Only three blockchain.New call sites outside tests: node.go:261, and cmd/juno/dbcmd.go:93/189. The revert loop rebuilds the Blockchain each iteration and goes through RevertHead, so it's safe twice over. No second instance shares a database in-process.
  • Invalidation direction. RevertHead nils before reverting, so readers fall back for the whole window; Store can only be stale-low, pointing at a block that still exists. Asymmetry is deliberate and correct. Both backends commit in one batch (statebackend.go, deprecated.go via database.Write/Update), so an error genuinely means nothing committed.
  • Empty chain. A missing height caches nil (unknown, not zero), so Height() keeps surfacing ErrKeyNotFound.
  • Consumers of the shared felts. All current L1Head() callers only read (rpc/v{8,9,10}/helpers.go, node/metrics.go, cmd/juno/dbcmd.go), so the new sharing contract isn't violated. The write-side clone is defensive rather than a bug fix, incidentally — l1/l1.go:445 deletes maxFinalisedHead from nonFinalisedLogs before building the head, so the l1 client no longer mutates it — but Clone() is the right call regardless.
  • verifyBlockSuccession still reads the height from the database (block_ops.go:18), so block succession can't be fooled by a stale cache.
  • No value copies of Blockchain now that it holds atomic.Pointer fields — only *blockchain.Blockchain in the tree, and vet's copylocks is clean.
  • --remote-db scoping is right: pebble's exclusive lock rules out every other same-database case, and remote mode is also read-only at the synchronizer.

Build / test

go vet ./blockchain/... — clean (it type-checks the test files too, so the new tests compile).

I could not run go test ./blockchain/ -race — the command needed approval in this environment, so the new tests are unverified by me. Please confirm CI is green.
• branch perf/blockchain-head-cache

Comment thread blockchain/blockchain_test.go
Comment thread blockchain/blockchain_test.go
Comment thread blockchain/blockchain.go
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Claude finished @brbrr's task in 5m 32s —— View job


Review: serve chain height and L1 head from memory

  • Read the pushed diff and diff it against the previously-reviewed revision
  • Re-trace writers of db.ChainHeight / db.L1Height
  • Re-check concurrency & invalidation ordering
  • Review tests (including whether the new ones are non-vacuous)
  • Build / vet / lint
  • Post findings

What 4daadd1 changed. Five of the six open points from the last pass were taken up, and all of them correctly:

Previous finding Status
Startup priming in New had zero coverage Fixed — TestHeadsAreCachedWhenTheDatabaseIsAlreadyPopulated
Both revert subtests landed on an empty chain, so the refresh was untested Fixed — new subtest at blockchain_test.go:1283, TestRevert now wraps headReadCounter
Field doc claimed an invariant narrower than the real one Fixed — now names the in-process bypass (node.Run migrations holding the raw db.KeyValueStore)
!b.cacheHeads guard checked after the read in cacheChainHeight Fixed — hoisted to an early return, plus the "why re-read instead of block.Number" rationale
EventFilter reading "latest" from the cache while Events reads the database Fixed — reverted to core.GetChainHeight with the reasoning in a comment

I checked the two new tests are non-vacuous: drop the constructor priming (lines 210-214) and the first goes red; drop defer b.cacheChainHeight() from RevertHead and the second goes red. Same for the three tests added in the previous round. That was the main gap and it's genuinely closed now.

Summary

No blockers. Two comments, one carried over.

Important

  • blockchain.go:459 (third pass, still open) — {backend write, cache refresh} is still not atomic. Because cacheChainHeight() re-reads the height after the commit, an interleaved Store/RevertHead leaves the cache durably ahead of the database and Head() returning ErrKeyNotFound until the next write. Re-verified latent, not live, and the proof is tight: both revertTask call sites are conc/stream callbacks on the same verifiers stream (sync/sync.go:191, and :364 from inside storeTask on that goroutine), sync/sync.go:359 is the only Store caller in the tree, p2p/sync never writes, sequencer mode has no synchronizer. The new field doc covers the bypass case but not the concurrency case, which is what this ordering is actually sensitive to. A mutex is free here — these calls are already serialized. SetL1Head has the identical unsynchronized shape.

Nits

  • blockchain.go:433-441 (new)update.BlockNumber is a nil deref reachable only when cacheHeads is true; the remote path returns at line 435 first. So the same input crashes a normal node and works on a --remote-db follower, and no test using WithRemoteDatabase() would catch it. Unreachable today, but it is a behaviour change — core.WriteL1Head(db, nil) marshals fine, so the pre-PR code tolerated a nil update. Two-line guard inline.
  • Scope, for the description (from the last pass, unchanged code — not re-posted inline): HeadState() still resolves the head itself (statebackend/statebackend.go:17, deprecated.go:22), which is where every block_id: "latest" state query lands via stateByBlockIDgetNonce, getStorageAt, getClass*, estimateFee, simulateTransactions, traceCall. EventFilter.Events (event_filter.go:161) and SetRangeEndBlockToL1Head (event_filter.go:106) also still read the database, the first now deliberately. That's a fine scope — Head/HeadsHeader/Height are worth caching on their own, and the constructor priming helps a restarted node immediately — but the title reads as "no more head reads", so please say which reads were measured and which remain.

Verified clean

  • Writer inventory. Re-grepped the whole tree: core.WriteChainHeight/DeleteChainHeight only at statebackend/block_ops.go:157,199,202, reachable only through Store/Finalise/RevertHead; core.WriteL1Head only at blockchain.go:430. No migration, pruner, or p2p/sync path touches either key, so the documented invariant is currently true and grep-checkable.
  • Invalidation direction. RevertHead nils before the commit and refreshes after, so readers fall back for the whole window; Store can only be stale-low, naming a block that still exists. The asymmetry is deliberate and correct. Both backends commit in one batch, so an error means nothing committed.
  • Stale-low reads don't break the sync loop. isReverting (sync/sync.go:216) reads Height(), but a stale-low value fails the localHeight+1 != nextHeight guard and returns "not reverting" — conservative, and fetcherTask's loop retries. revertTask reads HeadsHeader() on the same goroutine that refreshes the cache, so its loop always sees the post-revert height. nextHeight() runs before the workers start.
  • No aliasing on the cached pointers. cacheChainHeight and the New priming each take the address of a fresh local, so no two cache entries share storage, and the primed core.L1Head comes from a fresh decode with no other holder.
  • EventFilter reverting to the database is right, not a regression. Events has a documented read-ordering requirement (pre-confirmed before latest, event_filter.go:150-160) and re-reads latest every call by design; the constructor's value is overwritten from Height() anyway for to_block: "latest" (rpc/v10/events.go:102).
  • Empty chain. A missing height caches nil (unknown, not zero), so Height() keeps surfacing ErrKeyNotFound — covered by the tail of TestRevert.
  • L1Head() sharing contract is now documented, and every current caller only reads (rpc/v{8,9,10}/helpers.go, node/metrics.go, cmd/juno/dbcmd.go). The write-side Clone() is right regardless.
  • verifyBlockSuccession still reads the height from the database (block_ops.go:18), so succession checks can't be fooled by a stale cache.
  • No value copies of Blockchain now that it holds atomic.Pointer fields — grep finds only *blockchain.Blockchain, and vet's copylocks is clean.
  • --remote-db wiring (node/node.go:258-260) is correct and correctly scoped: pebble's exclusive lock rules out every other same-database case, and node.go:427 also makes a remote node's synchronizer read-only, so it never reaches Store/Finalise/RevertHead at all.

Build / test

go vet ./blockchain/... — clean (it type-checks the test files, so the new tests compile). golangci-lint, CodeQL, and Code Quality are green on this commit; Juno Test was still in progress when I finished.

I could not run go test ./blockchain/ -race — the command needed approval in this environment — so the new tests are unverified by me. Please confirm Juno Test goes green.
• branch perf/blockchain-head-cache

Comment thread blockchain/blockchain.go
Comment thread blockchain/blockchain.go
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Claude finished @brbrr's task in 4m 40s —— View job


Review: serve chain height and L1 head from memory

  • Diff c477980 against the previously-reviewed 4daadd1
  • Re-check the concurrency documentation and invalidation ordering
  • Re-check the SetL1Head(nil) guard and its test
  • Re-trace every writer of db.ChainHeight / db.L1Height
  • Confirm the new tests are non-vacuous
  • Build / vet / CI
  • Post findings

This revision is clean — both remaining items from the previous pass are resolved, and I found nothing new. No inline comments this round.

What c477980 changed

Previous finding Resolution
update.BlockNumber was a cacheHeads-only nil deref in SetL1Head Fixed — blockchain.go:433-436 stores nil (unknown) for a nil update, so both modes agree, plus TestClearingTheL1HeadEmptiesTheCache
Field doc named the bypass case but not the concurrency case Fixed — blockchain.go:109-113 now names both: "written through Blockchain by one writer at a time: a migration on the raw db.KeyValueStore, or a concurrent store and revert, leave it stale"

On the headsMu mutex I'd raised three times: documenting the constraint instead of enforcing it is a legitimate call here, and the reworded comment now says the thing a future reader actually needs. I re-verified the invariant independently once more and it holds tightly — sync/sync.go:359 is the only Store caller in the tree, both revertTask call sites are conc/stream callbacks on the same verifiers stream (sync/sync.go:191, and :364 from inside storeTask on that goroutine), p2p/sync holds a *blockchain.Blockchain but only ever reads it (Height, GlobalStateRootByBlockNumber, StateAtBlockNumber, SanityCheckNewHeight — grep finds no Store( anywhere under p2p/), and sequencer mode takes the if cfg.Sequencer branch at node/node.go:326 so synchronizer is never constructed. If a second writer ever does appear, the comment is now the thing that flags it.

Verified clean

  • Writer inventory (re-grepped). core.WriteChainHeight/DeleteChainHeight exist only at blockchain/statebackend/block_ops.go:157,199,202, reachable only via Store/Finalise/RevertHead; core.WriteL1Head only at blockchain/blockchain.go:425. No migration, pruner, mempool, or p2p path touches either key, so the documented invariant is grep-checkable.
  • The nil-update path is genuinely correct, not just non-panicking. WriteL1Head(db, nil) marshals a nil pointer, GetL1Head unmarshals it back to a zero L1Head with no error (core/accessors.go:206-220) — so caching nil ("unknown") and letting readers fall through to the database gives exactly the same observable result as the pre-PR code. The test asserts that.
  • No feed or callback inside the write window. stateBackend.Store (statebackend.go:79-108) does everything in one database.Write batch and notifies nobody, so the gap between the commit and the deferred cacheChainHeight() is not observable by any in-process subscriber — a stale-low cache in that window still names a block that exists.
  • Invalidation ordering. RevertHead nils before the commit and refreshes after, so readers fall back for the whole window; Store/Finalise can only be stale-low. The asymmetry is deliberate and correctly commented.
  • EventFilter deliberately back on the database (blockchain.go:519-521) with the reason written down — Events re-reads latest every call by design, so the constructor must not disagree with it.
  • Tests are non-vacuous. Spot-checked each: drop the update == nil guard and TestClearingTheL1HeadEmptiesTheCache panics; drop defer b.cacheChainHeight() from RevertHead and the new TestRevert subtest at blockchain_test.go:1294 counts a read and fails; drop the constructor priming and TestHeadsAreCachedWhenTheDatabaseIsAlreadyPopulated fails. headReadCounter only intercepts Get, which is the right shape for both accessors today.
  • --remote-db wiring (node/node.go:258-260) is correct and correctly scoped; node.go:427 also makes a remote node's synchronizer read-only, so it never reaches the write paths at all.
  • No value copies of Blockchain now that it holds atomic.Pointer fields — vet's copylocks is clean.

One thing for the PR description (not a code issue)

HeadState() still resolves the head itself (statebackend/statebackend.go:17, deprecated.go:22), which is where every block_id: "latest" state query lands via stateByBlockIDgetNonce, getStorageAt, getClass*, estimateFee, simulateTransactions, traceCall. EventFilter.Events (event_filter.go:161) and SetRangeEndBlockToL1Head (event_filter.go:106) also still read the database, the first now deliberately. That's a reasonable scope — Head/HeadsHeader/Height are worth caching on their own, and the constructor priming helps a restarted node immediately — but the title reads as "no more head reads", so a line saying which reads were measured and which remain would save the next person a benchmark.

Build / test

go vet ./blockchain/... — clean (it type-checks the test files, so the new tests compile). On this commit golangci-lint, CodeQL, Code Quality, Dependency Review and Test Documentation Build are green; Juno Test, Deploy and Test on Dev and Container Security were still in progress when I finished.

I could not run go test ./blockchain/ -race — the command needed approval in this environment — so I verified the new tests by reading them rather than executing them. Please confirm Juno Test goes green.
• branch perf/blockchain-head-cache

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

Here I worry about cache DB consistency, lets somehow make it more robust. Also I guess we can update the caches directly after writes without requiring to read the ChainHeight or L1Head back from DB in order to populate the cache

Comment thread blockchain/blockchain.go
@@ -3,6 +3,7 @@ package blockchain
import (

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.

Here I worry about the tight window where db is updated and cache is not, we might have to synchronize them, or invalidate cache before starting writing to DB, so we don't read stale cache value, at the moment nothing hints cache is stale. We will still have concurrent DB read/write case but it might still worth

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

A reader does a lock-free atomic load, so a reader is safe as it is. The pair {database commit, cache refresh} in a writer is not atomic, but the node has only one writer.

Store and RevertHead are stream callbacks on the same stream, and storeTask calls revertTask on that same goroutine. There is only one caller of Store in the tree.

Thus a mutex would protect an interleave that no call path can cause, and it would tell a future reader that this package expects concurrent writers. I prefer to keep the rule in the field comment, where a grep can check it. It would make sense adding a mutex when we'll get a second writer.

You also asked to set the cache directly instead of a read of the database. Store and Finalise can do this, because it writes exactly block.Number. RevertHead cannot: Blockchain does not know the new height unless it reads the database or repeats the rule "delete at genesis, else N-1" in a second place. RevertHead also cannot calculate the new height when the cache is already nil. Thus the choice is one read-back rule for all three methods, or a mixed scheme with the height rule in two places. I decided to keep the read-back.

Comment thread blockchain/blockchain.go Outdated
@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Claude finished @brbrr's task in 3m 47s —— View job


Review: serve chain height and L1 head from memory

  • Diff 263a9ea against the previously-reviewed c477980
  • Re-check the new L1-head commit ordering against @EgeCaner's feed-before-commit point
  • Re-trace writers of db.ChainHeight / db.L1Height
  • Re-check concurrency & invalidation ordering
  • Confirm the new test is non-vacuous
  • Build / vet / CI
  • Post findings

This revision is clean — I found nothing new. No inline comments this round.

What 263a9ea changed

SetL1Head no longer publishes before it commits:

if err := core.WriteL1Head(b.database, update); err != nil {
    return err
}
b.cacheL1Head(update)
b.l1HeadFeed.Send(update)

That's the right fix for @EgeCaner's "we should not publish L1 head before committing it", and it's a real fix rather than a cosmetic reorder: the pruner subscribes to this feed (node/node.go:370, node/node.go:503) and deletes historical state on the head it receives, so under the old order a failed WriteL1Head could prune against a head that was never durable — and on the next restart the node would read the older head back from the database, below the retention floor it had just pruned to. The three RPC subscription handlers (rpc/v{8,9,10}/handlers.go) were exposed to the same thing, just less destructively.

The extraction of cacheL1Head is a clean way to keep the early-return shape now that SetL1Head has a tail. The EventFilter comment rewording is equivalent in meaning.

The new test is non-vacuous. core.WriteL1Head writes via a bare w.Put (core/accessors.go:214-220), so failingL1HeadWriter.Put genuinely intercepts it — not a batch or an iterator that would slip past. And feed.Send is synchronous under the feed's mutex with a buffered-1 channel per subscriber (feed/feed.go:66-87), so the select/default assertion is deterministic on a single goroutine, not a sleep-free race. Drop either half of the fix and it goes red: restore the old ordering and sub.Recv() yields a head, drop the return on error and L1Head() serves the uncommitted value instead of ErrKeyNotFound. Good test.

On the two open points from @EgeCaner's review

Both are judgement calls the author has now answered; recording my read so you can close them either way.

"Update the caches directly after writes instead of reading back." The author's reasoning holds up. Store/Finalise could set block.Number directly, but RevertHead cannot — Blockchain has no way to know the post-revert height without either re-reading or restating statebackend's "delete the key at genesis, else N-1" rule (statebackend/block_ops.go:199-202) in a second place, where it would silently rot if the backend rule ever changed. It also can't compute it when the cache is already nil. So the choice is one read-back rule in one place versus a split scheme with the height rule duplicated; one read of a hot pebble key per block is not the cost this PR is fighting.

"The tight window where the DB is updated and the cache is not." Worth being precise about which direction the window can go, because the two write paths are deliberately asymmetric:

  • Store/Finalise — the cache can only lag low, naming block N-1 while the database holds N. That value was true a moment earlier and names a block that still exists, so a reader in the window sees a consistent, slightly-behind chain — exactly what it would have seen had it arrived before the commit. It can't go backwards either: the cache only ever advances at the deferred refresh. Pre-invalidating here would trade that for a database read on every reader during the window, which for a syncing node is most of the time — i.e. it would spend the win this PR exists to get, to fix a staleness that is already safe.
  • RevertHead — the cache is invalidated before the commit (blockchain.go:544-548), because there the stale value would outlive the block it names and point readers at a deleted one. That's the case your concern describes, and it's already handled.

What remains is the concurrency case — two writers overlapping, where cacheChainHeight's post-commit read can land out of order and leave the cache durably ahead of the database. I raised a headsMu for this across three passes; the author's position is that a mutex would advertise an expectation of concurrent writers that no call path can produce, and that the field comment is the grep-checkable rule instead. I re-verified the invariant once more and it holds tightly — sync/sync.go:359 is the only Store caller in the tree, both revertTask call sites are conc/stream callbacks on the same verifiers stream (sync/sync.go:191, and :364 from inside storeTask on that goroutine), p2p/sync holds a *blockchain.Blockchain but only reads it, sequencer mode never constructs a synchronizer, and l1/l1.go:460 is the only SetL1Head caller. Documenting rather than enforcing is a legitimate call at this size; if you'd rather have it enforced, the mutex is three lines and costs nothing on the read path, since readers stay on the lock-free atomic. Your call as maintainer — it isn't a correctness defect today either way.

Verified clean on this revision

  • Writer inventory (re-grepped). core.WriteChainHeight/DeleteChainHeight only at blockchain/statebackend/block_ops.go:157,199,202, reachable only through Store/Finalise/RevertHead; core.WriteL1Head only at blockchain/blockchain.go:424. No migration, pruner, mempool or p2p path touches either key.
  • The reorder doesn't strand a caller. l1.setL1Head (l1/l1.go:459-465) already wrapped and returned the SetL1Head error, and only calls c.listener.OnNewL1Head after it — so skipping the feed send on failure keeps the client's own notification and the feed consistent, where before they could disagree.
  • StoreGenesis still routes through b.Finalise, so it inherits the deferred refresh; TestStoreGenesisCachesTheHeight genuinely holds.
  • Deferred refresh on error paths. Both backends commit in a single batch, and defer b.cacheChainHeight() re-reads the database rather than trusting the caller, so a failed Store refreshes to whatever actually committed — nothing.
  • --remote-db (node/node.go:258-260) is unaffected by this commit: cacheL1Head returns at the guard before the nil check, and a remote node's synchronizer is read-only anyway (node/node.go:427).
  • L1Head() sharing contract is documented, and every current caller only reads (rpc/v{8,9,10}/helpers.go, node/metrics.go, cmd/juno/dbcmd.go).
  • No value copies of Blockchain now that it holds atomic.Pointer fields — vet's copylocks is clean.

One line still worth adding to the description

HeadState() resolves the head itself (statebackend/statebackend.go:17, deprecated.go:22), which is where every block_id: "latest" state query lands via stateByBlockIDgetNonce, getStorageAt, getClass*, estimateFee, simulateTransactions, traceCall. EventFilter.Events (event_filter.go:161) and SetRangeEndBlockToL1Head (event_filter.go:106) also still read the database, the first now deliberately. That's a fine scope, but the title reads as "no more head reads", so a sentence on which reads were measured and which remain would save the next person a benchmark.

Build / test

go vet ./blockchain/... — clean (it type-checks the test files, so the new tests compile). On this commit golangci-lint, CodeQL, Code Quality, Dependency Review and Test Documentation Build are green; Juno Test, Deploy and Test on Dev and Container Security were still in progress when I finished.

I could not run go test ./blockchain/ -race — the command needed approval in this environment — so I verified the new test by reading it and by tracing Put/Send rather than by executing it. Please confirm Juno Test goes green.
• branch perf/blockchain-head-cache

@brbrr
brbrr deployed to Development August 12, 2026 09:14 — with GitHub Actions Active
@brbrr
brbrr requested a review from EgeCaner August 14, 2026 09:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants