@@ -5,50 +5,53 @@ use anyhow::Context;
55use miden_node_utils:: shutdown:: CancellationToken ;
66use miden_node_utils:: tracing:: miden_instrument;
77use miden_protocol:: account:: AccountId ;
8+ use miden_protocol:: block:: BlockNumber ;
9+ use miden_protocol:: transaction:: TransactionId ;
810use miden_standards:: note:: AccountTargetNetworkNote ;
9- use tokio:: sync:: { Notify , Semaphore } ;
11+ use tokio:: sync:: { Semaphore , watch } ;
1012use tokio:: task:: JoinSet ;
1113
1214use crate :: LOG_TARGET ;
1315use crate :: actor:: { AccountActor , AccountActorContext } ;
1416use 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 ) ]
2145struct 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
2852impl 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.
7780pub 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) ]
324362mod 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