krabka_client_streams/dsl/suppress.rs
1//! `Suppressed` and `BufferConfig`: the suppress configuration surface.
2//!
3//! Slice A implements `until_window_closes(unbounded())`, which gives final
4//! results for windowed tables. Slice B adds `with_max_records`, a bounded
5//! buffer with `shutDownWhenFull`. Slice C adds `until_time_limit`, the
6//! `emit_early_when_full` overflow toggle, the eager `max_records(n)`
7//! constructor, `record_cap()`, and `is_emit_early()`. Slice D adds the logging
8//! toggle.
9
10use krabka_units::prelude::*;
11
12/// The bound on the suppress buffer and the behaviour when it is full.
13///
14/// A [`ByteSize`] stores `f64`, so this type is `PartialEq` but not `Eq`. Nothing
15/// keys a map or a set on a buffer config, so the weaker bound costs nothing.
16#[derive(Clone, Copy, Debug, PartialEq)]
17pub struct BufferConfig {
18 max_records: Option<usize>,
19 /// Cap on the total serialized size of the buffer, the sum of `key_bytes`
20 /// and `value_bytes`. The processor enforces it against the registered
21 /// store's `byte_size()`. This is the JVM `BufferConfig.maxBytes`.
22 max_bytes: Option<ByteSize>,
23 /// `false` is shutDownWhenFull, which is strict and panics. `true` is
24 /// emitEarlyWhenFull, which is eager.
25 emit_early: bool,
26}
27
28impl BufferConfig {
29 /// Unbounded and strict, that is, shutDownWhenFull.
30 #[must_use]
31 pub fn unbounded() -> Self {
32 Self {
33 max_records: None,
34 max_bytes: None,
35 emit_early: false,
36 }
37 }
38
39 /// Cap at `n` records, EAGER, that is, emit-early-when-full.
40 ///
41 /// This matches the JVM static `BufferConfig.maxRecords(n)`, the
42 /// rate-limiter default overflow.
43 #[must_use]
44 /// # Panics
45 /// Panics if synchronized client state is poisoned or a response violates an invariant established by protocol validation.
46 pub fn max_records(n: usize) -> Self {
47 assert!(n >= 1, "max_records must be >= 1");
48 Self {
49 max_records: Some(n),
50 max_bytes: None,
51 emit_early: true,
52 }
53 }
54
55 /// Cap at `n`, EAGER, that is, emit-early-when-full.
56 ///
57 /// This matches the JVM static `BufferConfig.maxBytes(n)`. The processor
58 /// measures the cap against the serialized `key_bytes` and `value_bytes`
59 /// summed across the buffered entries.
60 #[must_use]
61 /// # Panics
62 /// Panics if synchronized client state is poisoned or a response violates an invariant established by protocol validation.
63 pub fn max_bytes(n: ByteSize) -> Self {
64 assert!(n >= bytes(1), "max_bytes must be >= 1");
65 Self {
66 max_records: None,
67 max_bytes: Some(n),
68 emit_early: true,
69 }
70 }
71
72 /// Cap at `n` records and keep the current overflow mode.
73 ///
74 /// The mode is strict on the `unbounded()` path. This matches the JVM
75 /// `unbounded().withMaxRecords(n)`.
76 #[must_use]
77 /// # Panics
78 /// Panics if synchronized client state is poisoned or a response violates an invariant established by protocol validation.
79 pub fn with_max_records(self, n: usize) -> Self {
80 assert!(n >= 1, "max_records must be >= 1");
81 Self {
82 max_records: Some(n),
83 ..self
84 }
85 }
86
87 /// Cap at `n` and keep the current overflow mode. This matches the JVM
88 /// `unbounded().withMaxBytes(n)`.
89 #[must_use]
90 /// # Panics
91 /// Panics if synchronized client state is poisoned or a response violates an invariant established by protocol validation.
92 pub fn with_max_bytes(self, n: ByteSize) -> Self {
93 assert!(n >= bytes(1), "max_bytes must be >= 1");
94 Self {
95 max_bytes: Some(n),
96 ..self
97 }
98 }
99
100 /// Evict and emit the oldest buffered record when the buffer is full. This
101 /// is the eager mode.
102 #[must_use]
103 pub fn emit_early_when_full(self) -> Self {
104 Self {
105 emit_early: true,
106 ..self
107 }
108 }
109
110 /// Shut the task down when the buffer is full. This is the strict mode.
111 #[must_use]
112 pub fn shut_down_when_full(self) -> Self {
113 Self {
114 emit_early: false,
115 ..self
116 }
117 }
118
119 pub(crate) fn record_cap(&self) -> Option<usize> {
120 self.max_records
121 }
122 pub(crate) fn byte_cap(&self) -> Option<ByteSize> {
123 self.max_bytes
124 }
125 pub(crate) fn is_emit_early(&self) -> bool {
126 self.emit_early
127 }
128}
129
130/// A suppression configuration, parameterized by the table key `K`.
131///
132/// It carries a `fn(&K, i64) -> i64` that maps a record key and a timestamp to a
133/// buffer time. Window-close reads `window.end`, and time-limit reads the record
134/// timestamp. Fn pointers are `Copy`, so `Suppressed<K>` is `Copy` and needs no
135/// `K: Copy` bound.
136#[derive(Debug)]
137pub struct Suppressed<K> {
138 pub(crate) buffer: BufferConfig,
139 pub(crate) buffer_time: fn(&K, i64) -> i64,
140 pub(crate) wait: WaitKind,
141 /// Whether the client emits the suppress buffer's changelog topic. Default:
142 /// `true`. `false` matches the JVM `withLoggingDisabled()` and keeps the
143 /// buffer in memory only. There is then no changelog topic in the wire
144 /// topology and no restore.
145 pub(crate) logging: bool,
146}
147
148// All fields are Copy independently of K (fn pointer + plain enums/bool).
149impl<K> Clone for Suppressed<K> {
150 fn clone(&self) -> Self {
151 *self
152 }
153}
154
155impl<K> Copy for Suppressed<K> {}
156
157impl<K> Suppressed<K> {
158 /// Disable the suppress buffer's changelog, as the JVM
159 /// `withLoggingDisabled()` does. The buffer then stays in memory only, with
160 /// no changelog topic and no fault-tolerance.
161 #[must_use]
162 pub fn with_logging_disabled(self) -> Self {
163 Self {
164 logging: false,
165 ..self
166 }
167 }
168
169 /// Re-enable the suppress buffer's changelog. This is the default.
170 #[must_use]
171 pub fn with_logging_enabled(self) -> Self {
172 Self {
173 logging: true,
174 ..self
175 }
176 }
177}
178
179/// How long to wait before emitting a buffered record.
180#[derive(Clone, Copy, Debug)]
181pub(crate) enum WaitKind {
182 /// Window-close. The wait is the upstream window's grace, taken from the
183 /// `KTable` handle.
184 UpstreamGrace,
185 /// Time-limit. The wait is the configured duration.
186 Fixed(Time),
187}
188
189impl<KInner> Suppressed<crate::dsl::windows::Windowed<KInner>> {
190 /// Emit each window's final result once the window closes, that is, once
191 /// `stream_time >= window.end + grace`. This needs a windowed `KTable` and a
192 /// STRICT buffer, that is, shutDownWhenFull.
193 #[must_use]
194 /// # Panics
195 /// Panics if synchronized client state is poisoned or a response violates an invariant established by protocol validation.
196 pub fn until_window_closes(buffer: BufferConfig) -> Self {
197 assert!(
198 !buffer.is_emit_early(),
199 "untilWindowCloses requires a strict (shutDownWhenFull) buffer config"
200 );
201 Self {
202 buffer,
203 buffer_time: |k, _ts| k.window.end,
204 wait: WaitKind::UpstreamGrace,
205 logging: true,
206 }
207 }
208}
209
210impl<K> Suppressed<K> {
211 /// Rate-limiter: emit at most one update per key per `wait` in stream time.
212 /// A newer record for a key replaces the buffered one and resets the timer.
213 #[must_use]
214 /// # Panics
215 /// Panics if synchronized client state is poisoned or a response violates an invariant established by protocol validation.
216 pub fn until_time_limit(wait: Time, buffer: BufferConfig) -> Self {
217 assert!(wait >= Time::ZERO, "time limit must be >= 0");
218 Self {
219 buffer,
220 buffer_time: |_k, ts| ts,
221 wait: WaitKind::Fixed(wait),
222 logging: true,
223 }
224 }
225}
226
227#[cfg(test)]
228mod tests {
229 use assert2::check;
230
231 use super::*;
232
233 #[test]
234 fn buffer_config_caps_and_overflow() {
235 check!(BufferConfig::unbounded().record_cap() == None);
236 check!(!BufferConfig::unbounded().is_emit_early()); // strict
237 let strict = BufferConfig::unbounded().with_max_records(3);
238 check!(strict.record_cap() == Some(3));
239 check!(!strict.is_emit_early());
240 let eager = BufferConfig::max_records(5); // eager
241 check!(eager.record_cap() == Some(5));
242 check!(eager.is_emit_early());
243 check!(!eager.shut_down_when_full().is_emit_early());
244 check!(
245 BufferConfig::unbounded()
246 .emit_early_when_full()
247 .is_emit_early()
248 );
249 }
250
251 #[test]
252 fn buffer_config_byte_caps() {
253 check!(BufferConfig::unbounded().byte_cap() == None);
254 let eager = BufferConfig::max_bytes(kibibytes(1)); // eager static
255 check!(eager.byte_cap() == Some(kibibytes(1)));
256 check!(eager.record_cap() == None);
257 check!(eager.is_emit_early());
258 // strict path keeps shutDownWhenFull
259 let strict = BufferConfig::unbounded().with_max_bytes(bytes(512));
260 check!(strict.byte_cap() == Some(bytes(512)));
261 check!(!strict.is_emit_early());
262 // records + bytes coexist
263 let both = BufferConfig::unbounded()
264 .with_max_records(3)
265 .with_max_bytes(bytes(99));
266 check!(both.record_cap() == Some(3));
267 check!(both.byte_cap() == Some(bytes(99)));
268 }
269
270 #[test]
271 fn logging_toggles() {
272 use crate::dsl::windows::{Window, Windowed};
273 let on = Suppressed::until_window_closes(BufferConfig::unbounded());
274 check!(on.logging); // default on
275 let off = on.with_logging_disabled();
276 check!(!off.logging);
277 check!(off.with_logging_enabled().logging);
278 // window-close buffer_time still reads window.end after the toggle
279 let wk = Windowed {
280 key: "k".to_string(),
281 window: Window { start: 0, end: 7 },
282 };
283 check!((off.buffer_time)(&wk, 1) == 7);
284 }
285
286 #[test]
287 fn suppressed_constructors() {
288 use crate::dsl::windows::{Window, Windowed};
289 let wc = Suppressed::until_window_closes(BufferConfig::unbounded());
290 let wk = Windowed {
291 key: "k".to_string(),
292 window: Window { start: 0, end: 99 },
293 };
294 check!((wc.buffer_time)(&wk, 5) == 99); // window.end
295 let tl = Suppressed::<String>::until_time_limit(millis(50), BufferConfig::max_records(2));
296 check!((tl.buffer_time)(&"k".to_string(), 5) == 5); // record ts
297 }
298}