use std::collections::HashMap;
use std::num::NonZeroU16;
use anyhow::Context;
use miden_node_tracing::{debug, error, info, miden_instrument};
use miden_node_utils::tasks::Tasks;
use miden_protocol::account::AccountId;
use miden_protocol::block::BlockNumber;
use miden_protocol::transaction::TransactionId;
use crate::LOG_TARGET;
use crate::chain_state::ChainState;
use crate::committed_block::CommittedBlockEffects;
use crate::db::NtxDbWriter;
use crate::network_transaction::{
NetworkTransactionContext,
NetworkTransactionOutcome,
NetworkTransactionResult,
NoteUpdates,
};
struct Inflight {
tx_id: TransactionId,
expires_at: BlockNumber,
}
#[derive(Debug, Clone)]
pub struct SchedulerConfig {
pub max_concurrent_txs: usize,
pub tx_expiration_delta: NonZeroU16,
pub priority_accounts: Vec<AccountId>,
}
pub struct Scheduler {
ctx: NetworkTransactionContext,
tasks: Tasks<NetworkTransactionOutcome, AccountId>,
in_flight: HashMap<AccountId, Inflight>,
config: SchedulerConfig,
}
impl Scheduler {
pub fn new(ctx: NetworkTransactionContext, config: SchedulerConfig) -> Self {
Self {
ctx,
tasks: Tasks::new(),
in_flight: HashMap::new(),
config,
}
}
#[miden_instrument(
name = "ntx.scheduler.dispatch",
fields(tip.number = chain.chain_tip_header.block_num()),
err,
)]
pub async fn dispatch(&mut self, chain: &ChainState) -> anyhow::Result<()> {
let free = self.config.max_concurrent_txs.saturating_sub(self.tasks.len());
if free == 0 {
return Ok(());
}
let busy = self
.tasks
.tags()
.copied()
.chain(self.in_flight.keys().copied())
.collect::<Vec<_>>();
let block_num = chain.chain_tip_header.block_num();
let ready = self
.ctx
.db
.ready_accounts(
self.ctx.config.max_note_attempts,
block_num,
busy,
self.config.priority_accounts.clone(),
free,
)
.await
.context("failed to query accounts ready for a network transaction")?;
let dispatched = ready.len();
for account_id in ready {
let ctx = self.ctx.clone();
let chain = chain.clone();
self.tasks.spawn(account_id, ctx.build(account_id, chain));
debug!(
target: LOG_TARGET,
"dispatched a network transaction build",
account.id = account_id,
reference_block.number = block_num
);
}
debug!(
target: LOG_TARGET,
"network transaction pipeline",
reference_block.number = block_num,
build.dispatched.count = dispatched,
build.running.count = self.tasks.len(),
transaction.in_flight.count = self.in_flight.len(),
build.slots.count = self.config.max_concurrent_txs
);
Ok(())
}
pub fn handle_committed_block(&mut self, effects: &CommittedBlockEffects) {
let tip = effects.header.block_num();
let committed = effects.latest_tx_per_account();
self.in_flight.retain(|account_id, inflight| {
if committed.get(account_id) == Some(&inflight.tx_id) {
info!(
target: LOG_TARGET,
"submitted network transaction landed",
account.id = *account_id,
transaction.id = inflight.tx_id,
block.number = tip
);
return false;
}
if tip >= inflight.expires_at {
info!(
target: LOG_TARGET,
"submitted network transaction expired",
account.id = *account_id,
transaction.id = inflight.tx_id,
transaction.expires_at = inflight.expires_at,
tip.number = tip
);
return false;
}
true
});
}
pub async fn next_completion(&mut self) -> anyhow::Result<NetworkTransactionOutcome> {
match self.tasks.join_next().await {
Some((_, Ok(outcome))) => Ok(outcome),
Some((account_id, Err(err))) => Err(err).with_context(|| {
format!("network transaction build failed for account {account_id}")
}),
None => std::future::pending().await,
}
}
pub async fn handle_completion(
&mut self,
db: &NtxDbWriter,
outcome: NetworkTransactionOutcome,
) -> anyhow::Result<bool> {
let NetworkTransactionOutcome { account_id, block_num, notes, result } = outcome;
self.persist_note_updates(db, block_num, notes).await?;
match result {
NetworkTransactionResult::Submitted { tx_id } => {
let expires_at = block_num + u32::from(self.config.tx_expiration_delta.get());
info!(
target: LOG_TARGET,
"network transaction submitted; account is in flight",
account.id = account_id,
transaction.id = tx_id,
transaction.submitted_at = block_num,
transaction.expires_at = expires_at
);
self.in_flight.insert(account_id, Inflight { tx_id, expires_at });
Ok(true)
},
NetworkTransactionResult::Failed => Ok(true),
NetworkTransactionResult::NoWork => {
debug!(
target: LOG_TARGET,
"no viable notes for account",
account.id = account_id
);
Ok(false)
},
NetworkTransactionResult::Aborted(err) => {
error!(
&err,
target: LOG_TARGET,
"network transaction build could not run",
account.id = account_id
);
Ok(false)
},
}
}
async fn persist_note_updates(
&self,
db: &NtxDbWriter,
block_num: BlockNumber,
notes: NoteUpdates,
) -> anyhow::Result<()> {
let NoteUpdates { failed, discarded, scripts, eligibility } = notes;
if !eligibility.is_empty() {
db.update_note_eligibility(eligibility)
.await
.context("failed to correct note eligibility")?;
}
if !failed.is_empty() {
db.notes_failed(failed, block_num)
.await
.context("failed to persist note failures")?;
}
if !discarded.is_empty() {
db.discard_notes(discarded, block_num, self.ctx.config.max_note_attempts)
.await
.context("failed to discard notes")?;
}
for (script_root, script) in scripts {
db.insert_note_scripts(script_root, script)
.await
.context("failed to cache note script")?;
}
Ok(())
}
pub async fn shutdown(mut self) {
self.tasks.shutdown().await;
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use miden_protocol::account::AccountPatch;
use super::*;
use crate::NoteError;
use crate::db::queries::account_effect::NetworkAccountEffect;
use crate::test_utils::{
mock_block_header,
mock_network_account_id,
mock_network_account_id_seeded,
mock_transaction_id,
};
async fn test_scheduler() -> (Scheduler, NtxDbWriter, tempfile::TempDir) {
let (db, dir) = crate::db::test_setup().await;
let ctx = NetworkTransactionContext::test(&db.reader());
let config = SchedulerConfig {
max_concurrent_txs: 4,
tx_expiration_delta: NonZeroU16::new(30).unwrap(),
priority_accounts: Vec::new(),
};
(Scheduler::new(ctx, config), db, dir)
}
fn empty_effects(block_num: u32) -> CommittedBlockEffects {
CommittedBlockEffects {
header: mock_block_header(block_num.into()),
network_notes: vec![],
sponsorship_notes: vec![],
nullifiers: vec![],
network_account_updates: vec![],
account_transactions: vec![],
}
}
#[tokio::test]
async fn landed_transaction_releases_the_account() {
let (mut scheduler, _db, _dir) = test_scheduler().await;
let account_id = mock_network_account_id();
let tx_id = mock_transaction_id(1);
scheduler
.in_flight
.insert(account_id, Inflight { tx_id, expires_at: 31_u32.into() });
let mut effects = empty_effects(2);
effects.account_transactions = vec![(account_id, tx_id)];
scheduler.handle_committed_block(&effects);
assert!(
scheduler.in_flight.is_empty(),
"the block committing the submission must release the account",
);
}
#[tokio::test]
async fn unrelated_transaction_keeps_the_account_in_flight() {
let (mut scheduler, _db, _dir) = test_scheduler().await;
let account_id = mock_network_account_id();
scheduler.in_flight.insert(
account_id,
Inflight {
tx_id: mock_transaction_id(1),
expires_at: 31_u32.into(),
},
);
let mut effects = empty_effects(2);
effects.account_transactions = vec![(account_id, mock_transaction_id(9))];
scheduler.handle_committed_block(&effects);
assert!(scheduler.in_flight.contains_key(&account_id));
}
#[tokio::test]
async fn expired_submission_releases_the_account() {
let (mut scheduler, _db, _dir) = test_scheduler().await;
let account_id = mock_network_account_id();
scheduler.in_flight.insert(
account_id,
Inflight {
tx_id: mock_transaction_id(1),
expires_at: 31_u32.into(),
},
);
scheduler.handle_committed_block(&empty_effects(30));
assert!(scheduler.in_flight.contains_key(&account_id));
scheduler.handle_committed_block(&empty_effects(31));
assert!(scheduler.in_flight.is_empty());
}
#[tokio::test]
async fn in_flight_accounts_are_excluded_but_do_not_occupy_a_slot() {
let (mut scheduler, db, _dir) = test_scheduler().await;
let in_flight_account = mock_network_account_id();
let other_account = mock_network_account_id_seeded(42);
for account_id in [in_flight_account, other_account] {
db.upsert_account_for_test(
account_id,
crate::test_utils::mock_account(account_id),
mock_transaction_id(0),
)
.await
.unwrap();
db.insert_network_notes(vec![crate::test_utils::mock_single_target_note(
account_id, 1,
)])
.await
.unwrap();
}
scheduler.in_flight.insert(
in_flight_account,
Inflight {
tx_id: mock_transaction_id(1),
expires_at: 31_u32.into(),
},
);
let ready = db
.ready_accounts(
30,
BlockNumber::from(1),
vec![in_flight_account],
Vec::new(),
scheduler.config.max_concurrent_txs,
)
.await
.unwrap();
assert_eq!(ready, vec![other_account], "an in-flight account is not selected again");
}
#[tokio::test]
async fn uncommitted_accounts_are_not_ready() {
let (_scheduler, db, _dir) = test_scheduler().await;
let account_id = mock_network_account_id();
db.insert_network_notes(vec![crate::test_utils::mock_single_target_note(account_id, 1)])
.await
.unwrap();
assert!(
db.ready_accounts(30, BlockNumber::from(1), vec![], vec![], 4)
.await
.unwrap()
.is_empty(),
"a note targeting an account with no committed state is not dispatchable",
);
db.upsert_account_for_test(
account_id,
crate::test_utils::mock_account(account_id),
mock_transaction_id(0),
)
.await
.unwrap();
assert_eq!(
db.ready_accounts(30, BlockNumber::from(1), vec![], vec![], 4).await.unwrap(),
vec![account_id],
"the account becomes dispatchable once its state is committed",
);
}
#[tokio::test]
async fn submitted_outcome_persists_notes_and_records_the_submission() {
let (mut scheduler, db, _dir) = test_scheduler().await;
let account_id = mock_network_account_id();
let failed_note = crate::test_utils::mock_single_target_note(account_id, 1);
let discarded_note = crate::test_utils::mock_single_target_note(account_id, 2);
db.insert_network_notes(vec![failed_note.clone(), discarded_note.clone()])
.await
.unwrap();
let tx_id = mock_transaction_id(3);
let error: NoteError = Arc::new(std::io::Error::other("boom"));
let outcome = NetworkTransactionOutcome {
account_id,
block_num: 7_u32.into(),
notes: NoteUpdates {
failed: vec![(failed_note.as_note().nullifier(), error)],
discarded: vec![discarded_note.as_note().nullifier()],
eligibility: vec![],
scripts: vec![],
},
result: NetworkTransactionResult::Submitted { tx_id },
};
let refill = scheduler.handle_completion(&db, outcome).await.unwrap();
assert!(refill, "a submission frees a slot that should be refilled immediately");
assert!(scheduler.in_flight.contains_key(&account_id));
let failed = db.get_note_status(failed_note.as_note().id()).await.unwrap().unwrap();
assert_eq!(failed.attempt_count, 1);
let discarded = db.get_note_status(discarded_note.as_note().id()).await.unwrap().unwrap();
assert_eq!(discarded.attempt_count, 30, "a discarded note is pinned to the attempt cap");
}
#[tokio::test]
async fn no_work_outcome_does_not_refill_the_slot() {
let (mut scheduler, db, _dir) = test_scheduler().await;
let outcome = NetworkTransactionOutcome {
account_id: mock_network_account_id(),
block_num: 1_u32.into(),
notes: NoteUpdates::default(),
result: NetworkTransactionResult::NoWork,
};
let refill = scheduler.handle_completion(&db, outcome).await.unwrap();
assert!(!refill);
assert!(scheduler.in_flight.is_empty());
}
#[tokio::test]
async fn account_update_alone_does_not_resolve_an_in_flight_entry() {
let (mut scheduler, _db, _dir) = test_scheduler().await;
let account_id = mock_network_account_id();
scheduler.in_flight.insert(
account_id,
Inflight {
tx_id: mock_transaction_id(1),
expires_at: 31_u32.into(),
},
);
let mut effects = empty_effects(2);
effects.network_account_updates =
vec![(account_id, NetworkAccountEffect::Updated(AccountPatch::empty(account_id)))];
scheduler.handle_committed_block(&effects);
assert!(scheduler.in_flight.contains_key(&account_id));
}
}