Skip to content

feat: Lock-free apply_block refactor - #2345

Open
sergerad wants to merge 77 commits into
nextfrom
sergerad-lockfree-store-state
Open

feat: Lock-free apply_block refactor#2345
sergerad wants to merge 77 commits into
nextfrom
sergerad-lockfree-store-state

Conversation

@sergerad

@sergerad sergerad commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

Summary

Closes #1539.
Closes #1853.
Closes #2414.

Makes the store's block-write path lock-free for readers, and makes the resulting read-consistency rules type-enforced. Reads previously contended on RwLocks over the in-memory trees (and blocked during apply_block's DB-commit window); they now load an immutable snapshot via ArcSwap and are never blocked by writes. All reads flow through a request-scoped StateView, so a query combining tree and DB data at different chain heights is no longer expressible.

                                   LoadedState::start()
                                           │ spawns worker task, hands out one of each
             ┌─────────────────────┬───────┴────────────┬─────────────────────┐
             ▼                     ▼                    ▼                     ▼
      ┌────────────┐        ┌─────────────┐      ┌─────────────┐      ╔══════════════╗
      │ Arc<State> │        │ BlockWriter │      │ ProofWriter │      ║  WriteWorker ║
      │ (read-only,│        │ (write cap, │      │ (write cap, │      ║ (tokio task) ║
      │  shared)   │        │  1 holder)  │      │  1 holder)  │      ╚══════════════╝
      └────────────┘        └─────────────┘      └─────────────┘        owns MUTABLE
             │                     │                    │               trees: nullifier,
             │       apply_block() │      apply_proof() │               account, MMR,
             │                     │                    │               forest
             │         WriteRequest│                    ├─ commit proof       │
             │             mpsc(1) │                    │  to block store     │ per committed
             │                     ▼                    │                     │ block: builds
             │                     ═══════════▶ ════════╪═══════════▶         │ + publishes
             │                                          │                     ▼
             │                                          │              ┌────────────────┐
      State fields                                      │              │ StateSnapshot  │
      ┌───────────────────────────────────────┐         │              │  (immutable,   │
      │ latest_snapshot: Arc<ArcSwap<─────────── swap on commit ──────▶│  per block N)  │
      │                        StateSnapshot>>│         │              │  tree READER   │
      │ committed_tip_tx: watch ◀── fired by worker     │              │  views + MMR   │
      │ proven_tip:       watch ◀── advanced by ────────┘              │ + SnapshotGuard│
      │ db, block_store, block/proof caches   │                        └────────────────┘
      └───────────────────────────────────────┘                                ▲
             │                                                                 │ pins ONE
             │ view()  (wait-free ArcSwap load, one per request)               │ generation
             ▼                                                                 │
      ┌─────────────────────────────────────────────┐                          │
      │ StateView  { snapshot: Arc<StateSnapshot> ──┼──────────────────────────┘
      │              db:       Arc<Db>            } │
      │  • tip() = snapshot height                  │   ALL reads live here:
      │  • DB queries scoped by tip                 │   get_account, get_*_inputs,
      │    (ScopedBlockNum / ScopedBlockRange)      │   sync_*, get_block_header, …
      │  • trees only via block_in_place helpers    │
      └─────────────────────────────────────────────┘

  Writes flow LEFT→RIGHT (capability → worker → snapshot); reads flow DOWN (State → view →
  pinned snapshot + tip-scoped DB). Live tips (committed/proven) bypass snapshots via watch
  channels. Readers never block writes; a view keeps serving block N while the worker
  publishes N+1.

Why:

  • Read endpoints (sync, account/nullifier proofs, chain tip) no longer stall while a block is applied.
  • Removes the fragile cross-task lock choreography in apply_block (oneshot handshakes between the DB task and the in-memory update).
  • Snapshot-scoped reads were previously enforced only by convention — several paths read the tip and the data from different snapshots. StateView makes the scoping structural instead.

How:

