118 lines
4.0 KiB
Rust
118 lines
4.0 KiB
Rust
//! 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<usize> },
|
|
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<ImportProgressEvent> },
|
|
Noop,
|
|
}
|
|
|
|
impl ImportProgressSink {
|
|
pub fn channel() -> (Self, mpsc::Receiver<ImportProgressEvent>) {
|
|
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;
|
|
}
|
|
}
|