diff --git a/CHANGELOG.md b/CHANGELOG.md index d0e369a9e..a8b66a949 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 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 352a6de16..6a63f1ed2 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_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(); @@ -125,6 +126,18 @@ pub struct NtxBuilderConfig { )] pub script_cache_size: NonZeroUsize, + /// 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. + #[arg( + 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 idle_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_idle_timeout(self.idle_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/actor/mod.rs b/crates/ntx-builder/src/actor/mod.rs index 691fb8ee6..479ee254e 100644 --- a/crates/ntx-builder/src/actor/mod.rs +++ b/crates/ntx-builder/src/actor/mod.rs @@ -68,6 +68,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 idle timeout and the builder + /// has confirmed there are no available notes in the DB. + IdleTimeout(NetworkAccountId), } // ACCOUNT ACTOR CONFIG @@ -95,6 +98,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 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). @@ -200,6 +205,8 @@ pub struct AccountActor { max_notes_per_tx: NonZeroUsize, /// Maximum number of note execution attempts before dropping a note. max_note_attempts: usize, + /// Duration after which an idle actor will deactivate. + idle_timeout: Duration, /// Channel for sending requests to the coordinator. request_tx: mpsc::Sender, } @@ -235,6 +242,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, + idle_timeout: actor_context.idle_timeout, request_tx: actor_context.request_tx.clone(), } } @@ -269,6 +277,14 @@ impl AccountActor { // Enable transaction execution. ActorMode::NotesAvailable => semaphore.acquire().boxed(), }; + + // Idle timeout timer: only ticks when in NoViableNotes mode. + // Mode changes cause the next loop iteration to create a fresh sleep or pending. + let idle_timeout_sleep = match self.mode { + ActorMode::NoViableNotes => tokio::time::sleep(self.idle_timeout).boxed(), + _ => std::future::pending().boxed(), + }; + tokio::select! { _ = self.cancel_token.cancelled() => { return ActorShutdownReason::Cancelled(account_id); @@ -325,6 +341,10 @@ impl AccountActor { } } } + // Idle timeout: actor has been idle too long, deactivate account. + _ = idle_timeout_sleep => { + return ActorShutdownReason::IdleTimeout(account_id); + } } } } diff --git a/crates/ntx-builder/src/builder.rs b/crates/ntx-builder/src/builder.rs index 6707cb730..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() => { @@ -211,7 +214,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. diff --git a/crates/ntx-builder/src/coordinator.rs b/crates/ntx-builder/src/coordinator.rs index 2f04d7fdc..175e04915 100644 --- a/crates/ntx-builder/src/coordinator.rs +++ b/crates/ntx-builder/src/coordinator.rs @@ -56,6 +56,13 @@ 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 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. +/// /// 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. @@ -151,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 { @@ -159,17 +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"); + + // 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. @@ -183,8 +207,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 +236,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 +247,8 @@ impl Coordinator { handle.notify.notify_one(); } } + + inactive_targets } /// Writes mempool event effects to the database. @@ -268,3 +302,60 @@ impl Coordinator { } } } + +#[cfg(test)] +mod tests { + use miden_node_proto::domain::mempool::MempoolEvent; + use miden_node_proto::domain::note::NetworkNote; + + 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)); + } + + // 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 ca88ec50a..f91a6a3e8 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; @@ -22,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 @@ -52,6 +56,9 @@ const DEFAULT_MAX_NOTE_ATTEMPTS: usize = 30; const DEFAULT_SCRIPT_CACHE_SIZE: NonZeroUsize = NonZeroUsize::new(1_000).expect("literal is non-zero"); +/// Default duration after which an idle network account actor will deactivate. +const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_secs(5 * 60); + // CONFIGURATION // ================================================================================================= @@ -93,6 +100,12 @@ pub struct NtxBuilderConfig { /// Channel capacity for loading accounts from the store during startup. pub account_channel_capacity: usize, + /// 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, } @@ -115,6 +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, + idle_timeout: DEFAULT_IDLE_TIMEOUT, database_filepath, } } @@ -180,6 +194,15 @@ impl NtxBuilderConfig { self } + /// Sets the idle timeout for actors. + /// + /// Actors that remain idle (no viable notes) for this duration will be deactivated. + #[must_use] + pub fn with_idle_timeout(mut self, timeout: Duration) -> Self { + self.idle_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 +269,7 @@ impl NtxBuilderConfig { script_cache, max_notes_per_tx: self.max_notes_per_tx, max_note_attempts: self.max_note_attempts, + idle_timeout: self.idle_timeout, db: db.clone(), request_tx, }; 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()) +} diff --git a/docs/external/src/operator/architecture.md b/docs/external/src/operator/architecture.md index 9bc9ac0a3..674507752 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.idle-timeout` CLI +argument (default: 5 minutes). diff --git a/docs/internal/src/ntx-builder.md b/docs/internal/src/ntx-builder.md index 5ed9b4c82..a662f7658 100644 --- a/docs/internal/src/ntx-builder.md +++ b/docs/internal/src/ntx-builder.md @@ -34,12 +34,22 @@ 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 **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 +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.