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}