Skip to content

Commit 2f3bdcf

Browse files
authored
fix: close connection when stream lagged (#77)
1 parent 4b08fbb commit 2f3bdcf

3 files changed

Lines changed: 319 additions & 29 deletions

File tree

src/metrics.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ pub struct Metrics {
55
pub sessions_created_total: Counter,
66
/// existing session got new tokens
77
pub sessions_updated_total: Counter,
8-
/// session removed (idle ttl or last client left)
8+
/// session reaped by the cleanup sweep (idle past ttl)
99
pub sessions_expired_total: Counter,
1010
/// currently live sessions
1111
pub active_sessions: Gauge,

src/services/session_manager.rs

Lines changed: 155 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,19 @@ use std::collections::{HashMap, HashSet};
2424
use std::convert::Infallible;
2525
use std::sync::Arc;
2626
use std::time::Duration;
27-
use tokio::sync::mpsc;
27+
use tokio::sync::{broadcast, mpsc};
2828
use tokio_stream::wrappers::BroadcastStream;
2929

3030
const 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+
3240
pub 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

Comments
 (0)