Files
dirigent/crates/dirigent_archivist/src/import/progress.rs
T
2026-05-08 01:59:04 +02:00

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;
}
}