use super::config::SinkPoolConfig;
use super::worker::{ShardWorker, WorkerReport};
use super::{EncodedChunk, ShardWriter};
use crate::backpressure::InflightBudget;
use crate::error::SinkError;
use crate::metrics::SinkShardMetrics;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{mpsc, watch};
use tokio::task::JoinHandle;
use tokio::time::Instant;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct DrainReport {
pub flushed: u64,
pub abandoned: u64,
}
#[derive(Debug)]
pub struct SinkPool<W: ShardWriter> {
writer: Arc<W>,
endpoints: Vec<Arc<Vec<W::Endpoint>>>,
workers: Vec<JoinHandle<WorkerReport>>,
drain_tx: watch::Sender<Option<Instant>>,
metrics: Vec<Arc<SinkShardMetrics>>,
}
const BACKSTOP_GRACE: Duration = Duration::from_secs(1);
impl<W: ShardWriter> SinkPool<W> {
#[must_use]
#[expect(
clippy::too_many_arguments,
reason = "construction-time wiring call, used once by the pipeline runtime"
)]
pub fn spawn(
writer: Arc<W>,
shard_endpoints: Vec<Vec<W::Endpoint>>,
receivers: Vec<mpsc::Receiver<EncodedChunk>>,
config: SinkPoolConfig,
budget: Arc<InflightBudget>,
metrics: Vec<SinkShardMetrics>,
pipeline_name: &str,
runtime: &tokio::runtime::Handle,
) -> Self {
assert_eq!(
shard_endpoints.len(),
receivers.len(),
"one receiver per shard"
);
assert_eq!(
shard_endpoints.len(),
metrics.len(),
"one metrics set per shard"
);
assert!(
shard_endpoints.iter().all(|r| !r.is_empty()),
"every shard needs at least one replica"
);
assert!(
config.inflight.max_per_shard > 0,
"inflight.max_per_shard must be greater than zero"
);
if config.retry.stalls_indefinitely() {
tracing::warn!(
retry_max = ?config.retry.max,
"retry.max_attempts is 0 (unbounded) and retry.max is over 5m: once a \
shard backs off to its ceiling it sleeps that long between attempts and \
never abandons the batch, so a stalled shard looks identical to a \
healthy idle one. Bound it with retry.max_attempts, or lower retry.max. \
If this is deliberate, watch spate_sink_retry_backoff_seconds — it reads \
the backoff a shard is sleeping between attempts right now."
);
}
let (drain_tx, drain_rx) = watch::channel(None);
let endpoints: Vec<Arc<Vec<W::Endpoint>>> =
shard_endpoints.into_iter().map(Arc::new).collect();
let nonce = run_nonce();
let metrics: Vec<Arc<SinkShardMetrics>> = metrics.into_iter().map(Arc::new).collect();
let workers = receivers
.into_iter()
.zip(metrics.iter().map(Arc::clone))
.enumerate()
.map(|(shard, (rx, shard_metrics))| {
let worker = ShardWorker {
shard: u32::try_from(shard).unwrap_or(u32::MAX),
writer: Arc::clone(&writer),
endpoints: Arc::clone(&endpoints[shard]),
rx,
cfg: config,
budget: Arc::clone(&budget),
metrics: shard_metrics,
drain_deadline: drain_rx.clone(),
token_prefix: format!("{pipeline_name}-{nonce}-{shard}-"),
};
runtime.spawn(worker.run())
})
.collect();
SinkPool {
writer,
endpoints,
workers,
drain_tx,
metrics,
}
}
#[cfg(test)]
pub(crate) fn from_workers(
writer: Arc<W>,
workers: Vec<JoinHandle<WorkerReport>>,
drain_tx: watch::Sender<Option<Instant>>,
metrics: Vec<Arc<SinkShardMetrics>>,
) -> Self {
SinkPool {
writer,
endpoints: Vec::new(),
workers,
drain_tx,
metrics,
}
}
pub async fn probe_all(&self) -> Result<(), SinkError> {
for shard in &self.endpoints {
for endpoint in shard.iter() {
self.writer.probe(endpoint).await?;
}
}
Ok(())
}
pub async fn drain(self, deadline: Duration) -> DrainReport {
let deadline_at = Instant::now() + deadline;
let _ = self.drain_tx.send(Some(deadline_at));
let hard_at = deadline_at + BACKSTOP_GRACE;
let mut report = WorkerReport::default();
let mut forced = 0usize;
for (shard, mut handle) in self.workers.into_iter().enumerate() {
match tokio::time::timeout_at(hard_at, &mut handle).await {
Ok(Ok(r)) => report.absorb(r),
Ok(Err(join_err)) => {
tracing::error!(shard, error = %join_err, "sink shard worker panicked");
}
Err(_) => {
handle.abort();
self.metrics[shard].drain_overrun();
if forced == 0 {
tracing::error!(
shard,
grace = ?BACKSTOP_GRACE,
"sink shard worker did not return by the drain deadline and was \
force-aborted — a framework bug, not an operating condition. Its \
acknowledgements fail and that data replays after restart, but its \
flushed/abandoned counts are missing from the drain report."
);
} else {
tracing::error!(
shard,
"sink shard worker force-aborted: the drain budget was already spent \
by an earlier shard, so this one was given no time of its own. It \
may have been healthy. Same consequence — its acknowledgements fail \
and that data replays — but diagnose the first shard reported above."
);
}
forced += 1;
}
}
}
DrainReport {
flushed: report.flushed,
abandoned: report.abandoned,
}
}
}
fn run_nonce() -> String {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let pid = u64::from(std::process::id());
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
format!("{:x}", nanos ^ (pid << 48) ^ (n << 40))
}
#[cfg(all(test, not(loom)))]
mod nonce_tests {
use super::run_nonce;
#[test]
fn nonces_differ_within_and_across_calls() {
let a = run_nonce();
let b = run_nonce();
assert_ne!(a, b, "two pools in one process must not share tokens");
assert!(!a.is_empty() && a.len() <= 16);
}
}