@@ -24,11 +24,19 @@ use std::collections::{HashMap, HashSet};
2424use std:: convert:: Infallible ;
2525use std:: sync:: Arc ;
2626use std:: time:: Duration ;
27- use tokio:: sync:: mpsc;
27+ use tokio:: sync:: { broadcast , mpsc} ;
2828use tokio_stream:: wrappers:: BroadcastStream ;
2929
3030const TOKEN_LIST_CACHE_TTL : Duration = Duration :: from_hours ( 5 ) ;
3131
32+ /// State for [`SessionManager::broadcast_events_with_lag_close`]'s `unfold`.
33+ /// `Closed` is entered right after the lag-close event so the very next poll
34+ /// ends the stream.
35+ enum LagCloseState {
36+ Streaming ( BroadcastStream < BalanceEvent > ) ,
37+ Closed ,
38+ }
39+
3240pub struct SessionConfig {
3341 // interval for multicall for the whole watched token list
3442 pub snapshot_interval : usize ,
@@ -346,6 +354,55 @@ impl SessionManager {
346354 }
347355 }
348356
357+ /// Bridge the per-session broadcast channel into a stream of
358+ /// [`BalanceEvent`]s, converting a broadcast `Lagged` into a clean close.
359+ ///
360+ /// The wire protocol is snapshot-then-diffs: the initial snapshot is sent
361+ /// once and diffs are never re-sent. If a slow client's receiver overflows
362+ /// the broadcast ring buffer, those diffs are lost for good — the server's
363+ /// snapshot has already advanced, so the periodic refresh won't re-emit
364+ /// them either. Silently continuing would leave the client permanently
365+ /// stale. Instead we emit one `Error { code: 503 }` event and end the
366+ /// stream, so the client reconnects and is re-seeded with a full snapshot.
367+ fn broadcast_events_with_lag_close (
368+ rx : broadcast:: Receiver < BalanceEvent > ,
369+ metrics : Arc < Metrics > ,
370+ session : Session ,
371+ ) -> impl Stream < Item = BalanceEvent > {
372+ stream:: unfold (
373+ LagCloseState :: Streaming ( BroadcastStream :: new ( rx) ) ,
374+ move |state| {
375+ let metrics = Arc :: clone ( & metrics) ;
376+ async move {
377+ let mut stream = match state {
378+ LagCloseState :: Closed => return None ,
379+ LagCloseState :: Streaming ( stream) => stream,
380+ } ;
381+
382+ match stream. next ( ) . await {
383+ // Sender dropped (session reaped) — end the stream.
384+ None => None ,
385+ Some ( Ok ( event) ) => Some ( ( event, LagCloseState :: Streaming ( stream) ) ) ,
386+ Some ( Err ( err) ) => {
387+ metrics. broadcast_lagged_total . increment ( 1 ) ;
388+ tracing:: warn!(
389+ error = %err,
390+ session = %session,
391+ "sse client lagged behind broadcast, closing stream to force reconnect" ,
392+ ) ;
393+ let close_event = BalanceEvent :: Error {
394+ code : 503 ,
395+ message : "event stream lagged; reconnect for a fresh snapshot"
396+ . into ( ) ,
397+ } ;
398+ Some ( ( close_event, LagCloseState :: Closed ) )
399+ }
400+ }
401+ }
402+ } ,
403+ )
404+ }
405+
349406 pub async fn create_sse_connection (
350407 self : Arc < Self > ,
351408 session : Session ,
@@ -392,31 +449,19 @@ impl SessionManager {
392449 let manager_for_cleanup = Arc :: clone ( & self . sub_manager ) ;
393450 let metrics = Arc :: clone ( & self . metrics ) ;
394451
395- let broadcast_stream = BroadcastStream :: new ( rx) . filter_map ( move |result| {
396- let metrics = Arc :: clone ( & metrics) ;
397- async move {
398- match result {
399- Ok ( event) => match Self :: balance_event_to_sse ( event) {
400- Ok ( sse_event) => Some ( Ok ( sse_event) ) ,
401- Err ( err) => {
402- tracing:: error!(
403- error = %err,
404- "failed to serialize balance event as sse event" ,
405- ) ;
406- None
407- }
408- } ,
452+ let broadcast_stream = Self :: broadcast_events_with_lag_close ( rx, metrics, session)
453+ . filter_map ( move |event| async move {
454+ match Self :: balance_event_to_sse ( event) {
455+ Ok ( sse_event) => Some ( Ok ( sse_event) ) ,
409456 Err ( err) => {
410- metrics. broadcast_lagged_total . increment ( 1 ) ;
411- tracing:: warn!(
457+ tracing:: error!(
412458 error = %err,
413- "sse client lagged behind broadcast, dropping event" ,
459+ "failed to serialize balance event as sse event" ,
414460 ) ;
415461 None
416462 }
417463 }
418- }
419- } ) ;
464+ } ) ;
420465
421466 let sse_stream = initial_stream. chain ( broadcast_stream) ;
422467
@@ -435,3 +480,93 @@ impl SessionManager {
435480 }
436481 }
437482}
483+
484+ #[ cfg( test) ]
485+ mod broadcast_lag_tests {
486+ //! Regression tests for the lagged-SSE-client fix.
487+ //!
488+ //! Diffs are broadcast once and never re-sent, so a client whose receiver
489+ //! overflows the broadcast ring buffer used to silently miss updates and
490+ //! drift permanently stale (the old code logged a warning and dropped the
491+ //! events). The stream must instead emit a single 503 close event and end,
492+ //! so the client reconnects for a fresh snapshot.
493+
494+ use super :: * ;
495+ use crate :: domain:: EvmNetwork ;
496+ use uuid:: Uuid ;
497+
498+ fn test_session ( ) -> Session {
499+ Session {
500+ owner : Address :: ZERO ,
501+ network : EvmNetwork :: Eth ,
502+ client_id : Uuid :: from_u128 ( 1 ) ,
503+ }
504+ }
505+
506+ fn balance_update_event ( n : u8 ) -> BalanceEvent {
507+ let mut balances = HashMap :: new ( ) ;
508+ balances. insert ( Address :: from ( [ n; 20 ] ) , n. to_string ( ) ) ;
509+ BalanceEvent :: BalanceUpdate ( balances)
510+ }
511+
512+ /// The regression: overflow the ring buffer, then the stream must yield
513+ /// exactly one 503 error event and stop — no silent drop, no further
514+ /// events.
515+ #[ tokio:: test]
516+ async fn lagged_client_gets_close_event_then_stream_ends ( ) {
517+ let metrics = Arc :: new ( Metrics :: install ( ) ) ;
518+ // Tiny buffer so we can overflow it deterministically.
519+ let ( tx, rx) = broadcast:: channel ( 4 ) ;
520+
521+ // Send more than capacity with nobody draining: the receiver falls
522+ // behind by more than the buffer, so its first recv yields `Lagged`.
523+ for i in 0 ..8u8 {
524+ tx. send ( balance_update_event ( i) ) . expect ( "receiver is alive" ) ;
525+ }
526+
527+ let events: Vec < BalanceEvent > =
528+ SessionManager :: broadcast_events_with_lag_close ( rx, metrics, test_session ( ) )
529+ . collect ( )
530+ . await ;
531+
532+ assert_eq ! (
533+ events. len( ) ,
534+ 1 ,
535+ "lagged stream must yield only the close event, got {events:?}"
536+ ) ;
537+ match & events[ 0 ] {
538+ BalanceEvent :: Error { code, .. } => assert_eq ! ( * code, 503 ) ,
539+ other => panic ! ( "expected a 503 error close event, got {other:?}" ) ,
540+ }
541+ }
542+
543+ /// A client that keeps up receives every diff in order, and the stream
544+ /// ends cleanly when the sender is dropped — no spurious close event.
545+ #[ tokio:: test]
546+ async fn healthy_client_receives_all_events_without_close ( ) {
547+ let metrics = Arc :: new ( Metrics :: install ( ) ) ;
548+ let ( tx, rx) = broadcast:: channel ( 16 ) ;
549+
550+ for i in 0 ..3u8 {
551+ tx. send ( balance_update_event ( i) ) . expect ( "receiver is alive" ) ;
552+ }
553+ drop ( tx) ; // no more events → the stream should end on its own
554+
555+ let events: Vec < BalanceEvent > =
556+ SessionManager :: broadcast_events_with_lag_close ( rx, metrics, test_session ( ) )
557+ . collect ( )
558+ . await ;
559+
560+ assert_eq ! (
561+ events. len( ) ,
562+ 3 ,
563+ "every in-capacity event must pass through untouched"
564+ ) ;
565+ for event in & events {
566+ match event {
567+ BalanceEvent :: BalanceUpdate ( _) => { }
568+ other => panic ! ( "healthy stream must not inject a close event: {other:?}" ) ,
569+ }
570+ }
571+ }
572+ }
0 commit comments