feat: Lock-free apply_block refactor - #2345
Conversation
…ckfree-store-state
…ckfree-store-state
…ckfree-store-state
Mirko-von-Leipzig
left a comment
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
I would create a separate file per function instead.
There was a problem hiding this comment.
I also didn't review the contents under the assumption that this was a copy-paste move.
| #[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) | ||
| } | ||
| } |
There was a problem hiding this comment.
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
WriteStateto an sync-via-rpc thingy, and - sequencer to pass it.. to
apply_blocksomehow.
There was a problem hiding this comment.
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>, |
There was a problem hiding this comment.
This could probably be
| live: Arc<AtomicUsize>, | |
| live: Arc<()>, |
because count is tracked by arc already via Arc::strong_count.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Thank you, I think this is really cool!
| 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", | ||
| ); | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
I didn't inspect this code too deeply; @kkovaacs if you could perhaps do a more thorough job here.
Mirko-von-Leipzig
left a comment
There was a problem hiding this comment.
Blocking merge until we are done with the demo
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 ofapply_block); they now load an immutable snapshot viaArcSwapand are never blocked by writes.Why:
apply_block(oneshot handshakes between the DB task and the in-memory update), which was fragile and hard to reason about.How:
BlockWritertask (crates/store/src/state/writer.rs) owns the mutable nullifier tree, account tree, blockchain MMR, and account-state forest.State::apply_blockforwards blocks to it over an mpsc channel; requests are processed serially, so no locks are needed anywhere.InMemoryState(trees backed by read-only RocksDB snapshot views) and publishes it atomically viaArcSwap. Readersload_full()a snapshot wait-free and keep a consistent frozen view even while the next block commits.get_block_header,get_transaction_inputsnote 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_commitmentsgains anup_to_blockbound.Db::apply_blockis now a plain transaction — the oneshotallow_acquire/acquire_donesynchronization is removed.State::shutdowndrains and joins the writer task so tree storage is released before the data directory is re-opened or deleted (used by stress-test seeding).SnapshotGuardtracks 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).reader()views forAccountStateForest/AccountTreeWithHistory(relaxed toBackendReader/SmtStorageReaderbounds),chain_tipis now sync, new tracing field names allowlisted.Followup:
Changelog
`