Skip to main content

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}