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