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

257 lines
8.9 KiB
Rust

//! Per-backend writer task for `WritePolicy::Queued` backends.
//!
//! The task drains a per-backend mpsc, optionally batching/coalescing within
//! a configured window, and invokes `ArchiveBackend` methods directly. Errors
//! drift health on the parent registration; they do not propagate to the
//! caller.
use std::sync::Arc;
use std::time::Duration;
use chrono::Utc;
use tokio::sync::{mpsc, oneshot, watch, RwLock};
use tokio::task::JoinHandle;
use tracing::{debug, warn};
use uuid::Uuid;
use crate::backend::{ArchiveBackend, HealthStatus};
use super::OverflowPolicy;
#[derive(Debug)]
pub enum WriteOp {
PutSession(crate::types::SessionMetadata),
AppendMessages {
scroll_id: Uuid,
msgs: Vec<crate::types::MessageRecord>,
},
DeleteSession {
scroll_id: Uuid,
},
ClearSessionMessages {
scroll_id: Uuid,
},
AppendDagEdge(crate::types::DagEdge),
AppendMetaEvents {
scroll_id: Uuid,
events: Vec<crate::types::MetaEventRecord>,
},
Shutdown(oneshot::Sender<()>),
}
impl WriteOp {
pub fn op_label(&self) -> &'static str {
match self {
WriteOp::PutSession(_) => "put_session",
WriteOp::AppendMessages { .. } => "append_messages",
WriteOp::DeleteSession { .. } => "delete_session",
WriteOp::ClearSessionMessages { .. } => "clear_session_messages",
WriteOp::AppendDagEdge(_) => "append_dag_edge",
WriteOp::AppendMetaEvents { .. } => "append_meta_events",
WriteOp::Shutdown(_) => "shutdown",
}
}
}
#[derive(Debug)]
pub struct WriterHandle {
pub sender: mpsc::Sender<WriteOp>,
pub overflow: OverflowPolicy,
pub queue_depth: watch::Receiver<usize>,
pub join: tokio::sync::Mutex<Option<JoinHandle<()>>>,
pub backend_name: String,
}
impl WriterHandle {
pub async fn enqueue(&self, op: WriteOp) -> Result<(), crate::error::ArchivistError> {
match self.overflow {
OverflowPolicy::Block => self.sender.send(op).await.map_err(|_| {
crate::error::ArchivistError::Other(format!(
"writer task for `{}` has closed",
self.backend_name
))
}),
OverflowPolicy::Error => self.sender.try_send(op).map_err(|e| match e {
mpsc::error::TrySendError::Full(op) => {
crate::error::ArchivistError::WriteQueueFull {
backend: self.backend_name.clone(),
op: op.op_label(),
}
}
mpsc::error::TrySendError::Closed(_) => {
crate::error::ArchivistError::Other(format!(
"writer task for `{}` has closed",
self.backend_name
))
}
}),
OverflowPolicy::DropOldest => {
// Tokio mpsc can't truly "drop oldest" without draining from the
// other side; we approximate with "drop newest when full". For
// observability sinks this is acceptable — the contract is
// "never block, may lose data".
let _ = self.sender.try_send(op);
Ok(())
}
}
}
pub fn queue_depth_now(&self) -> usize {
*self.queue_depth.borrow()
}
}
#[allow(clippy::too_many_arguments)]
pub fn spawn_writer(
backend: Arc<dyn ArchiveBackend>,
backend_name: String,
capacity: usize,
batch_window: Duration,
overflow: OverflowPolicy,
health: Arc<RwLock<HealthStatus>>,
last_error: Arc<RwLock<Option<(chrono::DateTime<chrono::Utc>, String)>>>,
consecutive_failures: Arc<RwLock<u32>>,
) -> WriterHandle {
let (tx, mut rx) = mpsc::channel::<WriteOp>(capacity);
let (depth_tx, depth_rx) = watch::channel(0usize);
let join = tokio::spawn({
let backend_name = backend_name.clone();
async move {
const FAILURE_THRESHOLD: u32 = 5;
loop {
let Some(first) = rx.recv().await else { break };
let mut batch: Vec<WriteOp> = vec![first];
let deadline = tokio::time::Instant::now() + batch_window;
while tokio::time::Instant::now() < deadline {
match tokio::time::timeout_at(deadline, rx.recv()).await {
Ok(Some(op)) => batch.push(op),
Ok(None) => break,
Err(_) => break,
}
}
let _ = depth_tx.send(rx.len());
let coalesced = coalesce(batch);
let mut shutdown_ack: Option<oneshot::Sender<()>> = None;
for op in coalesced {
if let WriteOp::Shutdown(ack) = op {
shutdown_ack = Some(ack);
break;
}
match dispatch_op(&*backend, op).await {
Ok(()) => {
*consecutive_failures.write().await = 0;
let mut h = health.write().await;
if matches!(*h, HealthStatus::Degraded { .. }) {
*h = HealthStatus::Healthy;
}
}
Err(e) => {
warn!(
backend = backend_name.as_str(),
error = %e,
"queued write failed; drifting health"
);
let mut n = consecutive_failures.write().await;
*n = n.saturating_add(1);
*last_error.write().await = Some((Utc::now(), format!("{e}")));
let mut h = health.write().await;
if *n >= FAILURE_THRESHOLD {
*h = HealthStatus::Unavailable {
reason: format!("{} consecutive failures", *n),
};
} else {
*h = HealthStatus::Degraded { reason: format!("{e}") };
}
}
}
}
if let Some(ack) = shutdown_ack {
debug!(backend = backend_name.as_str(), "writer task shutting down");
let _ = ack.send(());
break;
}
}
}
});
WriterHandle {
sender: tx,
overflow,
queue_depth: depth_rx,
join: tokio::sync::Mutex::new(Some(join)),
backend_name,
}
}
fn coalesce(batch: Vec<WriteOp>) -> Vec<WriteOp> {
let mut out: Vec<WriteOp> = Vec::with_capacity(batch.len());
for op in batch {
let merged = match (out.last_mut(), &op) {
(
Some(WriteOp::AppendMessages { scroll_id: a, .. }),
WriteOp::AppendMessages { scroll_id: b, .. },
) if a == b => true,
(
Some(WriteOp::AppendMetaEvents { scroll_id: a, .. }),
WriteOp::AppendMetaEvents { scroll_id: b, .. },
) if a == b => true,
_ => false,
};
if merged {
match out.last_mut().unwrap() {
WriteOp::AppendMessages { msgs: m1, .. } => {
if let WriteOp::AppendMessages { msgs: m2, .. } = op {
m1.extend(m2);
continue;
}
}
WriteOp::AppendMetaEvents { events: e1, .. } => {
if let WriteOp::AppendMetaEvents { events: e2, .. } = op {
e1.extend(e2);
continue;
}
}
_ => {}
}
}
out.push(op);
}
out
}
async fn dispatch_op(backend: &dyn ArchiveBackend, op: WriteOp) -> crate::error::Result<()> {
match op {
WriteOp::PutSession(meta) => backend.put_session(meta).await,
WriteOp::AppendMessages { scroll_id, msgs } => {
backend.append_messages(scroll_id, msgs).await
}
WriteOp::DeleteSession { scroll_id } => backend.delete_session(scroll_id).await,
WriteOp::ClearSessionMessages { scroll_id } => {
backend.clear_session_messages(scroll_id).await
}
WriteOp::AppendDagEdge(edge) => {
if let Some(d) = backend.as_dag() {
d.append_dag_edge(edge).await
} else {
Ok(())
}
}
WriteOp::AppendMetaEvents { scroll_id, events } => {
if let Some(m) = backend.as_meta_events() {
m.append_meta_events(scroll_id, events).await
} else {
Ok(())
}
}
WriteOp::Shutdown(_) => Ok(()),
}
}