Skip to content

Commit b74674e

Browse files
ntx-builder: replace dataless Notify with per-account watch (#2301)
1 parent 317069e commit b74674e

9 files changed

Lines changed: 738 additions & 266 deletions

File tree

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

Lines changed: 307 additions & 149 deletions
Large diffs are not rendered by default.

bin/ntx-builder/src/committed_block.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
use std::collections::HashMap;
2+
13
use miden_protocol::account::{AccountId, AccountUpdateDetails};
24
use miden_protocol::block::{BlockHeader, SignedBlock};
35
use miden_protocol::note::Nullifier;
@@ -90,4 +92,14 @@ impl CommittedBlockEffects {
9092
.then_some(*account_id)
9193
})
9294
}
95+
96+
/// The latest transaction committed against each account in this block.
97+
///
98+
/// `account_transactions` is in block order, so collecting into a map keeps the last
99+
/// transaction per account. Both `apply_committed_block` (to persist `accounts.last_tx_id`) and
100+
/// the coordinator (to populate each [`AccountView`](crate::coordinator)'s `last_committed_tx`)
101+
/// derive landing state from this single definition, so the two never disagree.
102+
pub fn latest_tx_per_account(&self) -> HashMap<AccountId, TransactionId> {
103+
self.account_transactions.iter().copied().collect()
104+
}
93105
}

bin/ntx-builder/src/coordinator.rs

Lines changed: 152 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -5,50 +5,53 @@ use anyhow::Context;
55
use miden_node_utils::shutdown::CancellationToken;
66
use miden_node_utils::tracing::miden_instrument;
77
use miden_protocol::account::AccountId;
8+
use miden_protocol::block::BlockNumber;
9+
use miden_protocol::transaction::TransactionId;
810
use miden_standards::note::AccountTargetNetworkNote;
9-
use tokio::sync::{Notify, Semaphore};
11+
use tokio::sync::{Semaphore, watch};
1012
use tokio::task::JoinSet;
1113

1214
use crate::LOG_TARGET;
1315
use crate::actor::{AccountActor, AccountActorContext};
1416
use crate::committed_block::CommittedBlockEffects;
1517

18+
// ACCOUNT VIEW
19+
// ================================================================================================
20+
21+
/// Per-account state the coordinator pushes to an actor on every committed block.
22+
///
23+
/// Every field is cumulative, so an actor that wakes after several blocks reads the latest view and
24+
/// answers entirely in memory: "did my submission land" (`last_committed_tx`), "has it expired"
25+
/// (`chain_tip` vs the submission block), and "is there new work" (`notes_seen` vs a local cursor).
26+
/// The view is intentionally bounded: a single latest-tx slot and a monotone counter, never a
27+
/// growing list.
28+
#[derive(Clone, Debug)]
29+
pub(crate) struct AccountView {
30+
/// Chain tip as of the latest committed block. Advances every block, so the view always changes
31+
/// and every actor wakes (cheaply, in memory) once per block.
32+
pub chain_tip: BlockNumber,
33+
/// Latest transaction committed against this account, mirroring `accounts.last_tx_id`. An actor
34+
/// waiting on its submission compares this against its own transaction id to confirm landing.
35+
pub last_committed_tx: Option<TransactionId>,
36+
/// Monotone count of network notes seen targeting this account since the actor was spawned. A
37+
/// local cursor on the actor side answers "is there new work" without a DB query.
38+
pub notes_seen: u64,
39+
}
40+
1641
// ACTOR HANDLE
1742
// ================================================================================================
1843

1944
/// Handle to an account actor spawned by the coordinator.
20-
#[derive(Clone)]
2145
struct ActorHandle {
22-
/// [`Notify`] shared with the actor. The coordinator calls [`Notify::notify_one`] when DB state
23-
/// relevant to the actor may have changed, the actor awaits [`Notify::notified`] and
24-
/// re-evaluates its state on wake-up.
25-
notify: Arc<Notify>,
46+
/// Sender half of the per-account [`AccountView`] watch channel. The coordinator updates the
47+
/// view on every committed block; the actor awaits changes on its receiver and re-evaluates its
48+
/// state from the pushed data rather than querying the DB.
49+
view_tx: watch::Sender<AccountView>,
2650
}
2751

