Skip to main content

spate_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    metrics: Vec<Arc<SinkShardMetrics>>,
37}
38
39/// How long past the drain deadline a shard worker gets before `drain` gives
40/// up on it and force-aborts it.
41///
42/// The deadline itself is cooperative: workers watch it, abort their in-flight
43/// writes and abandon what is left, loudly. This is the backstop under that,
44/// so a worker that cannot honour it — a framework bug — degrades shutdown to
45/// a lost drain report rather than an unbounded hang (#83).
46///
47/// Sized by the tightest constraint, which is not the pod's
48/// `terminationGracePeriodSeconds` but the controller: it waits only
49/// `deadline + 2s` for the drain to report before giving up and running the
50/// final commit anyway (`pipeline::controller`). At an equal 2s the backstop
51/// could never fire *before* that, so the commit would race a pool still
52/// resolving. Ordering, tightest first: the worker's own `ABORT_GRACE`
53/// (500ms) < this < the controller's 2s.
54const BACKSTOP_GRACE: Duration = Duration::from_secs(1);
55
56impl<W: ShardWriter> SinkPool<W> {
57    /// Spawn one worker per shard onto `runtime`.
58    ///
59    /// `shard_endpoints[s]` are shard `s`'s replica endpoints;
60    /// `receivers[s]` its chunk queue; `metrics[s]` its pre-registered
61    /// handles. All three must have equal length, with at least one replica
62    /// per shard.
63    ///
64    /// # Panics
65    ///
66    /// Panics when the lengths disagree or a shard has no replicas —
67    /// construction-time configuration errors.
68    #[must_use]
69    #[expect(
70        clippy::too_many_arguments,
71        reason = "construction-time wiring call, used once by the pipeline runtime"
72    )]
73    pub fn spawn(
74        writer: Arc<W>,
75        shard_endpoints: Vec<Vec<W::Endpoint>>,
76        receivers: Vec<mpsc::Receiver<EncodedChunk>>,
77        config: SinkPoolConfig,
78        budget: Arc<InflightBudget>,
79        metrics: Vec<SinkShardMetrics>,
80        pipeline_name: &str,
81        runtime: &tokio::runtime::Handle,
82    ) -> Self {
83        assert_eq!(
84            shard_endpoints.len(),
85            receivers.len(),
86            "one receiver per shard"
87        );
88        assert_eq!(
89            shard_endpoints.len(),
90            metrics.len(),
91            "one metrics set per shard"
92        );
93        assert!(
94            shard_endpoints.iter().all(|r| !r.is_empty()),
95            "every shard needs at least one replica"
96        );
97        // Connectors validate this in their own config parsing, but a
98        // programmatically built `SinkPoolConfig` reaches here unchecked — and
99        // a zero-permit semaphore means every sealed batch parks forever
100        // instead of failing loudly at construction.
101        assert!(
102            config.inflight.max_per_shard > 0,
103            "inflight.max_per_shard must be greater than zero"
104        );
105
106        // Warn once per pool, from the seam every sink passes through, so no
107        // connector has to mirror the check.
108        if config.retry.stalls_indefinitely() {
109            tracing::warn!(
110                retry_max = ?config.retry.max,
111                "retry.max_attempts is 0 (unbounded) and retry.max is over 5m: once a \
112                 shard backs off to its ceiling it sleeps that long between attempts and \
113                 never abandons the batch, so a stalled shard looks identical to a \
114                 healthy idle one. Bound it with retry.max_attempts, or lower retry.max. \
115                 If this is deliberate, watch spate_sink_retry_backoff_seconds — it reads \
116                 the backoff a shard is sleeping between attempts right now."
117            );
118        }
119
120        let (drain_tx, drain_rx) = watch::channel(None);
121        let endpoints: Vec<Arc<Vec<W::Endpoint>>> =
122            shard_endpoints.into_iter().map(Arc::new).collect();
123
124        let nonce = run_nonce();
125        // Shared with the workers rather than moved into them: `drain` needs
126        // the handles too, to report a shard that overruns its deadline.
127        let metrics: Vec<Arc<SinkShardMetrics>> = metrics.into_iter().map(Arc::new).collect();
128        let workers = receivers
129            .into_iter()
130            .zip(metrics.iter().map(Arc::clone))
131            .enumerate()
132            .map(|(shard, (rx, shard_metrics))| {
133                let worker = ShardWorker {
134                    shard: u32::try_from(shard).unwrap_or(u32::MAX),
135                    writer: Arc::clone(&writer),
136                    endpoints: Arc::clone(&endpoints[shard]),
137                    rx,
138                    cfg: config,
139                    budget: Arc::clone(&budget),
140                    metrics: shard_metrics,
141                    drain_deadline: drain_rx.clone(),
142                    token_prefix: format!("{pipeline_name}-{nonce}-{shard}-"),
143                };
144                runtime.spawn(worker.run())
145            })
146            .collect();
147
148        SinkPool {
149            writer,
150            endpoints,
151            workers,
152            drain_tx,
153            metrics,
154        }
155    }
156
157    /// A pool around arbitrary worker handles, so the drain backstop can be
158    /// tested against a worker that ignores its deadline. Real workers cannot
159    /// do that — which is the point of the backstop and the reason this seam
160    /// exists.
161    #[cfg(test)]
162    pub(crate) fn from_workers(
163        writer: Arc<W>,
164        workers: Vec<JoinHandle<WorkerReport>>,
165        drain_tx: watch::Sender<Option<Instant>>,
166        metrics: Vec<Arc<SinkShardMetrics>>,
167    ) -> Self {
168        SinkPool {
169            writer,
170            endpoints: Vec::new(),
171            workers,
172            drain_tx,
173            metrics,
174        }
175    }
176
177    /// Probe every replica of every shard (readiness). Fails on the first
178    /// unhealthy endpoint.
179    pub async fn probe_all(&self) -> Result<(), SinkError> {
180        for shard in &self.endpoints {
181            for endpoint in shard.iter() {
182                self.writer.probe(endpoint).await?;
183            }
184        }
185        Ok(())
186    }
187
188    /// Drain the pool: workers force-seal partial batches, then in-flight
189    /// writes get until `deadline` before being aborted and abandoned.
190    ///
191    /// Always returns. A worker that does not stop by `deadline` is
192    /// force-aborted [`BACKSTOP_GRACE`] later; its acknowledgements fail with
193    /// it (so at-least-once holds and the data replays), but its counts are
194    /// missing from the returned report.
195    ///
196    /// Contract: the caller must have dropped every [`ShardQueues`]
197    /// (super::ShardQueues) clone first — workers only enter their drain
198    /// phase once their queue closes.
199    pub async fn drain(self, deadline: Duration) -> DrainReport {
200        let deadline_at = Instant::now() + deadline;
201        let _ = self.drain_tx.send(Some(deadline_at));
202        // Absolute, so joining the shards in sequence still bounds the whole
203        // drain: one wedged shard spends the budget once, and the shards
204        // behind it — long since finished — are joined immediately after.
205        let hard_at = deadline_at + BACKSTOP_GRACE;
206        let mut report = WorkerReport::default();
207        let mut forced = 0usize;
208        for (shard, mut handle) in self.workers.into_iter().enumerate() {
209            match tokio::time::timeout_at(hard_at, &mut handle).await {
210                Ok(Ok(r)) => report.absorb(r),
211                Ok(Err(join_err)) => {
212                    tracing::error!(shard, error = %join_err, "sink shard worker panicked");
213                }
214                Err(_) => {
215                    handle.abort();
216                    self.metrics[shard].drain_overrun();
217                    // `hard_at` is absolute, which is what bounds the whole
218                    // sequential join — but it also means the first overrun
219                    // spends the budget and every shard behind it times out
220                    // instantly, however healthy. Only the first can be
221                    // diagnosed as the culprit; the rest are collateral, and
222                    // saying so keeps the ERROR honest. The realistic cause
223                    // (a writer blocking runtime threads) starves them all,
224                    // so this is the expected shape, not a corner case.
225                    if forced == 0 {
226                        tracing::error!(
227                            shard,
228                            grace = ?BACKSTOP_GRACE,
229                            "sink shard worker did not return by the drain deadline and was \
230                             force-aborted — a framework bug, not an operating condition. Its \
231                             acknowledgements fail and that data replays after restart, but its \
232                             flushed/abandoned counts are missing from the drain report."
233                        );
234                    } else {
235                        tracing::error!(
236                            shard,
237                            "sink shard worker force-aborted: the drain budget was already spent \
238                             by an earlier shard, so this one was given no time of its own. It \
239                             may have been healthy. Same consequence — its acknowledgements fail \
240                             and that data replays — but diagnose the first shard reported above."
241                        );
242                    }
243                    forced += 1;
244                }
245            }
246        }
247        DrainReport {
248            flushed: report.flushed,
249            abandoned: report.abandoned,
250        }
251    }
252}
253
254/// A short id unique across process runs (boot time, pid, and an in-process
255/// counter), embedded in every deduplication token.
256///
257/// Without it, tokens are `{pipeline}-{shard}-{seq}` with `seq` restarting
258/// at 0 on every start: a restarted (or same-named concurrent) pipeline
259/// reuses tokens still inside the server's deduplication window, and the
260/// sink silently discards **new** rows while acknowledging them — data
261/// loss precisely when server-side dedup is enabled. With the nonce,
262/// in-session retries still share their batch's token (idempotent), while
263/// cross-run collisions are impossible; crash replay lands duplicate rows
264/// instead of losing them, which is the documented at-least-once contract.
265fn run_nonce() -> String {
266    use std::sync::atomic::{AtomicU64, Ordering};
267    static COUNTER: AtomicU64 = AtomicU64::new(0);
268    let nanos = std::time::SystemTime::now()
269        .duration_since(std::time::UNIX_EPOCH)
270        .map(|d| d.as_nanos() as u64)
271        .unwrap_or(0);
272    let pid = u64::from(std::process::id());
273    let n = COUNTER.fetch_add(1, Ordering::Relaxed);
274    format!("{:x}", nanos ^ (pid << 48) ^ (n << 40))
275}
276
277#[cfg(all(test, not(loom)))]
278mod nonce_tests {
279    use super::run_nonce;
280
281    #[test]
282    fn nonces_differ_within_and_across_calls() {
283        let a = run_nonce();
284        let b = run_nonce();
285        assert_ne!(a, b, "two pools in one process must not share tokens");
286        assert!(!a.is_empty() && a.len() <= 16);
287    }
288}