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}