2852
impl ActorHandle {
29-
fn new(notify: Arc<Notify>) -> Self {
30-
Self { notify }
31-
}
32-
33-
/// Signals the actor that DB state may have changed. Notifications coalesce when one is already
34-
/// pending.
35-
fn notify(&self) {
36-
self.notify.notify_one();
37-
}
38-
39-
/// Returns `true` if a notification is queued but not yet consumed by the actor.
40-
///
41-
/// Used after an actor has shut down to detect the race where a notification arrived just
42-
/// as the actor timed out. If so, the coordinator should respawn the actor.
43-
fn has_pending_notification(&self) -> bool {
44-
use futures::FutureExt;
45-
if self.notify.notified().now_or_never().is_some() {
46-
// Restore the permit so the respawned actor still sees the notification.
47-
self.notify.notify_one();
48-
true
49-
} else {
50-
false
51-
}
53+
fn new(view_tx: watch::Sender<AccountView>) -> Self {
54+
Self { view_tx }
5255
}
5356
}
5457

@@ -64,18 +67,18 @@ impl ActorHandle {
6467
/// 1. At the catch-up boundary, to spawn one actor per account returned by
6568
/// `Db::accounts_with_pending_notes()`.
6669
/// 2. On every committed block in steady state, via [`Coordinator::handle_committed_block`], which
67-
/// spawns missing actors for accounts that just received new network notes and wakes every
68-
/// active actor so it can re-evaluate its state from the DB.
70+
/// spawns missing actors for accounts that just received new network notes and pushes a fresh
71+
/// [`AccountView`] to every active actor so it can re-evaluate its state in memory.
6972
///
7073
/// Actors only operate on committed account state, so spawning is restricted to accounts whose
7174
/// creation has been committed: a spawn requested for a not-yet-committed account is deferred
7275
/// until the block carrying the account's creation arrives.
7376
///
74-
/// Notifications are coalesced through [`Notify`]: multiple wakes while an actor is busy
75-
/// collapse into one. Actors that crash repeatedly are deactivated after `max_account_crashes`
76-
/// failures.
77+
/// State changes are pushed through a per-account [`watch`] channel: intermediate views are
78+
/// coalesced (an actor busy for several blocks only ever sees the latest). Actors that crash
79+
/// repeatedly are deactivated after `max_account_crashes` failures.
7780
pub struct Coordinator {
78-
/// Mapping of network account IDs to their notification handles.
81+
/// Mapping of network account IDs to their view-channel handles.
7982
actor_registry: HashMap<AccountId, ActorHandle>,
8083

8184
/// Join set tracking each spawned actor task; used to detect intentional shutdowns vs. crashes.
@@ -159,14 +162,20 @@ impl Coordinator {
159162
return;
160163
}
161164

162-
let notify = Arc::new(Notify::new());
163-
let actor = AccountActor::new(account_id, &self.actor_context, notify.clone());
164-
let handle = ActorHandle::new(notify);
165+
let initial_view = AccountView {
166+
chain_tip: self.actor_context.state.chain.chain_tip_block_number(),
167+
last_committed_tx: None,
168+
notes_seen: 0,
169+
};
170+
let (view_tx, view_rx) = watch::channel(initial_view);
171+
let actor = AccountActor::new(account_id, &self.actor_context);
172+
let handle = ActorHandle::new(view_tx);
165173

166174
let semaphore = self.semaphore.clone();
167175
let shutdown = self.shutdown.clone();
168-
self.actor_join_set
169-
.spawn(Box::pin(async move { (account_id, actor.run(semaphore, shutdown).await) }));
176+
self.actor_join_set.spawn(Box::pin(async move {
177+
(account_id, actor.run(semaphore, view_rx, shutdown).await)
178+
}));
170179

171180
self.actor_registry.insert(account_id, handle);
172181
tracing::debug!(
@@ -212,8 +221,8 @@ impl Coordinator {
212221

213222
/// Reacts to a committed block: spawns actors for any newly-targeted network accounts whose
214223
/// committed state exists (deferring the rest until their creation commits), releases deferred
215-
/// spawns for accounts created by this block, and wakes every active actor so it can
216-
/// re-evaluate its state.
224+
/// spawns for accounts created by this block, and pushes a fresh [`AccountView`] to every
225+
/// active actor so it can re-evaluate its state in memory.
217226
pub async fn handle_committed_block(
218227
&mut self,
219228
effects: &CommittedBlockEffects,
@@ -230,33 +239,51 @@ impl Coordinator {
230239
.iter()
231240
.map(AccountTargetNetworkNote::target_account_id)
232241
.collect();
233-
for account_id in targeted {
234-
self.spawn_actor_when_committed(account_id).await?;
242+
for account_id in &targeted {
243+
self.spawn_actor_when_committed(*account_id).await?;
235244
}
236245

237-
for handle in self.actor_registry.values() {
238-
handle.notify();
246+
// Push the block's effects to every active actor. The latest transaction per account is the
247+
// same map `apply_committed_block` uses for `accounts.last_tx_id`, so the pushed
248+
// `last_committed_tx` agrees with the persisted state; the per-account note counts feed the
249+
// `notes_seen` work counter.
250+
let chain_tip = effects.header.block_num();
251+
let latest_tx = effects.latest_tx_per_account();
252+
let mut new_notes: HashMap<AccountId, u64> = HashMap::new();
253+
for note in &effects.network_notes {
254+
*new_notes.entry(note.target_account_id()).or_default() += 1;
255+
}
256+
257+
for (account_id, handle) in &self.actor_registry {
258+
let committed_tx = latest_tx.get(account_id).copied();
259+
let notes = new_notes.get(account_id).copied().unwrap_or(0);
260+
handle.view_tx.send_modify(|view| {
261+
view.chain_tip = chain_tip;
262+
if let Some(tx) = committed_tx {
263+
view.last_committed_tx = Some(tx);
264+
}
265+
view.notes_seen += notes;
266+
});
239267
}
240268
Ok(())
241269
}
242270

243271
/// Waits for the next actor to complete and handles the outcome.
244272
///
245-
/// Returns `Some(account_id)` if an actor should be respawned (because a notification arrived
246-
/// just as it shut down on idle timeout), or `None` otherwise. If no actors are currently
247-
/// running, this method waits indefinitely until new actors are spawned.
273+
/// Returns `Some(account_id)` if an actor should be respawned (because work reappeared for the
274+
/// account between its last view observation and its idle shutdown), or `None` otherwise. If no
275+
/// actors are currently running, this method waits indefinitely until new actors are spawned.
248276
pub async fn next(&mut self) -> anyhow::Result<Option<AccountId>> {
249277
let actor_result = self.actor_join_set.join_next().await;
250278
match actor_result {
251279
Some(Ok((account_id, Ok(())))) => {
252-
// Actor shut down intentionally (idle timeout or account removed). Remove from
253-
// registry and check if a notification arrived just as it shut down. If so, the
254-
// caller should respawn it.
255-
let should_respawn = self
256-
.actor_registry
257-
.remove(&account_id)
258-
.is_some_and(|handle| handle.has_pending_notification());
259-
280+
// Actor shut down intentionally on idle timeout, which only happens when it had no
281+
// pending notes. Reap it, then respawn if a block committed between its last
282+
// observation and its exit added (or re-armed) work for the account: that view
283+
// update went to a now-dropped receiver and would otherwise wait for the next
284+
// block.
285+
self.actor_registry.remove(&account_id);
286+
let should_respawn = self.account_has_pending_notes(account_id).await?;
260287
Ok(should_respawn.then_some(account_id))
261288
},
262289
Some(Ok((account_id, Err(err)))) => {
@@ -284,6 +311,17 @@ impl Coordinator {
284311
}
285312
}
286313

314+
/// Returns `true` if the account has any pending notes: eligible now, or awaiting a backoff or
315+
/// execution-hint window. Used to decide whether to respawn an actor that just idle-timed-out.
316+
async fn account_has_pending_notes(&self, account_id: AccountId) -> anyhow::Result<bool> {
317+
self.actor_context
318+
.state
319+
.db
320+
.account_has_pending_notes(account_id, self.actor_context.config.max_note_attempts)
321+
.await
322+
.context("failed to check pending notes when reaping an idle actor")
323+
}
324+
287325
/// Waits for all currently running actors to exit after cancellation.
288326
pub async fn shutdown(&mut self) -> anyhow::Result<()> {
289327
while let Some(result) = self.actor_join_set.join_next().await {
@@ -322,15 +360,22 @@ impl Coordinator {
322360

323361
#[cfg(test)]
324362
mod tests {
325-
use futures::FutureExt;
326-
327363
use super::*;
328364
use crate::test_utils::*;
329365

330-
/// Registers a dummy actor handle (no real actor task) in the coordinator's registry.
331-
fn register_dummy_actor(coordinator: &mut Coordinator, account_id: AccountId) {
332-
let notify = Arc::new(Notify::new());
333-
coordinator.actor_registry.insert(account_id, ActorHandle::new(notify));
366+
/// Registers a dummy actor handle (no real actor task) in the coordinator's registry and
367+
/// returns the view receiver so the test can observe what the coordinator pushes.
368+
fn register_dummy_actor(
369+
coordinator: &mut Coordinator,
370+
account_id: AccountId,
371+
) -> watch::Receiver<AccountView> {
372+
let (view_tx, view_rx) = watch::channel(AccountView {
373+
chain_tip: BlockNumber::GENESIS,
374+
last_committed_tx: None,
375+
notes_seen: 0,
376+
});
377+
coordinator.actor_registry.insert(account_id, ActorHandle::new(view_tx));
378+
view_rx
334379
}
335380

336381
/// Seeds a committed row for `account_id` so the coordinator's spawn check sees the account.
@@ -474,12 +519,13 @@ mod tests {
474519
}
475520

476521
#[tokio::test]
477-
async fn handle_committed_block_notifies_existing_actors() {
522+
async fn handle_committed_block_pushes_view_to_existing_actors() {
478523
let (mut coordinator, _dir, _rx) = Coordinator::test().await;
479524

480525
let bystander = mock_network_account_id();
481-
register_dummy_actor(&mut coordinator, bystander);
482-
let bystander_notify = coordinator.actor_registry.get(&bystander).unwrap().notify.clone();
526+
let mut bystander_rx = register_dummy_actor(&mut coordinator, bystander);
527+
// Mark the initial view as seen so the post-block update is observable as a change.
528+
let _ = bystander_rx.borrow_and_update();
483529

484530
let target = mock_network_account_id_seeded(42);
485531
seed_committed_account(&coordinator, target).await;
@@ -495,13 +541,51 @@ mod tests {
495541
coordinator.handle_committed_block(&effects).await.unwrap();
496542

497543
assert!(
498-
bystander_notify.notified().now_or_never().is_some(),
499-
"every registered actor should be notified on a committed block",
544+
bystander_rx.has_changed().unwrap(),
545+
"every registered actor should receive a view update on a committed block",
500546
);
547+
let view = bystander_rx.borrow_and_update();
548+
assert_eq!(view.chain_tip, 1_u32.into(), "the view carries the new chain tip");
549+
assert_eq!(view.notes_seen, 0, "a bystander targeted by no note sees no new work");
550+
drop(view);
501551

502552
assert!(
503553
coordinator.actor_registry.contains_key(&target),
504554
"freshly-targeted account should get an actor",
505555
);
506556
}
557+
558+
/// The pushed view carries the account's latest committed transaction (for landing detection)
559+
/// and a bumped note counter (for the work signal).
560+
#[tokio::test]
561+
async fn handle_committed_block_view_carries_landing_and_new_notes() {
562+
let (mut coordinator, _dir, _rx) = Coordinator::test().await;
563+
564+
let account_id = mock_network_account_id();
565+
// A dummy handle for the targeted account so the coordinator updates it in place instead of
566+
// spawning a real actor (which would own the receiver and hide it from the test).
567+
let mut rx = register_dummy_actor(&mut coordinator, account_id);
568+
let _ = rx.borrow_and_update();
569+
570+
let tx_id = mock_transaction_id(5);
571+
let note = mock_single_target_note(account_id, 10);
572+
let effects = CommittedBlockEffects {
573+
header: mock_block_header(3_u32.into()),
574+
network_notes: vec![note],
575+
nullifiers: vec![],
576+
network_account_updates: vec![],
577+
account_transactions: vec![(account_id, tx_id)],
578+
};
579+
580+
coordinator.handle_committed_block(&effects).await.unwrap();
581+
582+
let view = rx.borrow_and_update();
583+
assert_eq!(view.chain_tip, 3_u32.into());
584+
assert_eq!(
585+
view.last_committed_tx,
586+
Some(tx_id),
587+
"the account's latest committed tx is pushed for in-memory landing detection",
588+
);
589+
assert_eq!(view.notes_seen, 1, "one note targeting the account bumps the work counter");
590+
}
507591
}

0 commit comments

Comments
 (0)