spate_core/sink/config.rs
1//! Sink worker-pool tuning knobs.
2//!
3//! These are framework-level structs, and they are also the wire format: the
4//! `batch`, `inflight`, `retry` and `breaker` sub-sections of every sink's
5//! YAML deserialize straight into them. The keys and defaults are meant to be
6//! identical across connectors, so they are literally one type rather than a
7//! per-connector mirror that has to be kept in step by hand.
8
9use bytesize::ByteSize;
10use serde::{Deserialize, Deserializer};
11use std::time::Duration;
12
13/// Accept `128MiB`-style sizes on the wire while keeping the field a plain
14/// `u64` — `ByteSize` is a parsing convenience, not part of the batching API.
15fn de_byte_size<'de, D: Deserializer<'de>>(d: D) -> Result<u64, D::Error> {
16 ByteSize::deserialize(d).map(|b| b.as_u64())
17}
18
19/// Batch sealing thresholds for one shard worker. A batch seals as soon as
20/// **any** threshold trips; since chunks arrive whole, a sealed batch may
21/// overshoot `max_rows`/`max_bytes` by at most one chunk.
22#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
23#[serde(deny_unknown_fields, default)]
24pub struct BatchConfig {
25 /// Seal at this many rows (for a message-oriented sink, messages).
26 pub max_rows: u64,
27 /// Seal at this many encoded, uncompressed bytes.
28 #[serde(deserialize_with = "de_byte_size")]
29 pub max_bytes: u64,
30 /// Seal a non-empty batch this long after its first chunk arrived,
31 /// bounding latency at low throughput.
32 #[serde(with = "humantime_serde")]
33 pub linger: Duration,
34}
35
36impl Default for BatchConfig {
37 fn default() -> Self {
38 BatchConfig {
39 max_rows: 500_000,
40 max_bytes: 128 * 1024 * 1024,
41 linger: Duration::from_secs(1),
42 }
43 }
44}
45
46/// In-flight write limits for one shard worker.
47#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
48#[serde(deny_unknown_fields, default)]
49pub struct InflightConfig {
50 /// Concurrent sealed batches per shard (for a replicated sink, writes to
51 /// different replicas). While all permits are taken the worker stops
52 /// consuming its queue, which fills and surfaces as backpressure.
53 pub max_per_shard: usize,
54}
55
56impl Default for InflightConfig {
57 fn default() -> Self {
58 InflightConfig { max_per_shard: 2 }
59 }
60}
61
62/// Retry policy for batch writes. Retries rotate across healthy replicas;
63/// the sealed batch and its deduplication token are reused unchanged.
64#[derive(Clone, Copy, Debug, PartialEq, Deserialize)]
65#[serde(deny_unknown_fields, default)]
66pub struct RetryConfig {
67 /// First backoff delay.
68 #[serde(with = "humantime_serde")]
69 pub initial: Duration,
70 /// Backoff cap.
71 #[serde(with = "humantime_serde")]
72 pub max: Duration,
73 /// Backoff growth factor per attempt.
74 pub multiplier: f64,
75 /// Fraction of the delay randomized away (`0.0..=1.0`).
76 pub jitter: f64,
77 /// Total write attempts before the batch is abandoned (acknowledgements
78 /// failed, watermark stalls). `0` means unbounded — retry until the drain
79 /// deadline, at which point the attempt in flight is aborted and the batch
80 /// abandoned. The at-least-once default.
81 ///
82 /// An unbounded policy holds its in-flight slot
83 /// ([`InflightConfig::max_per_shard`]) for the whole outage, since a slot
84 /// frees only when its write task ends. That is intended — it is how a
85 /// down sink back-pressures the source rather than buffering — but it does
86 /// mean a shard talking to a dead sink runs at zero in-flight capacity
87 /// until either the sink recovers or the drain deadline arrives.
88 pub max_attempts: u32,
89}
90
91impl Default for RetryConfig {
92 fn default() -> Self {
93 RetryConfig {
94 initial: Duration::from_millis(100),
95 max: Duration::from_secs(10),
96 multiplier: 2.0,
97 jitter: 0.2,
98 max_attempts: 0,
99 }
100 }
101}
102
103/// Why a [`RetryConfig`] was rejected.
104///
105/// Each message names the offending key relative to the sink's `retry`
106/// section; connectors prepend their own config path when converting it into
107/// their `ConfigError`.
108#[derive(Clone, Debug, PartialEq, thiserror::Error)]
109#[non_exhaustive]
110pub enum RetryConfigError {
111 /// `multiplier` is not a finite number in `[1.0, 1e9]`. Below `1.0` the
112 /// delay shrinks instead of backing off.
113 #[error("retry.multiplier must be a finite number in [1.0, 1e9] (got {0})")]
114 Multiplier(f64),
115 /// `jitter` is not a finite fraction in `[0.0, 1.0]`.
116 #[error("retry.jitter must be a finite fraction in [0.0, 1.0] (got {0})")]
117 Jitter(f64),
118 /// `initial` or `max` is zero, leaving no delay to sleep at all.
119 #[error("retry.initial and retry.max must be non-zero")]
120 ZeroDelay,
121 /// `initial` is larger than the ceiling it grows towards.
122 #[error("retry.initial ({initial:?}) must not exceed retry.max ({max:?})")]
123 InitialExceedsMax {
124 /// The configured first delay.
125 initial: Duration,
126 /// The configured ceiling.
127 max: Duration,
128 },
129}
130
131impl RetryConfig {
132 /// Reject a retry policy that would misbehave at runtime. Connectors call
133 /// this from their config validation and prepend their own config path to
134 /// the message, so the rules stay in one place instead of being mirrored
135 /// per connector.
136 ///
137 /// This is about intent, not safety. [`Backoff`](super::retry) is total
138 /// for *any* `RetryConfig` — it saturates at `max` and never returns a
139 /// zero delay — so nothing here is load-bearing for the write loop. What
140 /// it catches is a policy no operator means: a sub-`1.0` multiplier
141 /// shrinks the delay instead of backing off, a zero delay is not a
142 /// backoff at all, and both are worth failing at load rather than at 3am.
143 ///
144 /// The bounds are deliberately generous and do **not** guarantee a
145 /// *sensible* policy — `initial: 1ns, max: 1ns` passes. They rule out the
146 /// nonsensical, not the merely aggressive.
147 ///
148 /// # Errors
149 ///
150 /// [`RetryConfigError`], naming the offending key.
151 ///
152 /// ```
153 /// use spate_core::sink::{RetryConfig, RetryConfigError};
154 ///
155 /// assert!(RetryConfig::default().validate().is_ok());
156 ///
157 /// let hot_loop = RetryConfig { multiplier: 0.5, ..RetryConfig::default() };
158 /// assert_eq!(hot_loop.validate(), Err(RetryConfigError::Multiplier(0.5)));
159 /// ```
160 pub fn validate(&self) -> Result<(), RetryConfigError> {
161 if !self.multiplier.is_finite() || !(1.0..=1e9).contains(&self.multiplier) {
162 return Err(RetryConfigError::Multiplier(self.multiplier));
163 }
164 if !self.jitter.is_finite() || !(0.0..=1.0).contains(&self.jitter) {
165 return Err(RetryConfigError::Jitter(self.jitter));
166 }
167 if self.initial.is_zero() || self.max.is_zero() {
168 return Err(RetryConfigError::ZeroDelay);
169 }
170 if self.initial > self.max {
171 return Err(RetryConfigError::InitialExceedsMax {
172 initial: self.initial,
173 max: self.max,
174 });
175 }
176 Ok(())
177 }
178
179 /// Whether this policy lets a shard sleep indefinitely without ever
180 /// giving up on the batch.
181 ///
182 /// It takes *both* halves: unbounded attempts, so the batch is never
183 /// abandoned, and a ceiling long enough that a sleeping shard is
184 /// indistinguishable from a wedged one. With a finite `max_attempts` the
185 /// batch is abandoned and the stall is bounded; with a short ceiling the
186 /// retries keep visibly ticking. Only the combination goes quiet.
187 ///
188 /// Deliberately not a [`validate`](Self::validate) rule. Nothing here is
189 /// unsafe — the drain deadline still aborts the sleep at shutdown, so
190 /// at-least-once holds — and a sink fronting an expensive or rate-limited
191 /// destination may well mean it. The threshold is a heuristic, which a
192 /// warning may use and a rejection may not.
193 pub(crate) fn stalls_indefinitely(&self) -> bool {
194 /// Past this, an unbounded policy stops reading as a backoff.
195 const CEILING: Duration = Duration::from_secs(300);
196 self.max_attempts == 0 && self.max > CEILING
197 }
198}
199
200/// Per-replica circuit breaker thresholds.
201#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
202#[serde(deny_unknown_fields, default)]
203pub struct BreakerConfig {
204 /// Consecutive failures that open the breaker, quarantining that endpoint.
205 pub failure_threshold: u32,
206 /// How long an open breaker rejects a replica before probing again.
207 #[serde(with = "humantime_serde")]
208 pub open_for: Duration,
209 /// Concurrent probe writes allowed while half-open.
210 pub half_open_probes: u32,
211}
212
213impl Default for BreakerConfig {
214 fn default() -> Self {
215 BreakerConfig {
216 failure_threshold: 3,
217 open_for: Duration::from_secs(5),
218 half_open_probes: 1,
219 }
220 }
221}
222
223/// Complete sink worker-pool configuration.
224#[derive(Clone, Copy, Debug, Default, PartialEq)]
225pub struct SinkPoolConfig {
226 /// Batch sealing thresholds.
227 pub batch: BatchConfig,
228 /// In-flight limits.
229 pub inflight: InflightConfig,
230 /// Write retry policy.
231 pub retry: RetryConfig,
232 /// Replica circuit breaker.
233 pub breaker: BreakerConfig,
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239
240 fn retry(mutate: impl FnOnce(&mut RetryConfig)) -> RetryConfig {
241 let mut cfg = RetryConfig::default();
242 mutate(&mut cfg);
243 cfg
244 }
245
246 #[test]
247 fn validate_rejects_policies_that_misbehave_at_runtime() {
248 let cases = [
249 (retry(|c| c.multiplier = 0.5), "multiplier"),
250 (retry(|c| c.multiplier = -2.0), "multiplier"),
251 (retry(|c| c.multiplier = f64::NAN), "multiplier"),
252 (retry(|c| c.multiplier = f64::INFINITY), "multiplier"),
253 (retry(|c| c.multiplier = 1e9 + 1.0), "multiplier"),
254 (retry(|c| c.jitter = 1.5), "jitter"),
255 (retry(|c| c.jitter = -0.1), "jitter"),
256 (retry(|c| c.jitter = f64::NAN), "jitter"),
257 (retry(|c| c.initial = Duration::ZERO), "non-zero"),
258 (retry(|c| c.max = Duration::ZERO), "non-zero"),
259 (
260 retry(|c| {
261 c.initial = Duration::from_secs(10);
262 c.max = Duration::from_secs(1);
263 }),
264 "must not exceed",
265 ),
266 ];
267 for (cfg, needle) in cases {
268 let err = cfg
269 .validate()
270 .expect_err(&format!("{cfg:?} must fail"))
271 .to_string();
272 assert!(err.contains(needle), "expected `{needle}` in `{err}`");
273 }
274 }
275
276 #[test]
277 fn the_error_messages_survived_the_move_into_the_framework() {
278 // Both sinks mirrored these strings before the rules moved here, and
279 // their config tests still assert on them under their own prefix.
280 assert_eq!(
281 RetryConfigError::Multiplier(0.5).to_string(),
282 "retry.multiplier must be a finite number in [1.0, 1e9] (got 0.5)"
283 );
284 assert_eq!(
285 RetryConfigError::Jitter(1.5).to_string(),
286 "retry.jitter must be a finite fraction in [0.0, 1.0] (got 1.5)"
287 );
288 assert_eq!(
289 RetryConfigError::ZeroDelay.to_string(),
290 "retry.initial and retry.max must be non-zero"
291 );
292 assert_eq!(
293 RetryConfigError::InitialExceedsMax {
294 initial: Duration::from_secs(10),
295 max: Duration::from_secs(1),
296 }
297 .to_string(),
298 "retry.initial (10s) must not exceed retry.max (1s)"
299 );
300 }
301
302 #[test]
303 fn only_unbounded_attempts_with_a_long_ceiling_count_as_a_stall() {
304 let long = Duration::from_secs(3600);
305 // Both halves, the only combination that goes quiet.
306 assert!(retry(|c| c.max = long).stalls_indefinitely());
307 // A finite attempt cap abandons the batch; the stall is bounded.
308 assert!(
309 !retry(|c| {
310 c.max = long;
311 c.max_attempts = 5;
312 })
313 .stalls_indefinitely()
314 );
315 // A short ceiling keeps the retries visibly ticking.
316 assert!(!RetryConfig::default().stalls_indefinitely());
317 // Right at the threshold is still fine; it is an upper bound.
318 assert!(!retry(|c| c.max = Duration::from_secs(300)).stalls_indefinitely());
319 assert!(retry(|c| c.max = Duration::from_secs(301)).stalls_indefinitely());
320 }
321
322 #[test]
323 fn validate_accepts_the_default_and_the_boundaries() {
324 assert!(RetryConfig::default().validate().is_ok());
325 let boundary = RetryConfig {
326 initial: Duration::from_nanos(1),
327 max: Duration::from_nanos(1),
328 multiplier: 1.0,
329 jitter: 0.0,
330 max_attempts: 0,
331 };
332 assert!(boundary.validate().is_ok(), "{boundary:?}");
333 let upper = RetryConfig {
334 multiplier: 1e9,
335 jitter: 1.0,
336 ..RetryConfig::default()
337 };
338 assert!(upper.validate().is_ok(), "{upper:?}");
339 }
340}