Skip to content

feat: Lock-free apply_block refactor - #2345

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

feat: Lock-free apply_block refactor#2345
sergerad wants to merge 39 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.

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 were blocked during the DB-commit window of apply_block); 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 that combines tree and DB data at different chain heights is no longer expressible.

Why:

  • Read endpoints (sync, account/nullifier proofs, chain tip) no longer stall while a block is being applied.
  • Removes the cross-task lock choreography in apply_block (oneshot handshakes between the DB task and the in-memory update), which was fragile and hard to reason about.
  • Snapshot-scoped reads were previously enforced by convention (comments and caller-side checks); several paths read the tip and the data from different snapshots. The StateView makes the scoping structural.

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 anywhere. 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. Readers keep a consistent frozen view even 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 and ProofWriter capabilities and a WriterTask join handle — statically limiting each write path to one task. The capabilities expose no read access; tasks that read and write receive the 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.

LGTM, thank you; this is already much better.

I left some comments regarding splitting the read and write portion sooner somehow; but thinking on it some more I'm not sure its tractable to implement at the moment.

Comment thread crates/block-producer/src/server/mod.rs Outdated
Comment thread crates/rpc/src/tests.rs Outdated
Comment on lines +417 to +424
pub fn reader(&self) -> AccountTreeWithHistory<S::Reader> {
let latest = self.latest.reader().expect("snapshot creation should not fail");
AccountTreeWithHistory {
block_number: self.block_number,
latest,
overlays: self.overlays.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.

I think the overall API shape here is incorrect, but perhaps that can be part of the followup.

What I would like is a guarantee that readers can never access writing. But with this API everybody has access to AccountTreeWithHistory and can therefore invoke apply_mutations if they so wish.

Something similar to what I suggested here #2353 (comment).

i.e. on Smt::load you get a single SmtWriter (non-clone), and a SmtReader. This would go together with a single WriteConnection and a ReadOnlyConnectionPool for the database.

The write task would get StateWriter (1 db connection, 1 smt writer per smt), and everything else gets ReadOnlyState for queries. This way we guarantee very early on that nothing can escalate their privilege.

So ito this code, reader would become SmtReader::snapshot(BlockNumber) -> AccountTreeSnapshot, or something similar.

@sergerad sergerad Jul 30, 2026

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.

What I would like is a guarantee that readers can never access writing. But with this API everybody has access to AccountTreeWithHistory and can therefore invoke apply_mutations if they so wish.

The apply_mutations fn lives in impl<S: SmtStorage> AccountTreeWithHistory<S> { so it is not callable from the AccountTreeWithHistory<SmtStorageReader> which is returned by the reader() here.

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.

Yes but readers already have access to super struct i.e. they could elect to write.

I don't want to audit this by reading every read query and checking they do the correct thing.

Ideally, right at the very start, before anything even gets launched, we already split things up. But perhaps that isn't possible because apply_block method lives together with the read methods so we cannot do that.

As an example, if we had the write methods in a separate gRPC service, we could give the write service state the write struct. And the readers would only ever get the read struct. But this isn't possible unless we split the services, which seems a bit silly (? perhaps ?).

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.

But lets ignore this for.

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.

Have unresolved to keep track of this. Might add to a stacked PR.

Comment thread crates/store/src/state/inputs.rs Outdated

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 would create a separate file per function instead.

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 also didn't review the contents under the assumption that this was a copy-paste move.

Comment thread crates/store/src/state/lifecycle.rs Outdated
Comment on lines +54 to +96
#[must_use = "call `start` to spawn the block writer and obtain the state"]
pub struct LoadedState {
state: State,
writer: BlockWriter,
}

impl LoadedState {
/// Spawns the block writer onto the current runtime and returns the state together with the
/// writer task's handle.
///
/// The writer exits once the shutdown token passed to [`State::load`] is cancelled or the last
/// state reference (holding the only write handle) is dropped — an in-flight block write
/// always completes first. Awaiting the returned handle after either event guarantees the
/// writer has released the tree storage it owns; a join error carries a writer panic.
///
/// Callers without a token to cancel should stop the store via [`State::stop`] rather than
/// dropping and joining by hand.
pub fn start(self) -> (Arc<State>, WriterTask) {
let writer_task = tokio::spawn(self.writer.run());
(Arc::new(self.state), WriterTask(writer_task))
}
}

// WRITER TASK
// ================================================================================================

/// Handle of the store's block writer task, returned by [`LoadedState::start`].
///
/// Awaiting it resolves once the writer has exited and released the tree storage it owns; a join
/// error carries a writer panic. The newtype ensures [`State::stop`] can only be given the store's
/// own writer task, and deliberately does not expose [`tokio::task::JoinHandle::abort`]: aborting
/// the writer mid-write could leave the trees lagging the committed database state, voiding the
/// guarantee that an in-flight block write always completes.
#[must_use = "await the writer task to observe its exit, or pass it to `State::stop`"]
pub struct WriterTask(tokio::task::JoinHandle<()>);

impl Future for WriterTask {
type Output = Result<(), tokio::task::JoinError>;

fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
Pin::new(&mut self.0).poll(cx)
}
}

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 mentioned; I think this is the wrong abstraction. I haven't checked how WriterTask works yet though.

I feel like State::load could return ReadOnlyState(Arc<State>) + StateWriter. The task spawning etc and what that means in various contexts should be more obvious at the call site, and not hidden imo.

e.g. I would expect to see, at the command level:

  • full node's to pass WriteState to an sync-via-rpc thingy, and
  • sequencer to pass it.. to apply_block somehow.

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.

Have updated to use a readonly State with BlockWriter and ProofWriter

/// Readers are expected to be request-scoped, so snapshot lifetimes should be well under a block
/// interval. A lifetime exceeding [`SNAPSHOT_LIFETIME_WARN_THRESHOLD`] is logged at warn level.
pub(crate) struct SnapshotGuard {
live: Arc<AtomicUsize>,

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 could probably be

Suggested change
live: Arc<AtomicUsize>,
live: Arc<()>,

because count is tracked by arc already via Arc::strong_count.

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 think I prefer AtomicUsize for clarity. The strong_count approach feels a bit too implicit. And potentially our warn/debug logs could be misleading / erroneous regarding the live count if multiple drops occur concurrently?

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.

One would have to prematurely "drop" the arc e.g.:

let count = live.downgrade().strong_count();

I figured you'd want to actually store something in the Arc eventually. But if you don't like relying on arc semantics, then lets not block on this. It just stood out to me that we were replicating arc internals.

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.

Thank you, I think this is really cool!

Comment on lines +271 to +278
if snapshots_live > SNAPSHOTS_LIVE_WARN_THRESHOLD {
tracing::warn!(
target: COMPONENT,
block_num = block_num.as_u32(),
snapshots.live = snapshots_live,
"too many live state snapshots; slow readers are pinning old generations",
);
}

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 like this.

I would like to explore some additional stuff we could diagnose here. For example if we could figure out a way to have each snapshot given the read request name as a static string.

And that here we could lookup the oldest snapshot and log that.

An alternative to this would be including a timeout task to each snapshot, which automatically does this logging instead. And the timeout task gets cancelled/aborted when the snapshot is dropped.

But lets leave that for a follow-up PR to explore this space.

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 didn't inspect this code too deeply; @kkovaacs if you could perhaps do a more thorough job here.

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

Blocking merge until we are done with the demo

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.

Refactor apply_block perf: move to a single, locked writer database connection

2 participants