use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use anyhow::Context;
use miden_node_tracing::{debug, error, info, miden_instrument, warn};
use miden_node_utils::shutdown::CancellationToken;
use miden_protocol::account::AccountId;
use miden_protocol::block::BlockNumber;
use miden_protocol::transaction::TransactionId;
use miden_standards::note::AccountTargetNetworkNote;
use tokio::sync::{Semaphore, watch};
use tokio::task::JoinSet;
use crate::LOG_TARGET;
#[cfg(test)]
use crate::actor::ActorRequest;
use crate::actor::{AccountActor, AccountActorContext};
use crate::committed_block::CommittedBlockEffects;
#[cfg(test)]
use crate::db::NtxDbWriter;
#[derive(Clone, Debug)]
pub(crate) struct AccountView {
pub chain_tip: BlockNumber,
pub last_committed_tx: Option<TransactionId>,
pub notes_seen: u64,
}
struct ActorHandle {
view_tx: watch::Sender<AccountView>,
}
impl ActorHandle {
fn new(view_tx: watch::Sender<AccountView>) -> Self {
Self { view_tx }
}
}
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",
fields(account.id = account_id),
)]
pub fn spawn_actor(&mut self, account_id: AccountId) {
if let Some(&count) = self.crash_counts.get(&account_id)
&& count >= self.max_account_crashes
{
warn!(
target: LOG_TARGET,
"Account deactivated due to repeated crashes, skipping actor spawn",
account.id = account_id,
account.crashes.count = count
);
return;
}
if self.actor_registry.contains_key(&account_id) {
error!(
anyhow::anyhow!("account actor already exists"),
target: LOG_TARGET,
"Account actor already exists",
account.id = account_id
);
return;
}
let initial_view = AccountView {
chain_tip: self.actor_context.state.chain.chain_tip_block_number(),
last_committed_tx: None,
notes_seen: 0,
};
let (view_tx, view_rx) = watch::channel(initial_view);
let actor = AccountActor::new(account_id, &self.actor_context);
let handle = ActorHandle::new(view_tx);
let semaphore = self.semaphore.clone();
let shutdown = self.shutdown.clone();
self.actor_join_set.spawn(Box::pin(async move {
(account_id, actor.run(semaphore, view_rx, shutdown).await)
}));
self.actor_registry.insert(account_id, handle);
debug!(
target: LOG_TARGET,
"Created actor for account",
account.id = account_id
);
}
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 {
info!(
target: LOG_TARGET,
"deferring actor spawn until the account's creation is committed",
account.id = account_id
);
self.pending_spawns.insert(account_id);
}
Ok(())
}
pub async fn handle_committed_block(
&mut self,
effects: &CommittedBlockEffects,
sponsored_accounts: &[AccountId],
) -> anyhow::Result<()> {
for account_id in effects.created_network_accounts() {
if self.pending_spawns.remove(&account_id) {
self.spawn_actor(account_id);
}
}
let mut targeted: HashSet<AccountId> = effects
.network_notes
.iter()
.map(AccountTargetNetworkNote::target_account_id)
.collect();
targeted.extend(sponsored_accounts.iter().copied());
for account_id in &targeted {
self.spawn_actor_when_committed(*account_id).await?;
}
let chain_tip = effects.header.block_num();
let latest_tx = effects.latest_tx_per_account();
let mut new_notes: HashMap<AccountId, u64> = HashMap::new();
for note in &effects.network_notes {
*new_notes.entry(note.target_account_id()).or_default() += 1;
}
for account_id in sponsored_accounts {
*new_notes.entry(*account_id).or_default() += 1;
}
for (account_id, handle) in &self.actor_registry {
let committed_tx = latest_tx.get(account_id).copied();
let notes = new_notes.get(account_id).copied().unwrap_or(0);
handle.view_tx.send_modify(|view| {
view.chain_tip = chain_tip;
if let Some(tx) = committed_tx {
view.last_committed_tx = Some(tx);
}
view.notes_seen += notes;
});
}
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(())))) => {
self.actor_registry.remove(&account_id);
let should_respawn = self.account_has_pending_notes(account_id).await?;
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;
error!(
&err,
target: LOG_TARGET,
"Account actor crashed",
account.id = account_id
);
self.actor_registry.remove(&account_id);
Ok(None)
},
Some(Err(err)) => {
error!(&err, target: LOG_TARGET, "Actor task failed");
Ok(None)
},
None => {
std::future::pending().await
},
}
}
async fn account_has_pending_notes(&self, account_id: AccountId) -> anyhow::Result<bool> {
self.actor_context
.state
.db
.account_has_pending_notes(account_id, self.actor_context.config.max_note_attempts)
.await
.context("failed to check pending notes when reaping an idle actor")
}
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, NtxDbWriter, tempfile::TempDir, tokio::sync::mpsc::Receiver<ActorRequest>) {
let (db, dir) = crate::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()), db, dir, rx)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::*;
fn register_dummy_actor(
coordinator: &mut Coordinator,
account_id: AccountId,
) -> watch::Receiver<AccountView> {
let (view_tx, view_rx) = watch::channel(AccountView {
chain_tip: BlockNumber::GENESIS,
last_committed_tx: None,
notes_seen: 0,
});
coordinator.actor_registry.insert(account_id, ActorHandle::new(view_tx));
view_rx
}
async fn seed_committed_account(db: &NtxDbWriter, account_id: AccountId) {
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, db, _dir, _rx) = Coordinator::test().await;
let target_id = mock_network_account_id();
seed_committed_account(&db, 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],
sponsorship_notes: vec![],
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, db, _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],
sponsorship_notes: vec![],
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",
);
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![],
sponsorship_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, _db, _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![],
sponsorship_notes: vec![],
nullifiers: vec![],
network_account_updates: vec![(
updated_id,
miden_protocol::account::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, _db, _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, _db, _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_pushes_view_to_existing_actors() {
let (mut coordinator, db, _dir, _rx) = Coordinator::test().await;
let bystander = mock_network_account_id();
let mut bystander_rx = register_dummy_actor(&mut coordinator, bystander);
let _ = bystander_rx.borrow_and_update();
let target = mock_network_account_id_seeded(42);
seed_committed_account(&db, target).await;
let note = mock_single_target_note(target, 10);
let effects = CommittedBlockEffects {
header: mock_block_header(1_u32.into()),
network_notes: vec![note],
sponsorship_notes: vec![],
nullifiers: vec![],
network_account_updates: vec![],
account_transactions: vec![],
};
coordinator.handle_committed_block(&effects, &[]).await.unwrap();
assert!(
bystander_rx.has_changed().unwrap(),
"every registered actor should receive a view update on a committed block",
);
let view = bystander_rx.borrow_and_update();
assert_eq!(view.chain_tip, 1_u32.into(), "the view carries the new chain tip");
assert_eq!(view.notes_seen, 0, "a bystander targeted by no note sees no new work");
drop(view);
assert!(
coordinator.actor_registry.contains_key(&target),
"freshly-targeted account should get an actor",
);
}
#[tokio::test]
async fn handle_committed_block_view_carries_landing_and_new_notes() {
let (mut coordinator, _db, _dir, _rx) = Coordinator::test().await;
let account_id = mock_network_account_id();
let mut rx = register_dummy_actor(&mut coordinator, account_id);
let _ = rx.borrow_and_update();
let tx_id = mock_transaction_id(5);
let note = mock_single_target_note(account_id, 10);
let effects = CommittedBlockEffects {
header: mock_block_header(3_u32.into()),
network_notes: vec![note],
sponsorship_notes: vec![],
nullifiers: vec![],
network_account_updates: vec![],
account_transactions: vec![(account_id, tx_id)],
};
coordinator.handle_committed_block(&effects, &[]).await.unwrap();
let view = rx.borrow_and_update();
assert_eq!(view.chain_tip, 3_u32.into());
assert_eq!(
view.last_committed_tx,
Some(tx_id),
"the account's latest committed tx is pushed for in-memory landing detection",
);
assert_eq!(view.notes_seen, 1, "one note targeting the account bumps the work counter");
}
#[tokio::test]
async fn handle_committed_block_sponsorship_wakeup_bumps_notes_seen() {
let (mut coordinator, _db, _dir, _rx) = Coordinator::test().await;
let account_id = mock_network_account_id();
let mut rx = register_dummy_actor(&mut coordinator, account_id);
let _ = rx.borrow_and_update();
let effects = CommittedBlockEffects {
header: mock_block_header(1_u32.into()),
network_notes: vec![],
sponsorship_notes: vec![],
nullifiers: vec![],
network_account_updates: vec![],
account_transactions: vec![],
};
coordinator.handle_committed_block(&effects, &[account_id]).await.unwrap();
let view = rx.borrow_and_update();
assert_eq!(
view.notes_seen, 1,
"a sponsorship for a pending feature note bumps the work counter",
);
}
}