Skip to content

feat: Lock-free apply_block refactor - #2345

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

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

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.

How:

  • Single-writer task: a new BlockWriter task (crates/store/src/state/writer.rs) owns the mutable nullifier tree, account tree, blockchain MMR, and account-state forest. State::apply_block forwards blocks to it over an mpsc channel; requests are processed serially, so no locks are needed anywhere.
  • Snapshot publication: after each DB commit the writer builds a new immutable InMemoryState (trees backed by read-only RocksDB snapshot views) and publishes it atomically via ArcSwap. Readers load_full() a snapshot wait-free and keep a consistent frozen view even while the next block commits.
  • Read consistency scoped by snapshot tip: queries that combine DB and in-memory data (get_block_header, get_transaction_inputs note lookups) are now scoped to the snapshot's block number, since mid-apply the DB may be ahead of the published snapshot. select_existing_note_commitments gains an up_to_block bound.
  • Simplified DB apply: Db::apply_block is now a plain transaction — the oneshot allow_acquire/acquire_done synchronization is removed.
  • Deterministic shutdown: State::shutdown drains and joins the writer task so tree storage is released before the data directory is re-opened or deleted (used by stress-test seeding).
  • 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), chain_tip is now sync, new tracing field names allowlisted.

Followup:

  • Type-enforced read consistency: a new state view type could be added to provide stronger enforcement of state consistency (where DB queries need to be scoped by block number). For example:
/// A consistent read view of the store, pinned at the snapshot's block height.
pub(crate) struct StateView {
    snapshot: Arc<InMemoryState>,
    db: Arc<Db>,
}

impl State {
    fn view(&self) -> StateView {
        StateView { snapshot: self.snapshot(), db: Arc::clone(&self.db) }
    }
}

impl StateView {
    fn tip(&self) -> BlockNumber { self.snapshot.latest_block_num() }

    // Tree/MMR access delegates to the snapshot.
    fn blockchain(&self) -> &Blockchain { &self.snapshot.blockchain }

    // DB access is scoped automatically — callers can't pass their own tip.
    async fn select_existing_note_commitments(
        &self,
        commitments: Vec<Word>,
    ) -> Result<HashSet<Word>> {
        self.db.select_existing_note_commitments(commitments, self.tip()).await
    }
}

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 thread crates/store/src/accounts/mod.rs

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