Lock-free write path (state/writer/)

  • A single WriteWorker task owns the mutable nullifier tree, account tree, blockchain MMR, and account-state forest, processing blocks serially from an mpsc channel — no locks. In-flight writes always complete; shutdown is only observed between requests.
  • After each DB commit, the worker builds an immutable StateSnapshot (trees backed by read-only RocksDB snapshot views) and publishes it atomically via ArcSwap, so readers keep a consistent frozen view while the next block commits.
  • Db::apply_block is now a plain transaction — the oneshot allow_acquire/acquire_done synchronization is removed.

Write capabilities (state/lifecycle.rs)

  • LoadedState::start spawns the worker and returns the read-only Arc<State> plus non-cloneable BlockWriter/ProofWriter capabilities and a WriterTask handle, statically limiting each write path to one task. The capabilities expose no read access; tasks that read and write get Arc<State> alongside their capability.
  • BlockWriter::stop drains and joins the worker so tree storage is released deterministically before the data directory is re-opened or deleted (used by recover and stress-test seeding).

Type-enforced reads (state/view/)

  • All tree and DB reads live on StateView, pinned to one snapshot per request (State::view()). DB queries are scoped by the view's tip internally; callers cannot supply their own. RocksDB-backed trees are only reachable through block_in_place helpers; snapshot fields are only visible inside the view module.
  • Tip-scoped Db queries require view-issued proof types (ScopedBlockNum / ScopedBlockRange), constructible only by a StateView after validating the bound against its tip — extending the enforcement to the DB boundary itself.
  • Range-scoped sync queries validate range.end() <= tip themselves via a new RangeBeyondTip error (same InvalidArgument response as before); the RPC layer's range_bounds_check is deleted and pagination's chain_tip is now the tip the query actually ran against.
  • Fixes paths that previously took two snapshots per request (get_account, the block producer's get_tx_inputs); sync_chain_mmr clamps the proven tip to the view's tip.

Live tips (state/tip.rs)

  • State::committed_tip() / proven_tip() read the watch channels their writers publish to (mirroring subscribe_committed_tip / subscribe_proven_tip); the Finality enum is removed. The committed tip is published after the snapshot, so it never reports a block a fresh view cannot serve.

Observability: SnapshotGuard tracks live snapshot generations and lifetimes; warns when a snapshot outlives 10s or more than 4 generations are pinned (a leaked/slow reader pins a RocksDB snapshot).

Supporting changes: read-only reader() views for AccountStateForest / AccountTreeWithHistory (relaxed to BackendReader/SmtStorageReader bounds); state module restructured into view/ (read endpoints) and writer/ (worker + capabilities); new tracing field names allowlisted.

Changelog

[[entry]]
scope       = "node"
impact      = "changed"
description = "Store reads are lock-free: readers use atomically published in-memory snapshots and are no longer blocked while blocks are applied."

@sergerad
sergerad marked this pull request as ready for review July 23, 2026 01:18

@Mirko-von-Leipzig Mirko-von-Leipzig left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Some comments; but the size of the PR does somewhat force our hand towards merging this and fixing things in post.

A more staggered/stacked approach could have been:

  • snapshot API + one or two example impls
  • more impls
  • lastly update apply block

I do think we can do better once we have sqlite added; but this is a decent stepping stone.

Comment thread bin/node/src/commands/recover.rs
Comment thread crates/store/src/state/tip.rs
Comment thread crates/store/src/state/mod.rs Outdated
Comment thread crates/store/src/state/lifecycle.rs Outdated
Comment on lines +188 to +189
let (current_block_height, store_inputs) = state
.with_view(async |view| {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Why do we use with_state as a closure?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Do you mean with_view? Mainly so that a set of view calls are ensured to use the same snapshot (because state.view() could return a different snapshot different calls).

Comment thread crates/rpc/src/server/mod.rs
Comment on lines +77 to +83
/// Runs a read operation over a view pinned at the current chain tip, dropping the view — and
/// releasing its snapshot generation — as soon as the operation completes.
///
/// This is the required form whenever multiple reads must observe the *same* snapshot: the
/// closure's view is one consistent generation, whereas consecutive [`Self::view`] calls may
/// straddle a commit. The typical case is pairing a query with [`StateView::tip`] so a
/// response reports exactly the height it was served at.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

tbh I don't quite understand the need for view and with_view. Why can't the caller hold the view for as many calls as they want?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Both are intended to be used so that the lifetime of the StateView (and underlying Arc<StateSnapshot>) are as short as possible.

So for example

  // Bad
  let view = state.view();
  let tip = view.tip();
  // more unrelated instructions...
  // scope finally ends
}

  // Good
  let tip = state.view().tip();

And

  // Bad
  let view = state.view();
  let x = view.get_accounts();
  let y = view.compute_something(x);
  // more unrelated instructions...
  // scope finally ends
}

  // Good
  // Snapshot tightly scoped
  state.with_view(...);
  // unrelated instructions don't affect lifetime of snapshot

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I understand in general, but because we offer both APIs there isn't really any safety in that; its an improvement in cases where we should otherwise manually drop.

But I don't know of any such cases? I would expect a view to live for 99% of an RPC request's lifespan.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Frankly I think we should just keep view() and use an explicit block on the client side if early drops are desired.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Personally I like having view()/with_view() API because it is very explicit about the importance of limiting StateView/StateSnapshot lifetime. Up to you guys.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I'm fine with having both, too!

Comment thread crates/store/src/state/view/mod.rs
Comment thread crates/store/src/state/writer/worker.rs
Comment thread crates/store/src/state/writer/worker.rs

@igamigo igamigo left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Not a full review (hopefully by tomorrow morning), but so far it's looking good. Left just one comment for now

Comment thread crates/store/src/state/writer.rs Outdated
.reader()
.expect("nullifier tree snapshot creation should not fail"),
account_tree: self.account_tree.reader(),
blockchain: self.blockchain.clone(),

@igamigo igamigo Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Is this fine to do? I haven't done the math but wouldn't this self.blockchain grow into the hundreds of MBs fairly quickly (weeks/months)? Not sure there is an easy way to work around this though. This might have been accounted for already

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Comment on lines +77 to +83
/// Runs a read operation over a view pinned at the current chain tip, dropping the view — and
/// releasing its snapshot generation — as soon as the operation completes.
///
/// This is the required form whenever multiple reads must observe the *same* snapshot: the
/// closure's view is one consistent generation, whereas consecutive [`Self::view`] calls may
/// straddle a commit. The typical case is pairing a query with [`StateView::tip`] so a
/// response reports exactly the height it was served at.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Frankly I think we should just keep view() and use an explicit block on the client side if early drops are desired.

Comment thread crates/store/src/state/view/mod.rs
Comment thread crates/store/src/state/writer/worker.rs
Comment thread bin/node/src/commands/modes.rs
self.nullifier_tree
.reader()
.expect("nullifier tree snapshot creation should not fail"),
self.blockchain.clone(),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

As @igamigo has pointed out this might be problematic if the underlying MMR grows. I don't think we can avoid this clone now, but we should probably check if it's possible to implement the underlying MMR as an immutable data structure.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Comment thread crates/store/src/state/writer/worker.rs Outdated
// commits; queries that combine DB and in-memory data are scoped by block number.
let resolved_note_ids = self
.db
.apply_block(signed_block, notes, precomputed_public_states, unresolved_note_nullifiers)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This operation also does pruning, possibly removing data that is still reachable from a StateView.

At tip N, the account-tree snapshot can still serve block N-50. If N+1 commits while that view is alive, the cutoff advances to N-49 and may delete non-latest rows from N-50. The view can then return a valid N-50 witness/header but missing or incorrect historical code, vault, or storage
details. An upper-bound ScopedBlockNum cannot prevent data below that bound from disappearing.

To fix this we should either open and use an SQLite transaction for the view (I don't think this is currently possible, maybe it will be once we migrate off Diesel) or make pruning honor the oldest live snapshot height and retain everything needed by that generation.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I have gone with the latter. Although if a StateSnapshot is too far behind, it will be ignored by pruning. That is a failure state that our traces / alerts would let us know about.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

For other potential problems, followup #2437

Comment thread crates/store/src/state/view/batch_inputs.rs Outdated
Comment thread crates/store/src/state/writer/apply_proof.rs Outdated
Comment thread crates/store/src/state/writer/worker.rs Outdated
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.

Enforce block-scoped DB reads through a view type Refactor apply_block perf: move to a single, locked writer database connection

4 participants