Skip to main content

etl_core/sink/
pool.rs

1//! The sink pool: one worker task per shard on the I/O runtime.
2
3use super::config::SinkPoolConfig;
4use super::worker::{ShardWorker, WorkerReport};
5use super::{EncodedChunk, ShardWriter};
6use crate::backpressure::InflightBudget;
7use crate::error::SinkError;
8use crate::metrics::SinkShardMetrics;
9use std::sync::Arc;
10use std::time::Duration;
11use tokio::sync::{mpsc, watch};
12use tokio::task::JoinHandle;
13use tokio::time::Instant;
14
15/// What a full-pool drain accomplished.
16#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
17pub struct DrainReport {
18    /// Batches durably written over the pool's lifetime.
19    pub flushed: u64,
20    /// Batches abandoned (failed acknowledgements; replay after restart).
21    pub abandoned: u64,
22}
23
24/// Shard workers plus the handles to probe and drain them.
25///
26/// Construction wiring: build the queues with
27/// [`shard_queues`](super::shard_queues), hand the [`ShardQueues`]
28/// (senders) to the pipeline threads' terminal stages, and the receivers to
29/// [`SinkPool::spawn`].
30#[derive(Debug)]
31pub struct SinkPool<W: ShardWriter> {
32    writer: Arc<W>,
33    endpoints: Vec<Arc<Vec<W::Endpoint>>>,
34    workers: Vec<JoinHandle<WorkerReport>>,
35    drain_tx: watch::Sender<Option<Instant>>,
36}
37
38impl<W: ShardWriter> SinkPool<W> {
39    /// Spawn one worker per shard onto `runtime`.
40    ///
41    /// `shard_endpoints[s]` are shard `s`'s replica endpoints;
42    /// `receivers[s]` its chunk queue; `metrics[s]` its pre-registered
43    /// handles. All three must have equal length, with at least one replica
44    /// per shard.
45    ///
46    /// # Panics
47    ///
48    /// Panics when the lengths disagree or a shard has no replicas —
49    /// construction-time configuration errors.
50    #[must_use]
51    #[expect(
52        clippy::too_many_arguments,
53        reason = "construction-time wiring call, used once by the pipeline runtime"
54    )]
55    pub fn spawn(
56        writer: Arc<W>,
57        shard_endpoints: Vec<Vec<W::Endpoint>>,
58        receivers: Vec<mpsc::Receiver<EncodedChunk>>,
59        config: SinkPoolConfig,
60        budget: Arc<InflightBudget>,
61        metrics: Vec<SinkShardMetrics>,
62        pipeline_name: &str,
63        runtime: &tokio::runtime::Handle,
64    ) -> Self {
65        assert_eq!(
66            shard_endpoints.len(),
67            receivers.len(),
68            "one receiver per shard"
69        );
70        assert_eq!(
71            shard_endpoints.len(),
72            metrics.len(),
73            "one metrics set per shard"
74        );
75        assert!(
76            shard_endpoints.iter().all(|r| !r.is_empty()),
77            "every shard needs at least one replica"
78        );
79
80        let (drain_tx, drain_rx) = watch::channel(None);
81        let endpoints: Vec<Arc<Vec<W::Endpoint>>> =
82            shard_endpoints.into_iter().map(Arc::new).collect();
83
84        let nonce = run_nonce();
85        let workers = receivers
86            .into_iter()
87            .zip(metrics)
88            .enumerate()
89            .map(|(shard, (rx, shard_metrics))| {
90                let worker = ShardWorker {
91                    writer: Arc::clone(&writer),
92                    endpoints: Arc::clone(&endpoints[shard]),
93                    rx,
94                    cfg: config,
95                    budget: Arc::clone(&budget),
96                    metrics: Arc::new(shard_metrics),
97                    drain_deadline: drain_rx.clone(),
98                    token_prefix: format!("{pipeline_name}-{nonce}-{shard}-"),
99                };
100                runtime.spawn(worker.run())
101            })
102            .collect();
103
104        SinkPool {
105            writer,
106            endpoints,
107            workers,
108            drain_tx,
109        }
110    }
111
112    /// Probe every replica of every shard (readiness). Fails on the first
113    /// unhealthy endpoint.
114    pub async fn probe_all(&self) -> Result<(), SinkError> {
115        for shard in &self.endpoints {
116            for endpoint in shard.iter() {
117                self.writer.probe(endpoint).await?;
118            }
119        }
120        Ok(())
121    }
122
123    /// Drain the pool: workers force-seal partial batches, then in-flight
124    /// writes get until `deadline` before being aborted and abandoned.
125    ///
126    /// Contract: the caller must have dropped every [`ShardQueues`]
127    /// (super::ShardQueues) clone first — workers only enter their drain
128    /// phase once their queue closes.
129    pub async fn drain(self, deadline: Duration) -> DrainReport {
130        let _ = self.drain_tx.send(Some(Instant::now() + deadline));
131        let mut report = WorkerReport::default();
132        for handle in self.workers {
133            match handle.await {
134                Ok(r) => report.absorb(r),
135                Err(join_err) => {
136                    tracing::error!(error = %join_err, "sink shard worker panicked");
137                }
138            }
139        }
140        DrainReport {
141            flushed: report.flushed,
142            abandoned: report.abandoned,
143        }
144    }
145}
146
147/// A short id unique across process runs (boot time, pid, and an in-process
148/// counter), embedded in every deduplication token.
149///
150/// Without it, tokens are `{pipeline}-{shard}-{seq}` with `seq` restarting
151/// at 0 on every start: a restarted (or same-named concurrent) pipeline
152/// reuses tokens still inside the server's deduplication window, and the
153/// sink silently discards **new** rows while acknowledging them — data
154/// loss precisely when server-side dedup is enabled. With the nonce,
155/// in-session retries still share their batch's token (idempotent), while
156/// cross-run collisions are impossible; crash replay lands duplicate rows
157/// instead of losing them, which is the documented at-least-once contract.
158fn run_nonce() -> String {
159    use std::sync::atomic::{AtomicU64, Ordering};
160    static COUNTER: AtomicU64 = AtomicU64::new(0);
161    let nanos = std::time::SystemTime::now()
162        .duration_since(std::time::UNIX_EPOCH)
163        .map(|d| d.as_nanos() as u64)
164        .unwrap_or(0);
165    let pid = u64::from(std::process::id());
166    let n = COUNTER.fetch_add(1, Ordering::Relaxed);
167    format!("{:x}", nanos ^ (pid << 48) ^ (n << 40))
168}
169
170#[cfg(all(test, not(loom)))]
171mod nonce_tests {
172    use super::run_nonce;
173
174    #[test]
175    fn nonces_differ_within_and_across_calls() {
176        let a = run_nonce();
177        let b = run_nonce();
178        assert_ne!(a, b, "two pools in one process must not share tokens");
179        assert!(!a.is_empty() && a.len() <= 16);
180    }
181}