Skip to content

Commit b51c9b8

Browse files
chore: ntx builder actor deactivation (#1705)
1 parent 84c853b commit b51c9b8

File tree

12 files changed

+275
-114
lines changed

12 files changed

+275
-114
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
- 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/node/pull/1662)).
2929
- [BREAKING] Reworked `miden-remote-prover`, removing the `worker`/`proxy` distinction and simplifying to a `worker` with a request queue ([#1688](https://github.com/0xMiden/node/pull/1688)).
3030
- [BREAKING] Renamed `NoteRoot` protobuf message used in `GetNoteScriptByRoot` gRPC endpoints into `NoteScriptRoot` ([#1722](https://github.com/0xMiden/node/pull/1722)).
31+
- 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/node/pull/1705)).
3132
- [BREAKING] Modified `TransactionHeader` serialization to allow converting back into the native type after serialization ([#1759](https://github.com/0xMiden/node/issues/1759)).
3233
- Removed `chain_tip` requirement from mempool subscription request ([#1771](https://github.com/0xMiden/node/pull/1771)).
3334
- Moved bootstrap procedure to `miden-node validator bootstrap` command ([#1764](https://github.com/0xMiden/node/pull/1764)).

bin/node/src/commands/mod.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ const ENV_VALIDATOR_KMS_KEY_ID: &str = "MIDEN_NODE_VALIDATOR_KMS_KEY_ID";
4949
const ENV_NTX_DATA_DIRECTORY: &str = "MIDEN_NODE_NTX_DATA_DIRECTORY";
5050

5151
const DEFAULT_NTX_TICKER_INTERVAL: Duration = Duration::from_millis(200);
52+
const DEFAULT_NTX_IDLE_TIMEOUT: Duration = Duration::from_secs(5 * 60);
5253
const DEFAULT_NTX_SCRIPT_CACHE_SIZE: NonZeroUsize = NonZeroUsize::new(1000).unwrap();
5354

5455
/// Configuration for the Validator key used to sign blocks.
@@ -171,6 +172,18 @@ pub struct NtxBuilderConfig {
171172
)]
172173
pub script_cache_size: NonZeroUsize,
173174

175+
/// Duration after which an idle network account will deactivate.
176+
///
177+
/// An account is considered idle once it has no viable notes to consume.
178+
/// A deactivated account will reactivate if targeted with new notes.
179+
#[arg(
180+
long = "ntx-builder.idle-timeout",
181+
default_value = &duration_to_human_readable_string(DEFAULT_NTX_IDLE_TIMEOUT),
182+
value_parser = humantime::parse_duration,
183+
value_name = "DURATION"
184+
)]
185+
pub idle_timeout: Duration,
186+
174187
/// Directory for the ntx-builder's persistent database.
175188
///
176189
/// If not set, defaults to the node's data directory.
@@ -201,6 +214,7 @@ impl NtxBuilderConfig {
201214
)
202215
.with_tx_prover_url(self.tx_prover_url)
203216
.with_script_cache_size(self.script_cache_size)
217+
.with_idle_timeout(self.idle_timeout)
204218
}
205219
}
206220

bin/node/src/main.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ pub struct Cli {
1717
pub command: Command,
1818
}
1919

20-
#[expect(clippy::large_enum_variant)]
2120
#[derive(Subcommand)]
2221
pub enum Command {
2322
/// Commands related to the node's store component.
@@ -40,7 +39,7 @@ pub enum Command {
4039
///
4140
/// This is the recommended way to run the node at the moment.
4241
#[command(subcommand)]
43-
Bundled(commands::bundled::BundledCommand),
42+
Bundled(Box<commands::bundled::BundledCommand>),
4443
}
4544

4645
impl Command {

crates/ntx-builder/src/actor/mod.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ pub enum ActorShutdownReason {
6060
/// Occurs when an account actor detects that its account has been removed from the database
6161
/// (e.g. due to a reverted account creation).
6262
AccountRemoved(NetworkAccountId),
63+
/// Occurs when the actor has been idle for longer than the idle timeout and the builder
64+
/// has confirmed there are no available notes in the DB.
65+
IdleTimeout(NetworkAccountId),
6366
}
6467

6568
// ACCOUNT ACTOR CONFIG
@@ -87,6 +90,8 @@ pub struct AccountActorContext {
8790
pub max_notes_per_tx: NonZeroUsize,
8891
/// Maximum number of note execution attempts before dropping a note.
8992
pub max_note_attempts: usize,
93+
/// Duration after which an idle actor will deactivate.
94+
pub idle_timeout: Duration,
9095
/// Database for persistent state.
9196
pub db: Db,
9297
/// Channel for sending requests to the coordinator (via the builder event loop).
@@ -192,6 +197,8 @@ pub struct AccountActor {
192197
max_notes_per_tx: NonZeroUsize,
193198
/// Maximum number of note execution attempts before dropping a note.
194199
max_note_attempts: usize,
200+
/// Duration after which an idle actor will deactivate.
201+
idle_timeout: Duration,
195202
/// Channel for sending requests to the coordinator.
196203
request_tx: mpsc::Sender<ActorRequest>,
197204
}
@@ -227,6 +234,7 @@ impl AccountActor {
227234
script_cache: actor_context.script_cache.clone(),
228235
max_notes_per_tx: actor_context.max_notes_per_tx,
229236
max_note_attempts: actor_context.max_note_attempts,
237+
idle_timeout: actor_context.idle_timeout,
230238
request_tx: actor_context.request_tx.clone(),
231239
}
232240
}
@@ -261,6 +269,14 @@ impl AccountActor {
261269
// Enable transaction execution.
262270
ActorMode::NotesAvailable => semaphore.acquire().boxed(),
263271
};
272+
273+
// Idle timeout timer: only ticks when in NoViableNotes mode.
274+
// Mode changes cause the next loop iteration to create a fresh sleep or pending.
275+
let idle_timeout_sleep = match self.mode {
276+
ActorMode::NoViableNotes => tokio::time::sleep(self.idle_timeout).boxed(),
277+
_ => std::future::pending().boxed(),
278+
};
279+
264280
tokio::select! {
265281
_ = self.cancel_token.cancelled() => {
266282
return Err(ActorShutdownReason::Cancelled(account_id));
@@ -309,6 +325,10 @@ impl AccountActor {
309325
self.mode = ActorMode::NoViableNotes;
310326
}
311327
}
328+
// Idle timeout: actor has been idle too long, deactivate account.
329+
_ = idle_timeout_sleep => {
330+
return Err(ActorShutdownReason::IdleTimeout(account_id));
331+
}
312332
}
313333
}
314334
}

crates/ntx-builder/src/builder.rs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,12 @@ impl NetworkTransactionBuilder {
112112
// Main event loop.
113113
loop {
114114
tokio::select! {
115-
// Handle actor result.
115+
// Handle actor result. If a timed-out actor needs respawning, do so.
116116
result = self.coordinator.next() => {
117-
result?;
117+
if let Some(account_id) = result? {
118+
self.coordinator
119+
.spawn_actor(AccountOrigin::store(account_id), &self.actor_context);
120+
}
118121
},
119122
// Handle mempool events.
120123
event = self.mempool_events.next() => {
@@ -203,7 +206,11 @@ impl NetworkTransactionBuilder {
203206
}
204207
}
205208
}
206-
self.coordinator.send_targeted(&event);
209+
let inactive_targets = self.coordinator.send_targeted(&event);
210+
for account_id in inactive_targets {
211+
self.coordinator
212+
.spawn_actor(AccountOrigin::store(account_id), &self.actor_context);
213+
}
207214
Ok(())
208215
},
209216
// Update chain state and notify affected actors.

crates/ntx-builder/src/coordinator.rs

Lines changed: 94 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,13 @@ impl ActorHandle {
6565
/// - Controls transaction concurrency across all network accounts using a semaphore.
6666
/// - Prevents resource exhaustion by limiting simultaneous transaction processing.
6767
///
68+
/// ## Actor Lifecycle
69+
/// - Actors that have been idle for longer than the idle timeout deactivate themselves.
70+
/// - When an actor deactivates, the coordinator checks if a notification arrived just as the actor
71+
/// timed out. If so, the actor is respawned immediately.
72+
/// - Deactivated actors are re-spawned when [`Coordinator::send_targeted`] detects notes targeting
73+
/// an account without an active actor.
74+
///
6875
/// The coordinator operates in an event-driven manner:
6976
/// 1. Network accounts are registered and actors spawned as needed.
7077
/// 2. Mempool events are written to DB, then actors are notified.
@@ -165,31 +172,48 @@ impl Coordinator {
165172
///
166173
/// If no actors are currently running, this method will wait indefinitely until
167174
/// new actors are spawned. This prevents busy-waiting when the coordinator is idle.
168-
pub async fn next(&mut self) -> anyhow::Result<()> {
175+
///
176+
/// Returns `Some(account_id)` if a timed-out actor should be respawned (because a
177+
/// notification arrived just as it timed out), or `None` otherwise.
178+
pub async fn next(&mut self) -> anyhow::Result<Option<NetworkAccountId>> {
169179
let actor_result = self.actor_join_set.join_next().await;
170180
match actor_result {
171181
Some(Ok(shutdown_reason)) => match shutdown_reason {
172182
ActorShutdownReason::Cancelled(account_id) => {
173183
// Do not remove the actor from the registry, as it may be re-spawned.
174184
// The coordinator should always remove actors immediately after cancellation.
175185
tracing::info!(account_id = %account_id, "Account actor cancelled");
176-
Ok(())
186+
Ok(None)
177187
},
178188
ActorShutdownReason::SemaphoreFailed(err) => Err(err).context("semaphore failed"),
179189
ActorShutdownReason::DbError(account_id, err) => {
180-
self.actor_registry.remove(&account_id);
181190
tracing::error!(account_id = %account_id, err = err.as_report(), "Account actor shut down due to DB error");
182-
Ok(())
191+
self.actor_registry.remove(&account_id);
192+
Ok(None)
183193
},
184194
ActorShutdownReason::AccountRemoved(account_id) => {
185195
self.actor_registry.remove(&account_id);
186196
tracing::info!(account_id = %account_id, "Account actor shut down: account removed");
187-
Ok(())
197+
Ok(None)
198+
},
199+
ActorShutdownReason::IdleTimeout(account_id) => {
200+
tracing::info!(account_id = %account_id, "Account actor shut down due to idle timeout");
201+
202+
// Remove the actor from the registry, but check if a notification arrived
203+
// just as the actor timed out. If so, the caller should respawn it.
204+
let should_respawn =
205+
self.actor_registry.remove(&account_id).is_some_and(|handle| {
206+
let notified = handle.notify.notified();
207+
tokio::pin!(notified);
208+
notified.enable()
209+
});
210+
211+
Ok(should_respawn.then_some(account_id))
188212
},
189213
},
190214
Some(Err(err)) => {
191215
tracing::error!(err = %err, "actor task failed");
192-
Ok(())
216+
Ok(None)
193217
},
194218
None => {
195219
// There are no actors to wait for. Wait indefinitely until actors are spawned.
@@ -203,8 +227,14 @@ impl Coordinator {
203227
/// Only actors that are currently active are notified. Since event effects are already
204228
/// persisted in the DB by `write_event()`, actors that spawn later read their state from the
205229
/// DB and do not need predating events.
206-
pub fn send_targeted(&self, event: &MempoolEvent) {
230+
///
231+
/// Returns account IDs of note targets that do not have active actors (e.g. previously
232+
/// deactivated due to sterility). The caller can use this to re-activate actors for those
233+
/// accounts.
234+
pub fn send_targeted(&self, event: &MempoolEvent) -> Vec<NetworkAccountId> {
207235
let mut target_account_ids = HashSet::new();
236+
let mut inactive_targets = Vec::new();
237+
208238
if let MempoolEvent::TransactionAdded { network_notes, account_delta, .. } = event {
209239
// We need to inform the account if it was updated. This lets it know that its own
210240
// transaction has been applied, and in the future also resolves race conditions with
@@ -228,6 +258,8 @@ impl Coordinator {
228258

229259
if self.actor_registry.contains_key(&account) {
230260
target_account_ids.insert(account);
261+
} else {
262+
inactive_targets.push(account);
231263
}
232264
}
233265
}
@@ -237,6 +269,8 @@ impl Coordinator {
237269
handle.notify.notify_one();
238270
}
239271
}
272+
273+
inactive_targets
240274
}
241275

242276
/// Writes mempool event effects to the database.
@@ -283,3 +317,56 @@ impl Coordinator {
283317
}
284318
}
285319
}
320+
321+
#[cfg(test)]
322+
mod tests {
323+
use miden_node_proto::domain::mempool::MempoolEvent;
324+
325+
use super::*;
326+
use crate::db::Db;
327+
use crate::test_utils::*;
328+
329+
/// Creates a coordinator with default settings backed by a temp DB.
330+
async fn test_coordinator() -> (Coordinator, tempfile::TempDir) {
331+
let (db, dir) = Db::test_setup().await;
332+
(Coordinator::new(4, db), dir)
333+
}
334+
335+
/// Registers a dummy actor handle (no real actor task) in the coordinator's registry.
336+
fn register_dummy_actor(coordinator: &mut Coordinator, account_id: NetworkAccountId) {
337+
let notify = Arc::new(Notify::new());
338+
let cancel_token = CancellationToken::new();
339+
coordinator
340+
.actor_registry
341+
.insert(account_id, ActorHandle::new(notify, cancel_token));
342+
}
343+
344+
// SEND TARGETED TESTS
345+
// ============================================================================================
346+
347+
#[tokio::test]
348+
async fn send_targeted_returns_inactive_targets() {
349+
let (mut coordinator, _dir) = test_coordinator().await;
350+
351+
let active_id = mock_network_account_id();
352+
let inactive_id = mock_network_account_id_seeded(42);
353+
354+
// Only register the active account.
355+
register_dummy_actor(&mut coordinator, active_id);
356+
357+
let note_active = mock_single_target_note(active_id, 10);
358+
let note_inactive = mock_single_target_note(inactive_id, 20);
359+
360+
let event = MempoolEvent::TransactionAdded {
361+
id: mock_tx_id(1),
362+
nullifiers: vec![],
363+
network_notes: vec![note_active, note_inactive],
364+
account_delta: None,
365+
};
366+
367+
let inactive_targets = coordinator.send_targeted(&event);
368+
369+
assert_eq!(inactive_targets.len(), 1);
370+
assert_eq!(inactive_targets[0], inactive_id);
371+
}
372+
}

crates/ntx-builder/src/db/mod.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,4 +226,15 @@ impl Db {
226226
apply_migrations(&mut conn).expect("migrations should apply on empty database");
227227
(conn, dir)
228228
}
229+
230+
/// Creates an async `Db` instance backed by a temp file for testing.
231+
///
232+
/// Returns `(Db, TempDir)` — the `TempDir` must be kept alive for the DB's lifetime.
233+
#[cfg(test)]
234+
pub async fn test_setup() -> (Db, tempfile::TempDir) {
235+
let dir = tempfile::tempdir().expect("failed to create temp directory");
236+
let db_path = dir.path().join("test.sqlite3");
237+
let db = Db::setup(db_path).await.expect("test DB setup should succeed");
238+
(db, dir)
239+
}
229240
}

0 commit comments

Comments
 (0)