Skip to main content

miden_node_store/state/
lifecycle.rs

1//! Store lifecycle: loading the state, starting its write worker, and stopping the store.
2
3use std::num::NonZeroUsize;
4use std::path::Path;
5use std::sync::Arc;
6use std::sync::atomic::AtomicUsize;
7
8use arc_swap::ArcSwap;
9use miden_node_tracing::spawn::spawn_blocking_in_current_span;
10use miden_node_tracing::{ErrorReport, miden_instrument};
11use miden_node_utils::clap::StorageOptions;
12use miden_node_utils::shutdown::CancellationToken;
13use tokio::sync::{mpsc, watch};
14use tokio::task::JoinHandle;
15use tracing::Instrument;
16
17use crate::account_state_forest::AccountStateForestBackend;
18use crate::accounts::AccountTreeWithHistory;
19use crate::blocks::BlockStore;
20use crate::db::Db;
21use crate::errors::StateInitializationError;
22use crate::proven_tip::ProvenTipWriter;
23use crate::state::loader::{
24    ACCOUNT_STATE_FOREST_STORAGE_DIR,
25    ACCOUNT_TREE_STORAGE_DIR,
26    AccountForestLoader,
27    NULLIFIER_TREE_STORAGE_DIR,
28    TreeStorage,
29    TreeStorageLoader,
30    load_mmr,
31    verify_account_state_forest_consistency,
32    verify_tree_consistency,
33};
34use crate::state::writer::{WriteRequest, WriteWorker, WriterTask};
35use crate::state::{
36    BlockCache,
37    BlockWriter,
38    ProofCache,
39    ProofWriter,
40    SnapshotGuard,
41    State,
42    StateSnapshot,
43};
44use crate::{COMPONENT, DataDirectory, DatabaseOptions};
45
46/// Awaits a spawned load task, forwarding its result.
47///
48/// The load tasks are never aborted, so a join error is a panic from the task; it is resumed on
49/// the caller so panics keep propagating as panics.
50async fn join_load_task<T>(
51    handle: JoinHandle<Result<T, StateInitializationError>>,
52) -> Result<T, StateInitializationError> {
53    match handle.await {
54        Ok(result) => result,
55        Err(err) => std::panic::resume_unwind(err.into_panic()),
56    }
57}
58
59/// Number of recent committed blocks held in the in-memory cache for replica subscriptions.
60const BLOCK_CACHE_CAPACITY: NonZeroUsize = NonZeroUsize::new(512).unwrap();
61
62/// Number of recent block proofs held in the in-memory cache for replica subscriptions.
63const PROOF_CACHE_CAPACITY: NonZeroUsize = NonZeroUsize::new(512).unwrap();
64
65// LOADED STATE
66// ================================================================================================
67
68/// A loaded store state whose write worker has not been started yet.
69///
70/// Returned by [`State::load`]. [`Self::start`] spawns the writer and yields the read-only
71/// [`State`] together with the write capabilities; since this is the only way to obtain a
72/// [`BlockWriter`], [`BlockWriter::apply_block`] can always make progress.
73#[must_use = "call `start` to spawn the write worker and obtain the state"]
74pub struct LoadedState {
75    state: State,
76    writer: WriteWorker,
77    write_tx: mpsc::Sender<WriteRequest>,
78}
79
80impl LoadedState {
81    /// Spawns the write worker onto the current runtime and returns the read-only state together
82    /// with the write capabilities and the writer task's handle.
83    ///
84    /// [`Arc<State>`] is the read-only view shared with every component that queries or
85    /// subscribes; it exposes no mutating methods. [`BlockWriter`] and [`ProofWriter`] are the
86    /// only handles able to mutate the store — hand them to the single task driving each write
87    /// path (block production or sync, and proof scheduling or sync respectively). The
88    /// capabilities expose no read access: tasks that both read and write receive the
89    /// [`Arc<State>`] alongside their capability.
90    ///
91    /// The writer exits once `shutdown` is cancelled or the [`BlockWriter`] (holding the only
92    /// request sender) is dropped — an in-flight block write always completes first. Awaiting the
93    /// returned handle after either event guarantees the writer has released the tree storage it
94    /// owns; a join error carries a writer panic.
95    ///
96    /// Callers without a token to cancel should pass [`CancellationToken::new`] and stop the store
97    /// via [`BlockWriter::stop`] instead of dropping and joining by hand.
98    pub fn start(
99        self,
100        shutdown: CancellationToken,
101    ) -> (Arc<State>, BlockWriter, ProofWriter, WriterTask) {
102        let writer_task = tokio::spawn(self.writer.run(shutdown));
103        let state = Arc::new(self.state);
104        let block_writer = BlockWriter {
105            block_store: Arc::clone(&state.block_store),
106            write_tx: self.write_tx,
107        };
108        let proof_writer = ProofWriter { state: Arc::clone(&state) };
109        (state, block_writer, proof_writer, WriterTask(writer_task))
110    }
111}
112
113// LOAD
114// ================================================================================================
115
116impl State {
117    /// Loads the state from the data directory.
118    ///
119    /// The loaded state owns all store data structures and exposes subscription methods for
120    /// sequencer and replica tasks. Call [`LoadedState::start`] on the result to spawn the block
121    /// writer and obtain the usable [`State`].
122    #[miden_instrument(
123        target = COMPONENT,
124    )]
125    pub async fn load(
126        data_path: &Path,
127        storage_options: StorageOptions,
128    ) -> Result<LoadedState, StateInitializationError> {
129        Self::load_with_database_options(data_path, storage_options, DatabaseOptions::default())
130            .await
131    }
132
133    /// Loads the state from the data directory using explicit database options.
134    ///
135    /// The loaded state owns all store data structures and exposes subscription methods for
136    /// sequencer and replica tasks. Call [`LoadedState::start`] on the result to spawn the block
137    /// writer and obtain the usable [`State`].
138    #[miden_instrument(
139        target = COMPONENT,
140    )]
141    pub async fn load_with_database_options(
142        data_path: &Path,
143        storage_options: StorageOptions,
144        database_options: DatabaseOptions,
145    ) -> Result<LoadedState, StateInitializationError> {
146        let data_directory = DataDirectory::load(data_path.to_path_buf())
147            .map_err(StateInitializationError::DataDirectoryLoadError)?;
148
149        let block_store = Arc::new(
150            BlockStore::load(data_directory.block_store_dir())
151                .map_err(StateInitializationError::BlockStoreLoadError)?,
152        );
153
154        let database_filepath = data_directory.database_path();
155        let db = Arc::new(
156            Db::load_with_pool_size(
157                database_filepath.clone(),
158                database_options.connection_pool_size,
159            )
160            .await
161            .map_err(StateInitializationError::DatabaseLoadError)?,
162        );
163
164        let genesis_header = db
165            .select_genesis_block_header()
166            .await?
167            .ok_or(StateInitializationError::GenesisBlockMissing)?;
168        let genesis_protocol_config_commitment = genesis_header.protocol_config_commitment();
169        if db
170            .select_protocol_config_by_commitment(genesis_protocol_config_commitment)
171            .await?
172            .is_none()
173        {
174            return Err(StateInitializationError::GenesisProtocolConfigMissing {
175                commitment: genesis_protocol_config_commitment,
176            });
177        }
178
179        // The chain tip drives forest loading and the account tree history below; `load_mmr`'s
180        // consistency check also pins the chain MMR to this header.
181        let latest_block_num = db
182            .select_block_header_by_block_num(None)
183            .await?
184            .ok_or(StateInitializationError::GenesisBlockMissing)?
185            .block_num();
186
187        let apply_block_thread_priority = storage_options.apply_block_thread_priority;
188
189        #[cfg(feature = "rocksdb")]
190        let (account_storage_config, nullifier_storage_config, forest_storage_config) = (
191            storage_options.account_tree.into(),
192            storage_options.nullifier_tree.into(),
193            storage_options.account_state_forest.into(),
194        );
195        #[cfg(not(feature = "rocksdb"))]
196        let (account_storage_config, nullifier_storage_config, forest_storage_config) =
197            ((), (), ());
198
199        // The four structures live in independent storages and the database pool supports
200        // concurrent readers, so open and load them concurrently. Each branch is a spawned task
201        // because loading has long synchronous sections (RocksDB opens, MMR hashing, SMT top
202        // reconstruction) that would serialize if polled from a single task. Spawning is eager, so
203        // all four run from this point; the join below only collects their results.
204        let mmr_task = tokio::spawn(
205            {
206                let db = Arc::clone(&db);
207                async move { load_mmr(&db).await }
208            }
209            .in_current_span(),
210        );
211        let account_tree_task = tokio::spawn(
212            {
213                let (db, path) = (Arc::clone(&db), data_path.to_path_buf());
214                async move {
215                    join_load_task(spawn_blocking_in_current_span(move || {
216                        TreeStorage::create(
217                            &path,
218                            &account_storage_config,
219                            ACCOUNT_TREE_STORAGE_DIR,
220                        )
221                    }))
222                    .await?
223                    .load_account_tree(&db)
224                    .await
225                }
226            }
227            .in_current_span(),
228        );
229        let nullifier_tree_task = tokio::spawn(
230            {
231                let (db, path) = (Arc::clone(&db), data_path.to_path_buf());
232                async move {
233                    join_load_task(spawn_blocking_in_current_span(move || {
234                        TreeStorage::create(
235                            &path,
236                            &nullifier_storage_config,
237                            NULLIFIER_TREE_STORAGE_DIR,
238                        )
239                    }))
240                    .await?
241                    .load_nullifier_tree(&db)
242                    .await
243                }
244            }
245            .in_current_span(),
246        );
247        let forest_task = tokio::spawn(
248            {
249                let (db, path) = (Arc::clone(&db), data_path.to_path_buf());
250                async move {
251                    let forest = join_load_task(spawn_blocking_in_current_span(move || {
252                        AccountStateForestBackend::create(
253                            &path,
254                            &forest_storage_config,
255                            ACCOUNT_STATE_FOREST_STORAGE_DIR,
256                        )
257                    }))
258                    .await?
259                    .load_account_state_forest(&db, latest_block_num)
260                    .await?;
261                    verify_account_state_forest_consistency(&forest, &db).await?;
262                    Ok(forest)
263                }
264            }
265            .in_current_span(),
266        );
267        let (blockchain, account_tree, nullifier_tree, forest) = tokio::try_join!(
268            join_load_task(mmr_task),
269            join_load_task(account_tree_task),
270            join_load_task(nullifier_tree_task),
271            join_load_task(forest_task),
272        )?;
273
274        // Verify that tree roots match the expected roots from the database. This catches any
275        // divergence between persistent storage and the database caused by corruption or incomplete
276        // shutdown.
277        verify_tree_consistency(account_tree.root(), nullifier_tree.root(), &db).await?;
278
279        let account_tree = AccountTreeWithHistory::new(account_tree, latest_block_num);
280
281        // Initialize the proven tip from the block store.
282        let proven_tip_init = block_store
283            .load_proven_tip()
284            .map_err(StateInitializationError::ProvenTipLoadError)?;
285        let (proven_tip, _rx) = ProvenTipWriter::new(proven_tip_init);
286
287        // Committed-tip watch: fires after each successful apply_block.
288        let (committed_tip_tx, _rx) = watch::channel(latest_block_num);
289        let committed_tip_tx = Arc::new(committed_tip_tx);
290
291        let block_cache = BlockCache::new(BLOCK_CACHE_CAPACITY);
292        let proof_cache = ProofCache::new(PROOF_CACHE_CAPACITY);
293
294        // Shared counter of live snapshot generations, for observability.
295        let snapshots_live = Arc::new(AtomicUsize::new(0));
296
297        // Create the initial snapshot from reader views of the just-loaded trees.
298        let initial_snapshot = Arc::new(StateSnapshot::new(
299            nullifier_tree
300                .reader()
301                .map_err(|e| StateInitializationError::NullifierTreeIoError(e.as_report()))?,
302            blockchain.clone(),
303            account_tree.reader(),
304            forest
305                .reader()
306                .map_err(|e| StateInitializationError::AccountStateForestIoError(e.as_report()))?,
307            SnapshotGuard::new(Arc::clone(&snapshots_live), latest_block_num),
308        ));
309        let latest_snapshot = Arc::new(ArcSwap::from(initial_snapshot));
310
311        // Assemble the write worker. It owns the writable trees and processes write requests
312        // serially, publishing a new snapshot after each committed block. The caller runs it; it
313        // exits when the shutdown token is cancelled or the `BlockWriter` (holding the only request
314        // sender) is dropped.
315        let (write_tx, write_rx) = mpsc::channel(1);
316        let block_writer = WriteWorker::new(
317            Arc::clone(&db),
318            Arc::clone(&block_store),
319            Arc::clone(&latest_snapshot),
320            Arc::clone(&committed_tip_tx),
321            block_cache.clone(),
322            write_rx,
323            nullifier_tree,
324            account_tree,
325            blockchain,
326            forest,
327            snapshots_live,
328            apply_block_thread_priority,
329        );
330        let state = Self {
331            data_directory: data_path.to_path_buf(),
332            db,
333            block_store,
334            latest_snapshot,
335            proven_tip,
336            committed_tip_tx,
337            block_cache,
338            proof_cache,
339        };
340
341        Ok(LoadedState { state, writer: block_writer, write_tx })
342    }
343
344    /// Loads the state with default options and starts its write worker, detaching the worker
345    /// task.
346    ///
347    /// Test-only helper for tests in sibling crates that don't manage the writer's lifecycle:
348    /// the detached writer exits once the returned [`BlockWriter`] is dropped. Hidden from public
349    /// docs and not part of the stable API.
350    ///
351    /// # Panics
352    ///
353    /// Panics if the state fails to load.
354    #[doc(hidden)]
355    pub async fn for_tests(data_path: &Path) -> (Arc<Self>, BlockWriter, ProofWriter) {
356        let (state, block_writer, proof_writer, _writer_task) =
357            Self::load(data_path, StorageOptions::default())
358                .await
359                .expect("state should load")
360                .start(CancellationToken::new());
361        (state, block_writer, proof_writer)
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use miden_node_db::sqlite::DbWriter;
368    use miden_node_utils::clap::StorageOptions;
369    use miden_node_utils::fee::{test_fee_params, test_protocol_config};
370    use miden_protocol::block::ValidatorConfig;
371    use miden_protocol::testing::random_secret_key::random_secret_key;
372
373    use super::State;
374    use crate::DataDirectory;
375    use crate::errors::{DatabaseError, StateInitializationError};
376    use crate::genesis::GenesisState;
377
378    async fn bootstrap_store(path: &std::path::Path) -> miden_protocol::Word {
379        let signer = random_secret_key();
380        let genesis = GenesisState::new(
381            Vec::new(),
382            test_fee_params(),
383            0,
384            ValidatorConfig::new(vec![signer.public_key()], 1).unwrap(),
385            test_protocol_config(),
386        )
387        .into_block()
388        .unwrap();
389        let commitment = genesis.protocol_config().to_commitment();
390        State::bootstrap(genesis, path).await.unwrap();
391        commitment
392    }
393
394    fn database_writer(path: &std::path::Path) -> DbWriter {
395        let database_path = DataDirectory::load(path.to_path_buf()).unwrap().database_path();
396        let (writer, _reader) = miden_node_db::sqlite::open(&database_path).unwrap();
397        writer
398    }
399
400    #[tokio::test]
401    async fn load_rejects_missing_genesis_protocol_config() {
402        let temp_dir = tempfile::tempdir().unwrap();
403        let commitment = bootstrap_store(temp_dir.path()).await;
404        database_writer(temp_dir.path())
405            .write::<_, DatabaseError, _>("delete genesis protocol config", move |tx| {
406                tx.execute("DELETE FROM protocol_configs WHERE commitment = ?1", &[&commitment])?;
407                Ok(())
408            })
409            .await
410            .unwrap();
411
412        let error = State::load(temp_dir.path(), StorageOptions::default())
413            .await
414            .err()
415            .expect("state load should fail");
416        assert!(matches!(
417            error,
418            StateInitializationError::GenesisProtocolConfigMissing { commitment: actual }
419                if actual == commitment
420        ));
421    }
422
423    #[tokio::test]
424    async fn load_rejects_corrupt_genesis_protocol_config() {
425        let temp_dir = tempfile::tempdir().unwrap();
426        let commitment = bootstrap_store(temp_dir.path()).await;
427        let mut bytes = miden_node_persistence::encode(&test_protocol_config());
428        bytes.push(0xff);
429        database_writer(temp_dir.path())
430            .write::<_, DatabaseError, _>("corrupt genesis protocol config", move |tx| {
431                tx.execute(
432                    "UPDATE protocol_configs SET protocol_config = ?1 WHERE commitment = ?2",
433                    &[&bytes, &commitment],
434                )?;
435                Ok(())
436            })
437            .await
438            .unwrap();
439
440        let error = State::load(temp_dir.path(), StorageOptions::default())
441            .await
442            .err()
443            .expect("state load should fail");
444        assert!(matches!(
445            error,
446            StateInitializationError::DatabaseError(DatabaseError::Persistence(_))
447        ));
448    }
449
450    #[tokio::test]
451    async fn state_view_returns_genesis_protocol_config() {
452        let temp_dir = tempfile::tempdir().unwrap();
453        let commitment = bootstrap_store(temp_dir.path()).await;
454
455        let loaded = State::load(temp_dir.path(), StorageOptions::default()).await.unwrap();
456        let protocol_config = loaded.state.view().get_protocol_config(commitment).await.unwrap();
457
458        assert_eq!(protocol_config, Some(test_protocol_config()));
459    }
460}