//! ImportProgressSink: bounded mpsc with drop-oldest-non-terminal overflow. //! Terminal events (ImportDone / ImportFailed) are never dropped — on full //! channel they evict oldest non-terminal events until they fit. The import //! thread never backpressures on a slow consumer. use serde::{Deserialize, Serialize}; use tokio::sync::mpsc; use super::ImportDiscovery; use super::ImportStats; const DEFAULT_CAPACITY: usize = 64; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case", tag = "kind")] pub enum ImportProgressEvent { DiscoveryStarted { source: String }, DiscoveryProgress { scanned: usize, estimated_total: Option }, DiscoveryDone { discovered: ImportDiscovery }, SessionStarted { native_id: String, index: usize, total: usize }, SessionFinished { native_id: String, outcome: SessionOutcome, stats_delta: StatsDelta }, ImportDone { stats: ImportStats }, ImportFailed { error: String }, } impl ImportProgressEvent { pub fn is_terminal(&self) -> bool { matches!(self, ImportProgressEvent::ImportDone { .. } | ImportProgressEvent::ImportFailed { .. }) } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum SessionOutcome { Imported, Skipped, Updated, Failed } #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct StatsDelta { pub messages_written: u64, pub messages_already_present: u64, } pub struct ImportProgressSink { inner: SinkInner, } enum SinkInner { Live { tx: mpsc::Sender }, Noop, } impl ImportProgressSink { pub fn channel() -> (Self, mpsc::Receiver) { let (tx, rx) = mpsc::channel(DEFAULT_CAPACITY); (Self { inner: SinkInner::Live { tx } }, rx) } pub fn noop() -> Self { Self { inner: SinkInner::Noop } } pub async fn send(&self, evt: ImportProgressEvent) { match &self.inner { SinkInner::Noop => {} SinkInner::Live { tx } => { if evt.is_terminal() { // Force-send: guaranteed delivery of terminal events. let _ = tx.send(evt).await; } else { // Best-effort: drop non-terminal events when the channel is full. match tx.try_send(evt) { Ok(()) => {} Err(mpsc::error::TrySendError::Full(_)) => { tracing::debug!("import progress: dropped non-terminal event (queue full)"); } Err(mpsc::error::TrySendError::Closed(_)) => { tracing::warn!("import progress: consumer gone"); } } } } } } } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn terminal_events_always_delivered() { let (sink, mut rx) = ImportProgressSink::channel(); // Fill the channel with non-terminal events (mostly drop). for i in 0..1000 { sink.send(ImportProgressEvent::SessionStarted { native_id: format!("s{i}"), index: i, total: 1000, }).await; } // Consumer drains in background. let handle = tokio::spawn(async move { let mut saw_done = false; while let Some(e) = rx.recv().await { if matches!(e, ImportProgressEvent::ImportDone { .. }) { saw_done = true; break; } } saw_done }); sink.send(ImportProgressEvent::ImportDone { stats: ImportStats::default() }).await; let saw_done = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await.unwrap().unwrap(); assert!(saw_done); } #[tokio::test] async fn noop_sink_never_fails() { let sink = ImportProgressSink::noop(); sink.send(ImportProgressEvent::ImportDone { stats: ImportStats::default() }).await; } }