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.
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.
| let (current_block_height, store_inputs) = state | ||
| .with_view(async |view| { |
There was a problem hiding this comment.
Why do we use with_state as a closure?
There was a problem hiding this comment.
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).
| /// 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. |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 snapshotThere was a problem hiding this comment.
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.
There was a problem hiding this comment.
Frankly I think we should just keep view() and use an explicit block on the client side if early drops are desired.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
I'm fine with having both, too!
…ckfree-store-state
igamigo
left a comment
There was a problem hiding this comment.
Not a full review (hopefully by tomorrow morning), but so far it's looking good. Left just one comment for now
| .reader() | ||
| .expect("nullifier tree snapshot creation should not fail"), | ||
| account_tree: self.account_tree.reader(), | ||
| blockchain: self.blockchain.clone(), |
There was a problem hiding this comment.
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
| /// 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. |
There was a problem hiding this comment.
Frankly I think we should just keep view() and use an explicit block on the client side if early drops are desired.
| self.nullifier_tree | ||
| .reader() | ||
| .expect("nullifier tree snapshot creation should not fail"), | ||
| self.blockchain.clone(), |
There was a problem hiding this comment.
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.
| // 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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
For other potential problems, followup #2437
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 duringapply_block's DB-commit window); they now load an immutable snapshot viaArcSwapand are never blocked by writes. All reads flow through a request-scopedStateView, so a query combining tree and DB data at different chain heights is no longer expressible.Why:
apply_block(oneshot handshakes between the DB task and the in-memory update).StateViewmakes the scoping structural instead.How:
Lock-free write path (
state/writer/)WriteWorkertask 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.StateSnapshot(trees backed by read-only RocksDB snapshot views) and publishes it atomically viaArcSwap, so readers keep a consistent frozen view while the next block commits.Db::apply_blockis now a plain transaction — the oneshotallow_acquire/acquire_donesynchronization is removed.Write capabilities (
state/lifecycle.rs)LoadedState::startspawns the worker and returns the read-onlyArc<State>plus non-cloneableBlockWriter/ProofWritercapabilities and aWriterTaskhandle, statically limiting each write path to one task. The capabilities expose no read access; tasks that read and write getArc<State>alongside their capability.BlockWriter::stopdrains and joins the worker so tree storage is released deterministically before the data directory is re-opened or deleted (used byrecoverand stress-test seeding).Type-enforced reads (
state/view/)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 throughblock_in_placehelpers; snapshot fields are only visible inside the view module.Dbqueries require view-issued proof types (ScopedBlockNum/ScopedBlockRange), constructible only by aStateViewafter validating the bound against its tip — extending the enforcement to the DB boundary itself.range.end() <= tipthemselves via a newRangeBeyondTiperror (sameInvalidArgumentresponse as before); the RPC layer'srange_bounds_checkis deleted and pagination'schain_tipis now the tip the query actually ran against.get_account, the block producer'sget_tx_inputs);sync_chain_mmrclamps 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 (mirroringsubscribe_committed_tip/subscribe_proven_tip); theFinalityenum is removed. The committed tip is published after the snapshot, so it never reports a block a fresh view cannot serve.Observability:
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).Supporting changes: read-only
reader()views forAccountStateForest/AccountTreeWithHistory(relaxed toBackendReader/SmtStorageReaderbounds);statemodule restructured intoview/(read endpoints) andwriter/(worker + capabilities); new tracing field names allowlisted.Changelog