use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use anyhow::Context;
use miden_node_utils::shutdown::CancellationToken;
use miden_node_utils::tracing::miden_instrument;
use miden_protocol::account::AccountId;
use miden_standards::note::AccountTargetNetworkNote;
use tokio::sync::{Notify, Semaphore};
use tokio::task::JoinSet;
use crate::LOG_TARGET;
use crate::actor::{AccountActor, AccountActorContext};
use crate::committed_block::CommittedBlockEffects;
#[derive(Clone)]
struct ActorHandle {
notify: Arc<Notify>,
}
impl ActorHandle {
fn new(notify: Arc<Notify>) -> Self {
Self { notify }
}
fn notify(&self) {
self.notify.notify_one();
}
fn has_pending_notification(&self) -> bool {
use futures::FutureExt;
if self.notify.notified().now_or_never().is_some() {
self.notify.notify_one();
true
} else {
false
}
}
}
pub struct Coordinator {
actor_registry: HashMap<AccountId, ActorHandle>,
actor_join_set: JoinSet<(AccountId, anyhow::Result<()>)>,
semaphore: Arc<Semaphore>,
actor_context: AccountActorContext,
crash_counts: HashMap<AccountId, usize>,
max_account_crashes: usize,
pending_spawns: HashSet<AccountId>,
shutdown: CancellationToken,
}
impl Coordinator {
pub fn new(
max_inflight_transactions: usize,
max_account_crashes: usize,
actor_context: AccountActorContext,
shutdown: CancellationToken,
) -> Self {
Self {
actor_registry: HashMap::new(),
actor_join_set: JoinSet::new(),
semaphore: Arc::new(Semaphore::new(max_inflight_transactions)),
actor_context,
crash_counts: HashMap::new(),
max_account_crashes,
pending_spawns: HashSet::new(),
shutdown,
}
}
#[miden_instrument(name = "ntx.builder.spawn_actor", skip(self))]
pub fn spawn_actor(&mut self, account_id: AccountId) {
if let Some(&count) = self.crash_counts.get(&account_id)
&& count >= self.max_account_crashes
{
tracing::warn!(
target: LOG_TARGET,
{
account.id = %account_id,
crash_count = count,
},
"Account deactivated due to repeated crashes, skipping actor spawn"
);
return;
}
if self.actor_registry.contains_key(&account_id) {
tracing::error!(
target: LOG_TARGET,
{ account.id = %account_id },
"Account actor already exists",
);
return;
}
let notify = Arc::new(Notify::new());
let actor = AccountActor::new(account_id, &self.actor_context, notify.clone());
let handle = ActorHandle::new(notify);
let semaphore = self.semaphore.clone();
let shutdown = self.shutdown.clone();
self.actor_join_set
.spawn(Box::pin(async move { (account_id, actor.run(semaphore, shutdown).await) }));
self.actor_registry.insert(account_id, handle);
tracing::debug!(
target: LOG_TARGET,
{ account.id = %account_id },
"Created actor for account"
);
}
pub async fn spawn_actor_when_committed(
&mut self,
account_id: AccountId,
) -> anyhow::Result<()> {
if self.actor_registry.contains_key(&account_id) {
return Ok(());
}
let committed = self
.actor_context
.state
.db
.account_exists(account_id)
.await
.context("failed to check for committed account state")?;
if committed {
self.spawn_actor(account_id);
} else {
tracing::info!(
target: LOG_TARGET,
{ account.id = %account_id },
"deferring actor spawn until the account's creation is committed",
);
self.pending_spawns.insert(account_id);
}
Ok(())
}
pub async fn handle_committed_block(
&mut self,
effects: &CommittedBlockEffects,
) -> anyhow::Result<()> {
for account_id in effects.created_network_accounts() {
if self.pending_spawns.remove(&account_id) {
self.spawn_actor(account_id);
}
}
let targeted: HashSet<AccountId> = effects
.network_notes
.iter()
.map(AccountTargetNetworkNote::target_account_id)
.collect();
for account_id in targeted {
self.spawn_actor_when_committed(account_id).await?;
}
for handle in self.actor_registry.values() {
handle.notify();
}
Ok(())
}
pub async fn next(&mut self) -> anyhow::Result<Option<AccountId>> {
let actor_result = self.actor_join_set.join_next().await;
match actor_result {
Some(Ok((account_id, Ok(())))) => {
let should_respawn = self
.actor_registry
.remove(&account_id)
.is_some_and(|handle| handle.has_pending_notification());
Ok(should_respawn.then_some(account_id))
},
Some(Ok((account_id, Err(err)))) => {
let count = self.crash_counts.entry(account_id).or_insert(0);
*count += 1;
tracing::error!(
target: LOG_TARGET,
{
account.id = %account_id,
error = %format!("{err:#}"),
},
"Account actor crashed"
);
self.actor_registry.remove(&account_id);
Ok(None)
},
Some(Err(err)) => {
tracing::error!(target: LOG_TARGET, error = %err, "Actor task failed");
Ok(None)
},
None => {
std::future::pending().await
},
}
}
pub async fn shutdown(&mut self) -> anyhow::Result<()> {
while let Some(result) = self.actor_join_set.join_next().await {
match result {
Ok((_account_id, Ok(()))) => {},
Ok((account_id, Err(err))) => {
return Err(err).with_context(|| {
format!("account actor {account_id} failed during shutdown")
});
},
Err(err) if err.is_cancelled() => {},
Err(err) => return Err(err).context("account actor failed to join"),
}
}
self.actor_registry.clear();
Ok(())
}
}
#[cfg(test)]
impl Coordinator {
pub async fn test()
-> (Self, tempfile::TempDir, tokio::sync::mpsc::Receiver<crate::actor::ActorRequest>) {
use crate::db::Db;
let (db, dir) = Db::test_setup().await;
let (tx, rx) = tokio::sync::mpsc::channel(8);
let mut actor_context = AccountActorContext::test(&db);
actor_context.request_tx = tx;
(Self::new(4, 10, actor_context, CancellationToken::new()), dir, rx)
}
}
#[cfg(test)]
mod tests {
use futures::FutureExt;
use super::*;
use crate::test_utils::*;
fn register_dummy_actor(coordinator: &mut Coordinator, account_id: AccountId) {
let notify = Arc::new(Notify::new());
coordinator.actor_registry.insert(account_id, ActorHandle::new(notify));
}
async fn seed_committed_account(coordinator: &Coordinator, account_id: AccountId) {
let db = coordinator.actor_context.state.db.clone();
db.upsert_account_for_test(account_id, mock_account(account_id), mock_transaction_id(0))
.await
.unwrap();
}
#[tokio::test]
async fn handle_committed_block_spawns_for_committed_note_target() {
let (mut coordinator, _dir, _rx) = Coordinator::test().await;
let target_id = mock_network_account_id();
seed_committed_account(&coordinator, target_id).await;
let note = mock_single_target_note(target_id, 10);
let effects = CommittedBlockEffects {
header: mock_block_header(1_u32.into()),
network_notes: vec![note],
nullifiers: vec![],
network_account_updates: vec![],
account_transactions: vec![],
};
coordinator.handle_committed_block(&effects).await.unwrap();
assert!(
coordinator.actor_registry.contains_key(&target_id),
"a committed account targeted by a note should get a fresh actor",
);
}
#[tokio::test]
async fn handle_committed_block_defers_spawn_until_account_creation_commits() {
let (mut coordinator, _dir, _rx) = Coordinator::test().await;
let (account, details) = mock_network_account_update();
let account_id = account.id();
let note = mock_single_target_note(account_id, 10);
let effects = CommittedBlockEffects {
header: mock_block_header(1_u32.into()),
network_notes: vec![note],
nullifiers: vec![],
network_account_updates: vec![],
account_transactions: vec![],
};
coordinator.handle_committed_block(&effects).await.unwrap();
assert!(
!coordinator.actor_registry.contains_key(&account_id),
"an account without committed state must not get an actor",
);
assert!(
coordinator.pending_spawns.contains(&account_id),
"the spawn must be deferred until the account's creation commits",
);
let db = coordinator.actor_context.state.db.clone();
db.upsert_account_for_test(account_id, account, mock_transaction_id(0))
.await
.unwrap();
let effects = CommittedBlockEffects {
header: mock_block_header(2_u32.into()),
network_notes: vec![],
nullifiers: vec![],
network_account_updates: vec![(account_id, details)],
account_transactions: vec![],
};
coordinator.handle_committed_block(&effects).await.unwrap();
assert!(
coordinator.actor_registry.contains_key(&account_id),
"the block committing the account's creation must release the deferred spawn",
);
assert!(
coordinator.pending_spawns.is_empty(),
"a released spawn must leave the pending set",
);
}
#[tokio::test]
async fn handle_committed_block_does_not_spawn_for_account_update_only() {
let (mut coordinator, _dir, _rx) = Coordinator::test().await;
let updated_id = mock_network_account_id();
let effects = CommittedBlockEffects {
header: mock_block_header(1_u32.into()),
network_notes: vec![],
nullifiers: vec![],
network_account_updates: vec![(
updated_id,
miden_protocol::account::delta::AccountUpdateDetails::Private,
)],
account_transactions: vec![],
};
coordinator.handle_committed_block(&effects).await.unwrap();
assert!(
!coordinator.actor_registry.contains_key(&updated_id),
"an account update without a new note should not trigger an actor spawn",
);
}
#[tokio::test]
async fn spawn_actor_skips_deactivated_account() {
let (mut coordinator, _dir, _rx) = Coordinator::test().await;
let account_id = mock_network_account_id();
coordinator.crash_counts.insert(account_id, coordinator.max_account_crashes);
coordinator.spawn_actor(account_id);
assert!(
!coordinator.actor_registry.contains_key(&account_id),
"deactivated account should not have an actor in the registry",
);
}
#[tokio::test]
async fn spawn_actor_allows_below_threshold() {
let (mut coordinator, _dir, _rx) = Coordinator::test().await;
let account_id = mock_network_account_id();
coordinator
.crash_counts
.insert(account_id, coordinator.max_account_crashes.saturating_sub(1));
coordinator.spawn_actor(account_id);
assert!(
coordinator.actor_registry.contains_key(&account_id),
"account below crash threshold should have an actor in the registry",
);
}
#[tokio::test]
async fn handle_committed_block_notifies_existing_actors() {
let (mut coordinator, _dir, _rx) = Coordinator::test().await;
let bystander = mock_network_account_id();
register_dummy_actor(&mut coordinator, bystander);
let bystander_notify = coordinator.actor_registry.get(&bystander).unwrap().notify.clone();
let target = mock_network_account_id_seeded(42);
seed_committed_account(&coordinator, target).await;
let note = mock_single_target_note(target, 10);
let effects = CommittedBlockEffects {
header: mock_block_header(1_u32.into()),
network_notes: vec![note],
nullifiers: vec![],
network_account_updates: vec![],
account_transactions: vec![],
};
coordinator.handle_committed_block(&effects).await.unwrap();
assert!(
bystander_notify.notified().now_or_never().is_some(),
"every registered actor should be notified on a committed block",
);
assert!(
coordinator.actor_registry.contains_key(&target),
"freshly-targeted account should get an actor",
);
}
}