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}