From 74dfabeba6a748b181effa7808455eaf05909c44 Mon Sep 17 00:00:00 2001 From: SantiagoPittella Date: Tue, 24 Feb 2026 15:18:54 -0300 Subject: [PATCH 1/6] chore: deactivate idle actors --- crates/ntx-builder/src/actor/mod.rs | 47 +++++++++++++++++++++++++ crates/ntx-builder/src/builder.rs | 17 +++++++++- crates/ntx-builder/src/coordinator.rs | 49 ++++++++++++++++++++++++++- crates/ntx-builder/src/lib.rs | 22 ++++++++++++ 4 files changed, 133 insertions(+), 2 deletions(-) diff --git a/crates/ntx-builder/src/actor/mod.rs b/crates/ntx-builder/src/actor/mod.rs index 691fb8ee6..f0a12f347 100644 --- a/crates/ntx-builder/src/actor/mod.rs +++ b/crates/ntx-builder/src/actor/mod.rs @@ -53,6 +53,14 @@ pub enum ActorRequest { }, /// A note script was fetched from the remote store and should be persisted to the local DB. CacheNoteScript { script_root: Word, script: NoteScript }, + /// The actor has been idle (in `NoViableNotes` mode) for longer than the sterility timeout + /// and is requesting to shut down. The builder validates the request against the DB before + /// approving. If approved (ack received), the actor exits. If rejected (`ack_tx` dropped), the + /// actor resumes in `NotesAvailable` mode. + Shutdown { + account_id: NetworkAccountId, + ack_tx: tokio::sync::oneshot::Sender<()>, + }, } // ACTOR SHUTDOWN REASON @@ -68,6 +76,9 @@ pub enum ActorShutdownReason { Cancelled(NetworkAccountId), /// Occurs when the actor encounters a database error it cannot recover from. DbError(NetworkAccountId), + /// Occurs when the actor has been idle for longer than the sterility timeout and the builder + /// has confirmed there are no available notes in the DB. + Sterile(NetworkAccountId), } // ACCOUNT ACTOR CONFIG @@ -95,6 +106,8 @@ pub struct AccountActorContext { pub max_notes_per_tx: NonZeroUsize, /// Maximum number of note execution attempts before dropping a note. pub max_note_attempts: usize, + /// Duration an actor must remain in `NoViableNotes` mode before requesting shutdown. + pub sterility_timeout: Duration, /// Database for persistent state. pub db: Db, /// Channel for sending requests to the coordinator (via the builder event loop). @@ -200,6 +213,8 @@ pub struct AccountActor { max_notes_per_tx: NonZeroUsize, /// Maximum number of note execution attempts before dropping a note. max_note_attempts: usize, + /// Duration an actor must remain in `NoViableNotes` mode before requesting shutdown. + sterility_timeout: Duration, /// Channel for sending requests to the coordinator. request_tx: mpsc::Sender, } @@ -235,6 +250,7 @@ impl AccountActor { script_cache: actor_context.script_cache.clone(), max_notes_per_tx: actor_context.max_notes_per_tx, max_note_attempts: actor_context.max_note_attempts, + sterility_timeout: actor_context.sterility_timeout, request_tx: actor_context.request_tx.clone(), } } @@ -269,6 +285,16 @@ impl AccountActor { // Enable transaction execution. ActorMode::NotesAvailable => semaphore.acquire().boxed(), }; + + // Sterility timer: only ticks when in NoViableNotes mode. + // Mode changes cause the next loop iteration to create a fresh sleep or pending. + let sterility_sleep = match self.mode { + ActorMode::NoViableNotes => { + tokio::time::sleep(self.sterility_timeout).boxed() + }, + _ => std::future::pending().boxed(), + }; + tokio::select! { _ = self.cancel_token.cancelled() => { return ActorShutdownReason::Cancelled(account_id); @@ -325,10 +351,31 @@ impl AccountActor { } } } + // Sterility timeout: actor has been idle too long, request shutdown. + _ = sterility_sleep => { + match self.initiate_shutdown(account_id).await { + Ok(()) => return ActorShutdownReason::Sterile(account_id), + Err(()) => self.mode = ActorMode::NotesAvailable, + } + } } } } + /// Sends a shutdown request to the builder and waits for acknowledgment. + /// + /// Returns `Ok(())` if the builder approved the shutdown (actor should exit). + /// Returns `Err(())` if the builder rejected the shutdown or the channel was dropped + /// (actor should resume as `NotesAvailable`). + async fn initiate_shutdown(&self, account_id: NetworkAccountId) -> Result<(), ()> { + let (ack_tx, ack_rx) = tokio::sync::oneshot::channel(); + self.request_tx + .send(ActorRequest::Shutdown { account_id, ack_tx }) + .await + .map_err(|_| ())?; + ack_rx.await.map_err(|_| ()) + } + /// Selects a transaction candidate by querying the DB. async fn select_candidate_from_db( &self, diff --git a/crates/ntx-builder/src/builder.rs b/crates/ntx-builder/src/builder.rs index 6707cb730..48babbce4 100644 --- a/crates/ntx-builder/src/builder.rs +++ b/crates/ntx-builder/src/builder.rs @@ -211,7 +211,11 @@ impl NetworkTransactionBuilder { } } } - self.coordinator.send_targeted(&event); + let inactive_targets = self.coordinator.send_targeted(&event); + for account_id in inactive_targets { + self.coordinator + .spawn_actor(AccountOrigin::store(account_id), &self.actor_context); + } Ok(()) }, // Update chain state and broadcast. @@ -260,6 +264,17 @@ impl NetworkTransactionBuilder { tracing::error!(err = %err, "failed to cache note script"); } }, + ActorRequest::Shutdown { account_id, ack_tx } => { + let block_num = self.chain_state.read().await.chain_tip_header.block_num(); + self.coordinator + .handle_shutdown_request( + account_id, + block_num, + self.config.max_note_attempts, + ack_tx, + ) + .await; + }, } } diff --git a/crates/ntx-builder/src/coordinator.rs b/crates/ntx-builder/src/coordinator.rs index 2f04d7fdc..9e696fc75 100644 --- a/crates/ntx-builder/src/coordinator.rs +++ b/crates/ntx-builder/src/coordinator.rs @@ -7,6 +7,7 @@ use miden_node_proto::domain::account::NetworkAccountId; use miden_node_proto::domain::mempool::MempoolEvent; use miden_node_proto::domain::note::{NetworkNote, SingleTargetNetworkNote}; use miden_protocol::account::delta::AccountUpdateDetails; +use miden_protocol::block::BlockNumber; use tokio::sync::{Notify, Semaphore}; use tokio::task::JoinSet; use tokio_util::sync::CancellationToken; @@ -166,6 +167,10 @@ impl Coordinator { tracing::error!(account_id = %account_id, "Account actor shut down due to DB error"); Ok(()) }, + ActorShutdownReason::Sterile(account_id) => { + tracing::info!(account_id = %account_id, "Account actor shut down due to sterility"); + Ok(()) + }, }, Some(Err(err)) => { tracing::error!(err = %err, "actor task failed"); @@ -183,8 +188,14 @@ impl Coordinator { /// Only actors that are currently active are notified. Since event effects are already /// persisted in the DB by `write_event()`, actors that spawn later read their state from the /// DB and do not need predating events. - pub fn send_targeted(&self, event: &MempoolEvent) { + /// + /// Returns account IDs of note targets that do not have active actors (e.g. previously + /// deactivated due to sterility). The caller can use this to re-activate actors for those + /// accounts. + pub fn send_targeted(&self, event: &MempoolEvent) -> Vec { let mut target_account_ids = HashSet::new(); + let mut inactive_targets = Vec::new(); + if let MempoolEvent::TransactionAdded { network_notes, account_delta, .. } = event { // We need to inform the account if it was updated. This lets it know that its own // transaction has been applied, and in the future also resolves race conditions with @@ -206,6 +217,8 @@ impl Coordinator { let network_account_id = note.account_id(); if self.actor_registry.contains_key(&network_account_id) { target_account_ids.insert(network_account_id); + } else { + inactive_targets.push(network_account_id); } } } @@ -215,6 +228,8 @@ impl Coordinator { handle.notify.notify_one(); } } + + inactive_targets } /// Writes mempool event effects to the database. @@ -261,6 +276,38 @@ impl Coordinator { } } + /// Handles a shutdown request from an actor that has been idle for longer than the sterility + /// timeout. + /// + /// Validates the request by checking the DB for available notes. If notes are available, the + /// shutdown is rejected by dropping `ack_tx` (the actor detects the `RecvError` and resumes). + /// If no notes are available, the actor is deregistered and the ack is sent, allowing the + /// actor to exit gracefully. + pub async fn handle_shutdown_request( + &mut self, + account_id: NetworkAccountId, + block_num: BlockNumber, + max_note_attempts: usize, + ack_tx: tokio::sync::oneshot::Sender<()>, + ) { + let has_notes = self + .db + .has_available_notes(account_id, block_num, max_note_attempts) + .await + .unwrap_or(false); + + if has_notes { + // Reject: drop ack_tx → actor detects RecvError, resumes. + tracing::debug!( + %account_id, + "Rejected actor shutdown: notes available in DB" + ); + } else { + self.actor_registry.remove(&account_id); + let _ = ack_tx.send(()); + } + } + /// Cancels an actor by its account ID. pub fn cancel_actor(&mut self, account_id: &NetworkAccountId) { if let Some(handle) = self.actor_registry.remove(account_id) { diff --git a/crates/ntx-builder/src/lib.rs b/crates/ntx-builder/src/lib.rs index ca88ec50a..da946d4e4 100644 --- a/crates/ntx-builder/src/lib.rs +++ b/crates/ntx-builder/src/lib.rs @@ -1,6 +1,7 @@ use std::num::NonZeroUsize; use std::path::PathBuf; use std::sync::Arc; +use std::time::Duration; use actor::AccountActorContext; use anyhow::Context; @@ -52,6 +53,10 @@ const DEFAULT_MAX_NOTE_ATTEMPTS: usize = 30; const DEFAULT_SCRIPT_CACHE_SIZE: NonZeroUsize = NonZeroUsize::new(1_000).expect("literal is non-zero"); +/// Default duration an actor must remain idle (in `NoViableNotes` mode) before requesting +/// shutdown. +const DEFAULT_STERILITY_TIMEOUT: Duration = Duration::from_secs(5 * 60); + // CONFIGURATION // ================================================================================================= @@ -93,6 +98,11 @@ pub struct NtxBuilderConfig { /// Channel capacity for loading accounts from the store during startup. pub account_channel_capacity: usize, + /// Duration an actor must remain idle (in `NoViableNotes` mode) before requesting shutdown. + /// When an actor has no viable notes for this duration, it will request to be deactivated + /// to free resources. + pub sterility_timeout: Duration, + /// Path to the SQLite database file used for persistent state. pub database_filepath: PathBuf, } @@ -115,6 +125,7 @@ impl NtxBuilderConfig { max_note_attempts: DEFAULT_MAX_NOTE_ATTEMPTS, max_block_count: DEFAULT_MAX_BLOCK_COUNT, account_channel_capacity: DEFAULT_ACCOUNT_CHANNEL_CAPACITY, + sterility_timeout: DEFAULT_STERILITY_TIMEOUT, database_filepath, } } @@ -180,6 +191,16 @@ impl NtxBuilderConfig { self } + /// Sets the sterility timeout for actors. + /// + /// Actors that remain idle (in `NoViableNotes` mode) for this duration will request to be + /// deactivated. + #[must_use] + pub fn with_sterility_timeout(mut self, timeout: Duration) -> Self { + self.sterility_timeout = timeout; + self + } + /// Builds and initializes the network transaction builder. /// /// This method connects to the store and block producer services, fetches the current @@ -246,6 +267,7 @@ impl NtxBuilderConfig { script_cache, max_notes_per_tx: self.max_notes_per_tx, max_note_attempts: self.max_note_attempts, + sterility_timeout: self.sterility_timeout, db: db.clone(), request_tx, }; From 09a2879549e92a0d21459b7286a080f694ddc025 Mon Sep 17 00:00:00 2001 From: SantiagoPittella Date: Tue, 24 Feb 2026 15:52:22 -0300 Subject: [PATCH 2/6] chore: add tests --- crates/ntx-builder/src/actor/mod.rs | 4 +- crates/ntx-builder/src/coordinator.rs | 119 ++++++++++++++++++ crates/ntx-builder/src/db/mod.rs | 11 ++ .../src/db/models/queries/tests.rs | 87 +------------ crates/ntx-builder/src/lib.rs | 3 + crates/ntx-builder/src/test_utils.rs | 80 ++++++++++++ 6 files changed, 215 insertions(+), 89 deletions(-) create mode 100644 crates/ntx-builder/src/test_utils.rs diff --git a/crates/ntx-builder/src/actor/mod.rs b/crates/ntx-builder/src/actor/mod.rs index f0a12f347..bbaee369e 100644 --- a/crates/ntx-builder/src/actor/mod.rs +++ b/crates/ntx-builder/src/actor/mod.rs @@ -289,9 +289,7 @@ impl AccountActor { // Sterility timer: only ticks when in NoViableNotes mode. // Mode changes cause the next loop iteration to create a fresh sleep or pending. let sterility_sleep = match self.mode { - ActorMode::NoViableNotes => { - tokio::time::sleep(self.sterility_timeout).boxed() - }, + ActorMode::NoViableNotes => tokio::time::sleep(self.sterility_timeout).boxed(), _ => std::future::pending().boxed(), }; diff --git a/crates/ntx-builder/src/coordinator.rs b/crates/ntx-builder/src/coordinator.rs index 9e696fc75..4a93446b6 100644 --- a/crates/ntx-builder/src/coordinator.rs +++ b/crates/ntx-builder/src/coordinator.rs @@ -314,4 +314,123 @@ impl Coordinator { handle.cancel_token.cancel(); } } + + /// Returns `true` if an actor is registered for the given account ID. + #[cfg(test)] + pub fn has_actor(&self, account_id: &NetworkAccountId) -> bool { + self.actor_registry.contains_key(account_id) + } +} + +#[cfg(test)] +mod tests { + use miden_node_proto::domain::mempool::MempoolEvent; + use miden_node_proto::domain::note::NetworkNote; + use miden_protocol::block::BlockNumber; + + use super::*; + use crate::db::Db; + use crate::test_utils::*; + + /// Creates a coordinator with default settings backed by a temp DB. + async fn test_coordinator() -> (Coordinator, tempfile::TempDir) { + let (db, dir) = Db::test_setup().await; + (Coordinator::new(4, db), dir) + } + + /// Registers a dummy actor handle (no real actor task) in the coordinator's registry. + fn register_dummy_actor(coordinator: &mut Coordinator, account_id: NetworkAccountId) { + let notify = Arc::new(Notify::new()); + let cancel_token = CancellationToken::new(); + coordinator + .actor_registry + .insert(account_id, ActorHandle::new(notify, cancel_token)); + } + + // HANDLE SHUTDOWN REQUEST TESTS + // ============================================================================================ + + #[tokio::test] + async fn shutdown_approved_when_no_notes() { + let (mut coordinator, _dir) = test_coordinator().await; + let account_id = mock_network_account_id(); + + register_dummy_actor(&mut coordinator, account_id); + assert!(coordinator.has_actor(&account_id)); + + let (ack_tx, ack_rx) = tokio::sync::oneshot::channel(); + let block_num = BlockNumber::from(1u32); + let max_note_attempts = 30; + + coordinator + .handle_shutdown_request(account_id, block_num, max_note_attempts, ack_tx) + .await; + + // Ack should be received (shutdown approved). + assert!(ack_rx.await.is_ok()); + // Actor should be deregistered. + assert!(!coordinator.has_actor(&account_id)); + } + + #[tokio::test] + async fn shutdown_rejected_when_notes_available() { + let (mut coordinator, _dir) = test_coordinator().await; + let account_id = mock_network_account_id(); + + // Insert a committed note for this account. + let note = mock_single_target_note(account_id, 10); + coordinator + .db + .sync_account_from_store(account_id, mock_account(account_id), vec![note]) + .await + .unwrap(); + + register_dummy_actor(&mut coordinator, account_id); + assert!(coordinator.has_actor(&account_id)); + + let (ack_tx, ack_rx) = tokio::sync::oneshot::channel(); + let block_num = BlockNumber::from(1u32); + let max_note_attempts = 30; + + coordinator + .handle_shutdown_request(account_id, block_num, max_note_attempts, ack_tx) + .await; + + // Ack_tx should have been dropped (shutdown rejected). + assert!(ack_rx.await.is_err()); + // Actor should still be registered. + assert!(coordinator.has_actor(&account_id)); + } + + // SEND TARGETED TESTS + // ============================================================================================ + + #[tokio::test] + async fn send_targeted_returns_inactive_targets() { + let (mut coordinator, _dir) = test_coordinator().await; + + let active_id = mock_network_account_id(); + let inactive_id = mock_network_account_id_seeded(42); + + // Only register the active account. + register_dummy_actor(&mut coordinator, active_id); + + let note_active = mock_single_target_note(active_id, 10); + let note_inactive = mock_single_target_note(inactive_id, 20); + + let event = MempoolEvent::TransactionAdded { + id: mock_tx_id(1), + nullifiers: vec![], + network_notes: vec![ + NetworkNote::SingleTarget(note_active), + NetworkNote::SingleTarget(note_inactive), + ], + account_delta: None, + }; + + let inactive_targets = coordinator.send_targeted(&event); + + assert_eq!(inactive_targets.len(), 1); + assert_eq!(inactive_targets[0], inactive_id); + } } diff --git a/crates/ntx-builder/src/db/mod.rs b/crates/ntx-builder/src/db/mod.rs index aff084241..fa3739494 100644 --- a/crates/ntx-builder/src/db/mod.rs +++ b/crates/ntx-builder/src/db/mod.rs @@ -224,4 +224,15 @@ impl Db { apply_migrations(&mut conn).expect("migrations should apply on empty database"); (conn, dir) } + + /// Creates an async `Db` instance backed by a temp file for testing. + /// + /// Returns `(Db, TempDir)` — the `TempDir` must be kept alive for the DB's lifetime. + #[cfg(test)] + pub async fn test_setup() -> (Db, tempfile::TempDir) { + let dir = tempfile::tempdir().expect("failed to create temp directory"); + let db_path = dir.path().join("test.sqlite3"); + let db = Db::setup(db_path).await.expect("test DB setup should succeed"); + (db, dir) + } } diff --git a/crates/ntx-builder/src/db/models/queries/tests.rs b/crates/ntx-builder/src/db/models/queries/tests.rs index 0db95c018..8967cbcfd 100644 --- a/crates/ntx-builder/src/db/models/queries/tests.rs +++ b/crates/ntx-builder/src/db/models/queries/tests.rs @@ -1,25 +1,13 @@ //! DB-level tests for NTX builder query functions. use diesel::prelude::*; -use miden_node_proto::domain::account::NetworkAccountId; -use miden_node_proto::domain::note::SingleTargetNetworkNote; use miden_protocol::Word; -use miden_protocol::account::{AccountId, AccountStorageMode, AccountType}; use miden_protocol::block::BlockNumber; -use miden_protocol::note::NoteExecutionHint; -use miden_protocol::testing::account_id::{ - ACCOUNT_ID_REGULAR_NETWORK_ACCOUNT_IMMUTABLE_CODE, - AccountIdBuilder, -}; -use miden_protocol::transaction::TransactionId; -use miden_standards::note::NetworkAccountTarget; -use miden_standards::testing::note::NoteBuilder; -use rand_chacha::ChaCha20Rng; -use rand_chacha::rand_core::SeedableRng; use super::*; use crate::db::models::conv as conversions; use crate::db::{Db, schema}; +use crate::test_utils::*; // TEST HELPERS // ================================================================================================ @@ -29,47 +17,6 @@ fn test_conn() -> (SqliteConnection, tempfile::TempDir) { Db::test_conn() } -/// Creates a network account ID from a test constant. -fn mock_network_account_id() -> NetworkAccountId { - let account_id: AccountId = - ACCOUNT_ID_REGULAR_NETWORK_ACCOUNT_IMMUTABLE_CODE.try_into().unwrap(); - NetworkAccountId::try_from(account_id).unwrap() -} - -/// Creates a distinct network account ID using a seeded RNG. -fn mock_network_account_id_seeded(seed: u8) -> NetworkAccountId { - let account_id = AccountIdBuilder::new() - .account_type(AccountType::RegularAccountImmutableCode) - .storage_mode(AccountStorageMode::Network) - .build_with_seed([seed; 32]); - NetworkAccountId::try_from(account_id).unwrap() -} - -/// Creates a unique `TransactionId` from a seed value. -fn mock_tx_id(seed: u64) -> TransactionId { - let w = |n: u64| Word::try_from([n, 0, 0, 0]).unwrap(); - TransactionId::new(w(seed), w(seed + 1), w(seed + 2), w(seed + 3)) -} - -/// Creates a `SingleTargetNetworkNote` targeting the given network account. -fn mock_single_target_note( - network_account_id: NetworkAccountId, - seed: u8, -) -> SingleTargetNetworkNote { - let mut rng = ChaCha20Rng::from_seed([seed; 32]); - let sender = AccountIdBuilder::new() - .account_type(AccountType::RegularAccountImmutableCode) - .storage_mode(AccountStorageMode::Private) - .build_with_rng(&mut rng); - - let target = NetworkAccountTarget::new(network_account_id.inner(), NoteExecutionHint::Always) - .expect("network account should be valid target"); - - let note = NoteBuilder::new(sender, rng).attachment(target).build().unwrap(); - - SingleTargetNetworkNote::try_from(note).expect("note should be single-target network note") -} - /// Counts the total number of rows in the `notes` table. fn count_notes(conn: &mut SqliteConnection) -> i64 { schema::notes::table.count().get_result(conn).unwrap() @@ -528,35 +475,3 @@ fn note_script_insert_is_idempotent() { let found = lookup_note_script(conn, &root).unwrap(); assert!(found.is_some()); } - -// HELPERS (domain type construction) -// ================================================================================================ - -/// Creates a mock `Account` for a network account. -/// -/// Uses `AccountBuilder` with minimal components needed for serialization. -fn mock_account(_account_id: NetworkAccountId) -> miden_protocol::account::Account { - use miden_protocol::account::auth::PublicKeyCommitment; - use miden_protocol::account::{AccountBuilder, AccountComponent}; - use miden_standards::account::auth::AuthFalcon512Rpo; - - let component_code = miden_standards::code_builder::CodeBuilder::default() - .compile_component_code("test::interface", "pub proc test_proc push.1.2 add end") - .unwrap(); - - let component = - AccountComponent::new(component_code, vec![]).unwrap().with_supports_all_types(); - - AccountBuilder::new([0u8; 32]) - .account_type(AccountType::RegularAccountImmutableCode) - .storage_mode(AccountStorageMode::Network) - .with_component(component) - .with_auth_component(AuthFalcon512Rpo::new(PublicKeyCommitment::from(Word::default()))) - .build_existing() - .unwrap() -} - -/// Creates a mock `BlockHeader` for the given block number. -fn mock_block_header(block_num: BlockNumber) -> miden_protocol::block::BlockHeader { - miden_protocol::block::BlockHeader::mock(block_num, None, None, &[], Word::default()) -} diff --git a/crates/ntx-builder/src/lib.rs b/crates/ntx-builder/src/lib.rs index da946d4e4..bf082dfd7 100644 --- a/crates/ntx-builder/src/lib.rs +++ b/crates/ntx-builder/src/lib.rs @@ -23,6 +23,9 @@ mod coordinator; pub(crate) mod db; pub(crate) mod inflight_note; +#[cfg(test)] +pub(crate) mod test_utils; + pub use builder::NetworkTransactionBuilder; // CONSTANTS diff --git a/crates/ntx-builder/src/test_utils.rs b/crates/ntx-builder/src/test_utils.rs new file mode 100644 index 000000000..fb7636d03 --- /dev/null +++ b/crates/ntx-builder/src/test_utils.rs @@ -0,0 +1,80 @@ +//! Shared test helpers for the NTX builder crate. + +use miden_node_proto::domain::account::NetworkAccountId; +use miden_node_proto::domain::note::SingleTargetNetworkNote; +use miden_protocol::Word; +use miden_protocol::account::{AccountId, AccountStorageMode, AccountType}; +use miden_protocol::block::BlockNumber; +use miden_protocol::note::NoteExecutionHint; +use miden_protocol::testing::account_id::{ + ACCOUNT_ID_REGULAR_NETWORK_ACCOUNT_IMMUTABLE_CODE, + AccountIdBuilder, +}; +use miden_protocol::transaction::TransactionId; +use miden_standards::note::NetworkAccountTarget; +use miden_standards::testing::note::NoteBuilder; +use rand_chacha::ChaCha20Rng; +use rand_chacha::rand_core::SeedableRng; + +/// Creates a network account ID from a test constant. +pub fn mock_network_account_id() -> NetworkAccountId { + let account_id: AccountId = + ACCOUNT_ID_REGULAR_NETWORK_ACCOUNT_IMMUTABLE_CODE.try_into().unwrap(); + NetworkAccountId::try_from(account_id).unwrap() +} + +/// Creates a distinct network account ID using a seeded RNG. +pub fn mock_network_account_id_seeded(seed: u8) -> NetworkAccountId { + let account_id = AccountIdBuilder::new() + .account_type(AccountType::RegularAccountImmutableCode) + .storage_mode(AccountStorageMode::Network) + .build_with_seed([seed; 32]); + NetworkAccountId::try_from(account_id).unwrap() +} + +/// Creates a unique `TransactionId` from a seed value. +pub fn mock_tx_id(seed: u64) -> TransactionId { + let w = |n: u64| Word::try_from([n, 0, 0, 0]).unwrap(); + TransactionId::new(w(seed), w(seed + 1), w(seed + 2), w(seed + 3)) +} + +/// Creates a `SingleTargetNetworkNote` targeting the given network account. +pub fn mock_single_target_note( + network_account_id: NetworkAccountId, + seed: u8, +) -> SingleTargetNetworkNote { + let mut rng = ChaCha20Rng::from_seed([seed; 32]); + let sender = AccountIdBuilder::new() + .account_type(AccountType::RegularAccountImmutableCode) + .storage_mode(AccountStorageMode::Private) + .build_with_rng(&mut rng); + + let target = NetworkAccountTarget::new(network_account_id.inner(), NoteExecutionHint::Always) + .expect("network account should be valid target"); + + let note = NoteBuilder::new(sender, rng).attachment(target).build().unwrap(); + + SingleTargetNetworkNote::try_from(note).expect("note should be single-target network note") +} + +/// Creates a mock `Account` for a network account. +/// +/// Uses `AccountBuilder` with minimal components needed for serialization. +pub fn mock_account(_account_id: NetworkAccountId) -> miden_protocol::account::Account { + use miden_protocol::account::AccountBuilder; + use miden_protocol::testing::noop_auth_component::NoopAuthComponent; + use miden_standards::testing::account_component::MockAccountComponent; + + AccountBuilder::new([0u8; 32]) + .account_type(AccountType::RegularAccountImmutableCode) + .storage_mode(AccountStorageMode::Network) + .with_component(MockAccountComponent::with_slots(vec![])) + .with_auth_component(NoopAuthComponent) + .build_existing() + .unwrap() +} + +/// Creates a mock `BlockHeader` for the given block number. +pub fn mock_block_header(block_num: BlockNumber) -> miden_protocol::block::BlockHeader { + miden_protocol::block::BlockHeader::mock(block_num, None, None, &[], Word::default()) +} From daf4bcad01a55aaa1ea080051e68358691ef39f0 Mon Sep 17 00:00:00 2001 From: SantiagoPittella Date: Tue, 24 Feb 2026 16:45:14 -0300 Subject: [PATCH 3/6] update docs & add configuration to binary --- bin/node/src/commands/mod.rs | 14 +++++++++++++ bin/node/src/main.rs | 2 +- crates/ntx-builder/src/coordinator.rs | 10 ++++++++- docs/external/src/operator/architecture.md | 5 +++++ docs/internal/src/ntx-builder.md | 24 ++++++++++++++++------ 5 files changed, 47 insertions(+), 8 deletions(-) diff --git a/bin/node/src/commands/mod.rs b/bin/node/src/commands/mod.rs index 352a6de16..b6ac9fa84 100644 --- a/bin/node/src/commands/mod.rs +++ b/bin/node/src/commands/mod.rs @@ -43,6 +43,7 @@ const ENV_NTX_SCRIPT_CACHE_SIZE: &str = "MIDEN_NTX_DATA_STORE_SCRIPT_CACHE_SIZE" const ENV_VALIDATOR_KEY: &str = "MIDEN_NODE_VALIDATOR_KEY"; const DEFAULT_NTX_TICKER_INTERVAL: Duration = Duration::from_millis(200); +const DEFAULT_NTX_STERILITY_TIMEOUT: Duration = Duration::from_secs(5 * 60); const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); const DEFAULT_NTX_SCRIPT_CACHE_SIZE: NonZeroUsize = NonZeroUsize::new(1000).unwrap(); @@ -125,6 +126,18 @@ pub struct NtxBuilderConfig { )] pub script_cache_size: NonZeroUsize, + /// Duration an actor must remain idle before requesting shutdown. + /// + /// When an actor has no viable notes for this duration, it will be deactivated to free + /// resources. It will be re-activated when new notes target it. + #[arg( + long = "ntx-builder.sterility-timeout", + default_value = &duration_to_human_readable_string(DEFAULT_NTX_STERILITY_TIMEOUT), + value_parser = humantime::parse_duration, + value_name = "DURATION" + )] + pub sterility_timeout: Duration, + /// Directory for the ntx-builder's persistent database. /// /// If not set, defaults to the node's data directory. @@ -155,6 +168,7 @@ impl NtxBuilderConfig { ) .with_tx_prover_url(self.tx_prover_url) .with_script_cache_size(self.script_cache_size) + .with_sterility_timeout(self.sterility_timeout) } } diff --git a/bin/node/src/main.rs b/bin/node/src/main.rs index be4b0d4ae..2a7ce7335 100644 --- a/bin/node/src/main.rs +++ b/bin/node/src/main.rs @@ -39,7 +39,7 @@ pub enum Command { /// /// This is the recommended way to run the node at the moment. #[command(subcommand)] - Bundled(commands::bundled::BundledCommand), + Bundled(Box), } impl Command { diff --git a/crates/ntx-builder/src/coordinator.rs b/crates/ntx-builder/src/coordinator.rs index 4a93446b6..c218a187e 100644 --- a/crates/ntx-builder/src/coordinator.rs +++ b/crates/ntx-builder/src/coordinator.rs @@ -57,6 +57,14 @@ impl ActorHandle { /// - Controls transaction concurrency across all network accounts using a semaphore. /// - Prevents resource exhaustion by limiting simultaneous transaction processing. /// +/// ## Actor Lifecycle +/// - Actors that have been idle for longer than the sterility timeout request shutdown from the +/// coordinator. +/// - The coordinator validates shutdown requests against the DB: if notes are still available for +/// the account, the request is rejected and the actor resumes processing. +/// - Deactivated actors are re-spawned when [`Coordinator::send_targeted`] detects notes targeting +/// an account without an active actor. +/// /// The coordinator operates in an event-driven manner: /// 1. Network accounts are registered and actors spawned as needed. /// 2. Mempool events are written to DB, then actors are notified. @@ -297,7 +305,7 @@ impl Coordinator { .unwrap_or(false); if has_notes { - // Reject: drop ack_tx → actor detects RecvError, resumes. + // Reject: drop ack_tx -> actor detects RecvError, resumes. tracing::debug!( %account_id, "Rejected actor shutdown: notes available in DB" diff --git a/docs/external/src/operator/architecture.md b/docs/external/src/operator/architecture.md index 9bc9ac0a3..f39696246 100644 --- a/docs/external/src/operator/architecture.md +++ b/docs/external/src/operator/architecture.md @@ -53,3 +53,8 @@ This restriction is will be lifted in the future, but for now this component _mu network transactions. The mempool is monitored via a gRPC event stream served by the block-producer. + +Internally, the builder spawns a dedicated actor for each network account that has pending notes. Actors that remain +idle (no notes to consume) for a configurable duration are automatically deactivated to conserve resources, and are +re-activated when new notes arrive. The idle timeout can be tuned with the `--ntx-builder.sterility-timeout` CLI +argument (default: 5 minutes). diff --git a/docs/internal/src/ntx-builder.md b/docs/internal/src/ntx-builder.md index 5ed9b4c82..75fe388c8 100644 --- a/docs/internal/src/ntx-builder.md +++ b/docs/internal/src/ntx-builder.md @@ -34,12 +34,24 @@ definitions of network accounts, notes and transactions mature. ## Implementation -On startup the mempool loads all unconsumed network notes from the store. From there it monitors -the mempool for events which would impact network account state. This communication takes the form -of an event stream via gRPC. - -The NTB periodically selects an arbitrary network account with available network notes and creates -a network transaction for it. +The NTB uses an actor-per-account model managed by a central `Coordinator`. On startup the +coordinator syncs all known network accounts and their unconsumed notes from the store. It then +monitors the mempool for events (via a gRPC event stream from the block-producer) which would +impact network account state. + +For each network account that has available notes, the coordinator spawns a dedicated +`AccountActor`. Each actor runs in its own async task and is responsible for creating transactions +that consume network notes targeting its account. Actors read their state from the database and +re-evaluate whenever notified by the coordinator. + +Actors that have been idle (no available notes to consume) for longer than the **sterility +timeout** will request shutdown from the coordinator. The coordinator validates the request against +the database before approving: if notes are still available, the request is rejected and the actor +resumes. The sterility timeout is configurable via the `--ntx-builder.sterility-timeout` CLI +argument (default: 5 minutes). + +Deactivated actors are re-spawned when new notes targeting their account are detected by the +coordinator (via the `send_targeted` path). The block-producer remains blissfully unaware of network transactions. From its perspective a network transaction is simply the same as any other. From ac9086b2325167b10e30c88602aa004b03fe64a6 Mon Sep 17 00:00:00 2001 From: SantiagoPittella Date: Tue, 24 Feb 2026 16:47:36 -0300 Subject: [PATCH 4/6] add changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0e369a9e..8e679e1ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ - Improved tracing span fields ([#1650](https://github.com/0xMiden/miden-node/pull/1650)) - Replaced NTX Builder's in-memory state management with SQLite-backed persistence; account states, notes, and transaction effects are now stored in the database and inflight state is purged on startup ([#1662](https://github.com/0xMiden/miden-node/pull/1662)). - [BREAKING] Reworked `miden-remote-prover`, removing the `worker`/`proxy` distinction and simplifying to a `worker` with a request queue ([#1688](https://github.com/0xMiden/miden-node/pull/1688)). +- NTX Builder actors now deactivate after being idle for a configurable sterility timeout (`--ntx-builder.sterility-timeout`, default 5 min) and are re-activated when new notes target their account ([#1705](https://github.com/0xMiden/node/pull/1705)). ## v0.13.5 (2026-02-19) From 24e1be3d6315dba9100d19638ee97db65903cf75 Mon Sep 17 00:00:00 2001 From: SantiagoPittella Date: Wed, 25 Feb 2026 12:29:36 -0300 Subject: [PATCH 5/6] review: rename sterile to idle --- CHANGELOG.md | 2 +- bin/node/src/commands/mod.rs | 16 ++++++------- crates/ntx-builder/src/actor/mod.rs | 28 +++++++++++----------- crates/ntx-builder/src/coordinator.rs | 8 +++---- crates/ntx-builder/src/lib.rs | 27 ++++++++++----------- docs/external/src/operator/architecture.md | 2 +- docs/internal/src/ntx-builder.md | 6 ++--- 7 files changed, 43 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e679e1ac..a8b66a949 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,7 @@ - Improved tracing span fields ([#1650](https://github.com/0xMiden/miden-node/pull/1650)) - Replaced NTX Builder's in-memory state management with SQLite-backed persistence; account states, notes, and transaction effects are now stored in the database and inflight state is purged on startup ([#1662](https://github.com/0xMiden/miden-node/pull/1662)). - [BREAKING] Reworked `miden-remote-prover`, removing the `worker`/`proxy` distinction and simplifying to a `worker` with a request queue ([#1688](https://github.com/0xMiden/miden-node/pull/1688)). -- NTX Builder actors now deactivate after being idle for a configurable sterility timeout (`--ntx-builder.sterility-timeout`, default 5 min) and are re-activated when new notes target their account ([#1705](https://github.com/0xMiden/node/pull/1705)). +- NTX Builder actors now deactivate after being idle for a configurable idle timeout (`--ntx-builder.idle-timeout`, default 5 min) and are re-activated when new notes target their account ([#1705](https://github.com/0xMiden/miden-node/pull/1705)). ## v0.13.5 (2026-02-19) diff --git a/bin/node/src/commands/mod.rs b/bin/node/src/commands/mod.rs index b6ac9fa84..6a63f1ed2 100644 --- a/bin/node/src/commands/mod.rs +++ b/bin/node/src/commands/mod.rs @@ -43,7 +43,7 @@ const ENV_NTX_SCRIPT_CACHE_SIZE: &str = "MIDEN_NTX_DATA_STORE_SCRIPT_CACHE_SIZE" const ENV_VALIDATOR_KEY: &str = "MIDEN_NODE_VALIDATOR_KEY"; const DEFAULT_NTX_TICKER_INTERVAL: Duration = Duration::from_millis(200); -const DEFAULT_NTX_STERILITY_TIMEOUT: Duration = Duration::from_secs(5 * 60); +const DEFAULT_NTX_IDLE_TIMEOUT: Duration = Duration::from_secs(5 * 60); const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); const DEFAULT_NTX_SCRIPT_CACHE_SIZE: NonZeroUsize = NonZeroUsize::new(1000).unwrap(); @@ -126,17 +126,17 @@ pub struct NtxBuilderConfig { )] pub script_cache_size: NonZeroUsize, - /// Duration an actor must remain idle before requesting shutdown. + /// Duration after which an idle network account will deactivate. /// - /// When an actor has no viable notes for this duration, it will be deactivated to free - /// resources. It will be re-activated when new notes target it. + /// An account is considered idle once it has no viable notes to consume. + /// A deactivated account will reactivate if targeted with new notes. #[arg( - long = "ntx-builder.sterility-timeout", - default_value = &duration_to_human_readable_string(DEFAULT_NTX_STERILITY_TIMEOUT), + long = "ntx-builder.idle-timeout", + default_value = &duration_to_human_readable_string(DEFAULT_NTX_IDLE_TIMEOUT), value_parser = humantime::parse_duration, value_name = "DURATION" )] - pub sterility_timeout: Duration, + pub idle_timeout: Duration, /// Directory for the ntx-builder's persistent database. /// @@ -168,7 +168,7 @@ impl NtxBuilderConfig { ) .with_tx_prover_url(self.tx_prover_url) .with_script_cache_size(self.script_cache_size) - .with_sterility_timeout(self.sterility_timeout) + .with_idle_timeout(self.idle_timeout) } } diff --git a/crates/ntx-builder/src/actor/mod.rs b/crates/ntx-builder/src/actor/mod.rs index bbaee369e..8c0578275 100644 --- a/crates/ntx-builder/src/actor/mod.rs +++ b/crates/ntx-builder/src/actor/mod.rs @@ -53,7 +53,7 @@ pub enum ActorRequest { }, /// A note script was fetched from the remote store and should be persisted to the local DB. CacheNoteScript { script_root: Word, script: NoteScript }, - /// The actor has been idle (in `NoViableNotes` mode) for longer than the sterility timeout + /// The actor has been idle (in `NoViableNotes` mode) for longer than the idle timeout /// and is requesting to shut down. The builder validates the request against the DB before /// approving. If approved (ack received), the actor exits. If rejected (`ack_tx` dropped), the /// actor resumes in `NotesAvailable` mode. @@ -76,9 +76,9 @@ pub enum ActorShutdownReason { Cancelled(NetworkAccountId), /// Occurs when the actor encounters a database error it cannot recover from. DbError(NetworkAccountId), - /// Occurs when the actor has been idle for longer than the sterility timeout and the builder + /// Occurs when the actor has been idle for longer than the idle timeout and the builder /// has confirmed there are no available notes in the DB. - Sterile(NetworkAccountId), + IdleTimeout(NetworkAccountId), } // ACCOUNT ACTOR CONFIG @@ -106,8 +106,8 @@ pub struct AccountActorContext { pub max_notes_per_tx: NonZeroUsize, /// Maximum number of note execution attempts before dropping a note. pub max_note_attempts: usize, - /// Duration an actor must remain in `NoViableNotes` mode before requesting shutdown. - pub sterility_timeout: Duration, + /// Duration after which an idle actor will deactivate. + pub idle_timeout: Duration, /// Database for persistent state. pub db: Db, /// Channel for sending requests to the coordinator (via the builder event loop). @@ -213,8 +213,8 @@ pub struct AccountActor { max_notes_per_tx: NonZeroUsize, /// Maximum number of note execution attempts before dropping a note. max_note_attempts: usize, - /// Duration an actor must remain in `NoViableNotes` mode before requesting shutdown. - sterility_timeout: Duration, + /// Duration after which an idle actor will deactivate. + idle_timeout: Duration, /// Channel for sending requests to the coordinator. request_tx: mpsc::Sender, } @@ -250,7 +250,7 @@ impl AccountActor { script_cache: actor_context.script_cache.clone(), max_notes_per_tx: actor_context.max_notes_per_tx, max_note_attempts: actor_context.max_note_attempts, - sterility_timeout: actor_context.sterility_timeout, + idle_timeout: actor_context.idle_timeout, request_tx: actor_context.request_tx.clone(), } } @@ -286,10 +286,10 @@ impl AccountActor { ActorMode::NotesAvailable => semaphore.acquire().boxed(), }; - // Sterility timer: only ticks when in NoViableNotes mode. + // Idle timeout timer: only ticks when in NoViableNotes mode. // Mode changes cause the next loop iteration to create a fresh sleep or pending. - let sterility_sleep = match self.mode { - ActorMode::NoViableNotes => tokio::time::sleep(self.sterility_timeout).boxed(), + let idle_timeout_sleep = match self.mode { + ActorMode::NoViableNotes => tokio::time::sleep(self.idle_timeout).boxed(), _ => std::future::pending().boxed(), }; @@ -349,10 +349,10 @@ impl AccountActor { } } } - // Sterility timeout: actor has been idle too long, request shutdown. - _ = sterility_sleep => { + // Idle timeout: actor has been idle too long, request shutdown. + _ = idle_timeout_sleep => { match self.initiate_shutdown(account_id).await { - Ok(()) => return ActorShutdownReason::Sterile(account_id), + Ok(()) => return ActorShutdownReason::IdleTimeout(account_id), Err(()) => self.mode = ActorMode::NotesAvailable, } } diff --git a/crates/ntx-builder/src/coordinator.rs b/crates/ntx-builder/src/coordinator.rs index c218a187e..ed1dcbdeb 100644 --- a/crates/ntx-builder/src/coordinator.rs +++ b/crates/ntx-builder/src/coordinator.rs @@ -58,7 +58,7 @@ impl ActorHandle { /// - Prevents resource exhaustion by limiting simultaneous transaction processing. /// /// ## Actor Lifecycle -/// - Actors that have been idle for longer than the sterility timeout request shutdown from the +/// - Actors that have been idle for longer than the idle timeout request shutdown from the /// coordinator. /// - The coordinator validates shutdown requests against the DB: if notes are still available for /// the account, the request is rejected and the actor resumes processing. @@ -175,8 +175,8 @@ impl Coordinator { tracing::error!(account_id = %account_id, "Account actor shut down due to DB error"); Ok(()) }, - ActorShutdownReason::Sterile(account_id) => { - tracing::info!(account_id = %account_id, "Account actor shut down due to sterility"); + ActorShutdownReason::IdleTimeout(account_id) => { + tracing::info!(account_id = %account_id, "Account actor shut down due to idle timeout"); Ok(()) }, }, @@ -284,7 +284,7 @@ impl Coordinator { } } - /// Handles a shutdown request from an actor that has been idle for longer than the sterility + /// Handles a shutdown request from an actor that has been idle for longer than the idle /// timeout. /// /// Validates the request by checking the DB for available notes. If notes are available, the diff --git a/crates/ntx-builder/src/lib.rs b/crates/ntx-builder/src/lib.rs index bf082dfd7..f91a6a3e8 100644 --- a/crates/ntx-builder/src/lib.rs +++ b/crates/ntx-builder/src/lib.rs @@ -56,9 +56,8 @@ const DEFAULT_MAX_NOTE_ATTEMPTS: usize = 30; const DEFAULT_SCRIPT_CACHE_SIZE: NonZeroUsize = NonZeroUsize::new(1_000).expect("literal is non-zero"); -/// Default duration an actor must remain idle (in `NoViableNotes` mode) before requesting -/// shutdown. -const DEFAULT_STERILITY_TIMEOUT: Duration = Duration::from_secs(5 * 60); +/// Default duration after which an idle network account actor will deactivate. +const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_secs(5 * 60); // CONFIGURATION // ================================================================================================= @@ -101,10 +100,11 @@ pub struct NtxBuilderConfig { /// Channel capacity for loading accounts from the store during startup. pub account_channel_capacity: usize, - /// Duration an actor must remain idle (in `NoViableNotes` mode) before requesting shutdown. - /// When an actor has no viable notes for this duration, it will request to be deactivated - /// to free resources. - pub sterility_timeout: Duration, + /// Duration after which an idle network account will deactivate. + /// + /// An account is considered idle once it has no viable notes to consume. + /// A deactivated account will reactivate if targeted with new notes. + pub idle_timeout: Duration, /// Path to the SQLite database file used for persistent state. pub database_filepath: PathBuf, @@ -128,7 +128,7 @@ impl NtxBuilderConfig { max_note_attempts: DEFAULT_MAX_NOTE_ATTEMPTS, max_block_count: DEFAULT_MAX_BLOCK_COUNT, account_channel_capacity: DEFAULT_ACCOUNT_CHANNEL_CAPACITY, - sterility_timeout: DEFAULT_STERILITY_TIMEOUT, + idle_timeout: DEFAULT_IDLE_TIMEOUT, database_filepath, } } @@ -194,13 +194,12 @@ impl NtxBuilderConfig { self } - /// Sets the sterility timeout for actors. + /// Sets the idle timeout for actors. /// - /// Actors that remain idle (in `NoViableNotes` mode) for this duration will request to be - /// deactivated. + /// Actors that remain idle (no viable notes) for this duration will be deactivated. #[must_use] - pub fn with_sterility_timeout(mut self, timeout: Duration) -> Self { - self.sterility_timeout = timeout; + pub fn with_idle_timeout(mut self, timeout: Duration) -> Self { + self.idle_timeout = timeout; self } @@ -270,7 +269,7 @@ impl NtxBuilderConfig { script_cache, max_notes_per_tx: self.max_notes_per_tx, max_note_attempts: self.max_note_attempts, - sterility_timeout: self.sterility_timeout, + idle_timeout: self.idle_timeout, db: db.clone(), request_tx, }; diff --git a/docs/external/src/operator/architecture.md b/docs/external/src/operator/architecture.md index f39696246..674507752 100644 --- a/docs/external/src/operator/architecture.md +++ b/docs/external/src/operator/architecture.md @@ -56,5 +56,5 @@ The mempool is monitored via a gRPC event stream served by the block-producer. Internally, the builder spawns a dedicated actor for each network account that has pending notes. Actors that remain idle (no notes to consume) for a configurable duration are automatically deactivated to conserve resources, and are -re-activated when new notes arrive. The idle timeout can be tuned with the `--ntx-builder.sterility-timeout` CLI +re-activated when new notes arrive. The idle timeout can be tuned with the `--ntx-builder.idle-timeout` CLI argument (default: 5 minutes). diff --git a/docs/internal/src/ntx-builder.md b/docs/internal/src/ntx-builder.md index 75fe388c8..a662f7658 100644 --- a/docs/internal/src/ntx-builder.md +++ b/docs/internal/src/ntx-builder.md @@ -44,10 +44,8 @@ For each network account that has available notes, the coordinator spawns a dedi that consume network notes targeting its account. Actors read their state from the database and re-evaluate whenever notified by the coordinator. -Actors that have been idle (no available notes to consume) for longer than the **sterility -timeout** will request shutdown from the coordinator. The coordinator validates the request against -the database before approving: if notes are still available, the request is rejected and the actor -resumes. The sterility timeout is configurable via the `--ntx-builder.sterility-timeout` CLI +Actors that have been idle (no available notes to consume) for longer than the **idle timeout** +will be deactivated. The idle timeout is configurable via the `--ntx-builder.idle-timeout` CLI argument (default: 5 minutes). Deactivated actors are re-spawned when new notes targeting their account are detected by the From 75f97f5493ea646698c5e5b019bc276a372e0cbc Mon Sep 17 00:00:00 2001 From: SantiagoPittella Date: Wed, 25 Feb 2026 13:18:43 -0300 Subject: [PATCH 6/6] review: remove coordinator confirmation for actor shutdown --- crates/ntx-builder/src/actor/mod.rs | 29 +----- crates/ntx-builder/src/builder.rs | 18 ++-- crates/ntx-builder/src/coordinator.rs | 125 +++++--------------------- 3 files changed, 28 insertions(+), 144 deletions(-) diff --git a/crates/ntx-builder/src/actor/mod.rs b/crates/ntx-builder/src/actor/mod.rs index 8c0578275..479ee254e 100644 --- a/crates/ntx-builder/src/actor/mod.rs +++ b/crates/ntx-builder/src/actor/mod.rs @@ -53,14 +53,6 @@ pub enum ActorRequest { }, /// A note script was fetched from the remote store and should be persisted to the local DB. CacheNoteScript { script_root: Word, script: NoteScript }, - /// The actor has been idle (in `NoViableNotes` mode) for longer than the idle timeout - /// and is requesting to shut down. The builder validates the request against the DB before - /// approving. If approved (ack received), the actor exits. If rejected (`ack_tx` dropped), the - /// actor resumes in `NotesAvailable` mode. - Shutdown { - account_id: NetworkAccountId, - ack_tx: tokio::sync::oneshot::Sender<()>, - }, } // ACTOR SHUTDOWN REASON @@ -349,31 +341,14 @@ impl AccountActor { } } } - // Idle timeout: actor has been idle too long, request shutdown. + // Idle timeout: actor has been idle too long, deactivate account. _ = idle_timeout_sleep => { - match self.initiate_shutdown(account_id).await { - Ok(()) => return ActorShutdownReason::IdleTimeout(account_id), - Err(()) => self.mode = ActorMode::NotesAvailable, - } + return ActorShutdownReason::IdleTimeout(account_id); } } } } - /// Sends a shutdown request to the builder and waits for acknowledgment. - /// - /// Returns `Ok(())` if the builder approved the shutdown (actor should exit). - /// Returns `Err(())` if the builder rejected the shutdown or the channel was dropped - /// (actor should resume as `NotesAvailable`). - async fn initiate_shutdown(&self, account_id: NetworkAccountId) -> Result<(), ()> { - let (ack_tx, ack_rx) = tokio::sync::oneshot::channel(); - self.request_tx - .send(ActorRequest::Shutdown { account_id, ack_tx }) - .await - .map_err(|_| ())?; - ack_rx.await.map_err(|_| ()) - } - /// Selects a transaction candidate by querying the DB. async fn select_candidate_from_db( &self, diff --git a/crates/ntx-builder/src/builder.rs b/crates/ntx-builder/src/builder.rs index 48babbce4..b3f5e44a9 100644 --- a/crates/ntx-builder/src/builder.rs +++ b/crates/ntx-builder/src/builder.rs @@ -112,9 +112,12 @@ impl NetworkTransactionBuilder { // Main event loop. loop { tokio::select! { - // Handle actor result. + // Handle actor result. If a timed-out actor needs respawning, do so. result = self.coordinator.next() => { - result?; + if let Some(account_id) = result? { + self.coordinator + .spawn_actor(AccountOrigin::store(account_id), &self.actor_context); + } }, // Handle mempool events. event = self.mempool_events.next() => { @@ -264,17 +267,6 @@ impl NetworkTransactionBuilder { tracing::error!(err = %err, "failed to cache note script"); } }, - ActorRequest::Shutdown { account_id, ack_tx } => { - let block_num = self.chain_state.read().await.chain_tip_header.block_num(); - self.coordinator - .handle_shutdown_request( - account_id, - block_num, - self.config.max_note_attempts, - ack_tx, - ) - .await; - }, } } diff --git a/crates/ntx-builder/src/coordinator.rs b/crates/ntx-builder/src/coordinator.rs index ed1dcbdeb..175e04915 100644 --- a/crates/ntx-builder/src/coordinator.rs +++ b/crates/ntx-builder/src/coordinator.rs @@ -7,7 +7,6 @@ use miden_node_proto::domain::account::NetworkAccountId; use miden_node_proto::domain::mempool::MempoolEvent; use miden_node_proto::domain::note::{NetworkNote, SingleTargetNetworkNote}; use miden_protocol::account::delta::AccountUpdateDetails; -use miden_protocol::block::BlockNumber; use tokio::sync::{Notify, Semaphore}; use tokio::task::JoinSet; use tokio_util::sync::CancellationToken; @@ -58,10 +57,9 @@ impl ActorHandle { /// - Prevents resource exhaustion by limiting simultaneous transaction processing. /// /// ## Actor Lifecycle -/// - Actors that have been idle for longer than the idle timeout request shutdown from the -/// coordinator. -/// - The coordinator validates shutdown requests against the DB: if notes are still available for -/// the account, the request is rejected and the actor resumes processing. +/// - Actors that have been idle for longer than the idle timeout deactivate themselves. +/// - When an actor deactivates, the coordinator checks if a notification arrived just as the actor +/// timed out. If so, the actor is respawned immediately. /// - Deactivated actors are re-spawned when [`Coordinator::send_targeted`] detects notes targeting /// an account without an active actor. /// @@ -160,7 +158,10 @@ impl Coordinator { /// /// If no actors are currently running, this method will wait indefinitely until /// new actors are spawned. This prevents busy-waiting when the coordinator is idle. - pub async fn next(&mut self) -> anyhow::Result<()> { + /// + /// Returns `Some(account_id)` if a timed-out actor should be respawned (because a + /// notification arrived just as it timed out), or `None` otherwise. + pub async fn next(&mut self) -> anyhow::Result> { let actor_result = self.actor_join_set.join_next().await; match actor_result { Some(Ok(shutdown_reason)) => match shutdown_reason { @@ -168,21 +169,31 @@ impl Coordinator { // Do not remove the actor from the registry, as it may be re-spawned. // The coordinator should always remove actors immediately after cancellation. tracing::info!(account_id = %account_id, "Account actor cancelled"); - Ok(()) + Ok(None) }, ActorShutdownReason::SemaphoreFailed(err) => Err(err).context("semaphore failed"), ActorShutdownReason::DbError(account_id) => { tracing::error!(account_id = %account_id, "Account actor shut down due to DB error"); - Ok(()) + Ok(None) }, ActorShutdownReason::IdleTimeout(account_id) => { tracing::info!(account_id = %account_id, "Account actor shut down due to idle timeout"); - Ok(()) + + // Remove the actor from the registry, but check if a notification arrived + // just as the actor timed out. If so, the caller should respawn it. + let should_respawn = + self.actor_registry.remove(&account_id).is_some_and(|handle| { + let notified = handle.notify.notified(); + tokio::pin!(notified); + notified.enable() + }); + + Ok(should_respawn.then_some(account_id)) }, }, Some(Err(err)) => { tracing::error!(err = %err, "actor task failed"); - Ok(()) + Ok(None) }, None => { // There are no actors to wait for. Wait indefinitely until actors are spawned. @@ -284,57 +295,18 @@ impl Coordinator { } } - /// Handles a shutdown request from an actor that has been idle for longer than the idle - /// timeout. - /// - /// Validates the request by checking the DB for available notes. If notes are available, the - /// shutdown is rejected by dropping `ack_tx` (the actor detects the `RecvError` and resumes). - /// If no notes are available, the actor is deregistered and the ack is sent, allowing the - /// actor to exit gracefully. - pub async fn handle_shutdown_request( - &mut self, - account_id: NetworkAccountId, - block_num: BlockNumber, - max_note_attempts: usize, - ack_tx: tokio::sync::oneshot::Sender<()>, - ) { - let has_notes = self - .db - .has_available_notes(account_id, block_num, max_note_attempts) - .await - .unwrap_or(false); - - if has_notes { - // Reject: drop ack_tx -> actor detects RecvError, resumes. - tracing::debug!( - %account_id, - "Rejected actor shutdown: notes available in DB" - ); - } else { - self.actor_registry.remove(&account_id); - let _ = ack_tx.send(()); - } - } - /// Cancels an actor by its account ID. pub fn cancel_actor(&mut self, account_id: &NetworkAccountId) { if let Some(handle) = self.actor_registry.remove(account_id) { handle.cancel_token.cancel(); } } - - /// Returns `true` if an actor is registered for the given account ID. - #[cfg(test)] - pub fn has_actor(&self, account_id: &NetworkAccountId) -> bool { - self.actor_registry.contains_key(account_id) - } } #[cfg(test)] mod tests { use miden_node_proto::domain::mempool::MempoolEvent; use miden_node_proto::domain::note::NetworkNote; - use miden_protocol::block::BlockNumber; use super::*; use crate::db::Db; @@ -355,61 +327,6 @@ mod tests { .insert(account_id, ActorHandle::new(notify, cancel_token)); } - // HANDLE SHUTDOWN REQUEST TESTS - // ============================================================================================ - - #[tokio::test] - async fn shutdown_approved_when_no_notes() { - let (mut coordinator, _dir) = test_coordinator().await; - let account_id = mock_network_account_id(); - - register_dummy_actor(&mut coordinator, account_id); - assert!(coordinator.has_actor(&account_id)); - - let (ack_tx, ack_rx) = tokio::sync::oneshot::channel(); - let block_num = BlockNumber::from(1u32); - let max_note_attempts = 30; - - coordinator - .handle_shutdown_request(account_id, block_num, max_note_attempts, ack_tx) - .await; - - // Ack should be received (shutdown approved). - assert!(ack_rx.await.is_ok()); - // Actor should be deregistered. - assert!(!coordinator.has_actor(&account_id)); - } - - #[tokio::test] - async fn shutdown_rejected_when_notes_available() { - let (mut coordinator, _dir) = test_coordinator().await; - let account_id = mock_network_account_id(); - - // Insert a committed note for this account. - let note = mock_single_target_note(account_id, 10); - coordinator - .db - .sync_account_from_store(account_id, mock_account(account_id), vec![note]) - .await - .unwrap(); - - register_dummy_actor(&mut coordinator, account_id); - assert!(coordinator.has_actor(&account_id)); - - let (ack_tx, ack_rx) = tokio::sync::oneshot::channel(); - let block_num = BlockNumber::from(1u32); - let max_note_attempts = 30; - - coordinator - .handle_shutdown_request(account_id, block_num, max_note_attempts, ack_tx) - .await; - - // Ack_tx should have been dropped (shutdown rejected). - assert!(ack_rx.await.is_err()); - // Actor should still be registered. - assert!(coordinator.has_actor(&account_id)); - } - // SEND TARGETED TESTS // ============================================================================================