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 acknowledgments; 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
28/// [`ShardQueues`](super::ShardQueues) (senders) to the pipeline threads'
29/// terminal stages, and the receivers to [`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. This constant is the backstop under that,
44/// so a worker that cannot honor the deadline degrades shutdown to a lost
45/// drain report rather than an unbounded hang (#83).
46///
47/// The tightest constraint is the controller rather than the pod's
48/// `terminationGracePeriodSeconds`. The controller waits only `deadline + 2s`
49/// for the drain to report before giving up and running the final commit
50/// anyway (`pipeline::controller`). At an equal 2s the backstop could never
51/// fire *before* that, so the commit would race a pool still resolving.
52/// Ordering, tightest first: the worker's own `ABORT_GRACE` (500ms) < this <
53/// 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. A
99        // zero-permit semaphore means every sealed batch parks forever
100        // instead of failing 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.
159    #[cfg(test)]
160    pub(crate) fn from_workers(
161        writer: Arc<W>,
162        workers: Vec<JoinHandle<WorkerReport>>,
163        drain_tx: watch::Sender<Option<Instant>>,
164        metrics: Vec<Arc<SinkShardMetrics>>,
165    ) -> Self {
166        SinkPool {
167            writer,
168            endpoints: Vec::new(),
169            workers,
170            drain_tx,
171            metrics,
172        }
173    }
174
175    /// Probe every replica of every shard (readiness). Fails on the first
176    /// unhealthy endpoint.
177    pub async fn probe_all(&self) -> Result<(), SinkError> {
178        for shard in &self.endpoints {
179            for endpoint in shard.iter() {
180                self.writer.probe(endpoint).await?;
181            }
182        }
183        Ok(())
184    }
185
186    /// Drain the pool. Workers force-seal partial batches, then in-flight
187    /// writes get until `deadline` before being aborted and abandoned.
188    ///
189    /// Always returns. A worker that does not stop by `deadline` is
190    /// force-aborted `BACKSTOP_GRACE` later; its acknowledgments fail with
191    /// it (so at-least-once holds and the data replays), but its counts are
192    /// missing from the returned report.
193    ///
194    /// Contract: the caller must have dropped every
195    /// [`ShardQueues`](super::ShardQueues) clone first. Workers only enter
196    /// their drain phase once their queue closes.
197    pub async fn drain(self, deadline: Duration) -> DrainReport {
198        let deadline_at = Instant::now() + deadline;
199        let _ = self.drain_tx.send(Some(deadline_at));
200        // Absolute, so joining the shards in sequence still bounds the whole
201        // drain. One wedged shard spends the budget once, and the shards
202        // behind it (long since finished) are joined immediately after.
203        let hard_at = deadline_at + BACKSTOP_GRACE;
204        let mut report = WorkerReport::default();
205        let mut forced = 0usize;
206        for (shard, mut handle) in self.workers.into_iter().enumerate() {
207            match tokio::time::timeout_at(hard_at, &mut handle).await {
208                Ok(Ok(r)) => report.absorb(r),
209                Ok(Err(join_err)) => {
210                    tracing::error!(shard, error = %join_err, "sink shard worker panicked");
211                }
212                Err(_) => {
213                    handle.abort();
214                    self.metrics[shard].drain_overrun();
215                    // `hard_at` is absolute, which bounds the whole sequential
216                    // join. It also means the first overrun spends the budget
217                    // and every shard behind it times out instantly, however
218                    // healthy. Only the first can be diagnosed as the culprit;
219                    // the rest are collateral. The realistic cause (a writer
220                    // blocking runtime threads) starves them all.
221                    if forced == 0 {
222                        tracing::error!(
223                            shard,
224                            grace = ?BACKSTOP_GRACE,
225                            "sink shard worker did not return by the drain deadline and was \
226                             force-aborted — a framework bug, not an operating condition. Its \
227                             acknowledgments fail and that data replays after restart, but its \
228                             flushed/abandoned counts are missing from the drain report."
229                        );
230                    } else {
231                        tracing::error!(
232                            shard,
233                            "sink shard worker force-aborted: the drain budget was already spent \
234                             by an earlier shard, so this one was given no time of its own. It \
235                             may have been healthy. Same consequence — its acknowledgments fail \
236                             and that data replays — but diagnose the first shard reported above."
237                        );
238                    }
239                    forced += 1;
240                }
241            }
242        }
243        DrainReport {
244            flushed: report.flushed,
245            abandoned: report.abandoned,
246        }
247    }
248}
249
250/// A short id unique across process runs (boot time, pid, and an in-process
251/// counter), embedded in every deduplication token.
252///
253/// Without it, tokens are `{pipeline}-{shard}-{seq}` with `seq` restarting
254/// at 0 on every start. A restarted (or same-named concurrent) pipeline
255/// reuses tokens still inside the server's deduplication window, and the
256/// sink silently discards **new** rows while acknowledging them, losing data
257/// wherever server-side dedup is enabled. With the nonce, in-session retries
258/// still share their batch's token (idempotent), while cross-run collisions
259/// are impossible; crash replay lands duplicate rows instead of losing them,
260/// as at-least-once requires.
261fn run_nonce() -> String {
262    use std::sync::atomic::{AtomicU64, Ordering};
263    static COUNTER: AtomicU64 = AtomicU64::new(0);
264    let nanos = std::time::SystemTime::now()
265        .duration_since(std::time::UNIX_EPOCH)
266        .map(|d| d.as_nanos() as u64)
267        .unwrap_or(0);
268    let pid = u64::from(std::process::id());
269    let n = COUNTER.fetch_add(1, Ordering::Relaxed);
270    format!("{:x}", nanos ^ (pid << 48) ^ (n << 40))
271}
272
273#[cfg(all(test, not(loom)))]
274mod nonce_tests {
275    use super::run_nonce;
276
277    #[test]
278    fn nonces_differ_within_and_across_calls() {
279        let a = run_nonce();
280        let b = run_nonce();
281        assert_ne!(a, b, "two pools in one process must not share tokens");
282        assert!(!a.is_empty() && a.len() <= 16);
283    }
284}