Skip to main content

meathook/layer/
tier.rs

1//! [`Tier`]: one buffering layer, generic over its [`Store`] backend.
2//!
3//! A tier owns all the pipeline-side buffering logic exactly once —
4//! wall-clock window alignment, [`FlushPolicy`] firing, replay-on-startup,
5//! and retain-on-downstream-failure — and delegates the actual holding of
6//! records to a [`Store`]. `Tier` backed by [`MemStore`](crate::MemStore)
7//! is a volatile in-memory buffer; backed by
8//! [`JsonlStore`](crate::JsonlStore) it is a durable write-ahead spool.
9
10use std::error;
11use std::marker::PhantomData;
12
13use time::OffsetDateTime;
14use tracing::{debug, warn};
15
16use super::FlushPolicy;
17use crate::sink::{Sink, WindowMeta};
18use crate::store::{Segment as _, Store};
19
20/// Error from a [`Tier`] layer.
21#[derive(Debug, thiserror::Error)]
22pub enum TierError<St, S>
23where
24    St: error::Error + Send + Sync + 'static,
25    S: error::Error + Send + Sync + 'static,
26{
27    /// The tier's store failed to append, read, or remove a window.
28    #[error("store error: {0}")]
29    Store(#[source] St),
30    /// The wrapped sink rejected a drained window.
31    #[error("downstream sink error: {0}")]
32    Downstream(#[source] S),
33}
34
35/// Buffering tier over a pluggable [`Store`].
36///
37/// Records ingest into the wall-clock-aligned window
38/// `align(meta.end)` (aligned to the policy's `every`); when the policy
39/// fires, stored windows drain downstream oldest-first, each removed from
40/// the store only after the downstream sink accepted it — a transient
41/// outage of the terminal sink does not lose data held in this tier.
42///
43/// One window key can drain downstream **more than once**: the
44/// `max_records` valve fires mid-window and later records re-open the same
45/// key, and a failed drain is retried after the window has grown. Both
46/// deliveries carry the same [`WindowMeta`], so a terminal sink that keys
47/// storage by window start alone would overwrite the earlier chunk — key
48/// by content as well, as `HfSink` (feature `huggingface`) does.
49///
50/// A window whose delivery keeps failing (for example a record the
51/// terminal sink deterministically rejects) does not stall the pipeline:
52/// each drain pass attempts every closed window oldest-first, retains the
53/// failed ones for the next pass, and surfaces the first error. Such a
54/// poisoned window is retried on every pass and retained in the store
55/// indefinitely — remove its segment by hand (for
56/// [`JsonlStore`](crate::JsonlStore), delete the window's `.jsonl` file)
57/// if it must be discarded.
58///
59/// The policy is checked on each `ingest` (collector ticks are frequent
60/// compared to flush windows, so no extra timer task is needed);
61/// [`flush`](Sink::flush) force-drains. Whatever a previous run left in the
62/// store is replayed downstream on the first `ingest` or `flush` call, with
63/// [`WindowMeta`] reconstructed from the window key alone — replayed
64/// windows land at the same storage path (idempotent).
65pub struct Tier<R, St, S> {
66    /// Backend that persists buffered records by aligned window key.
67    store: St,
68    /// Policy that defines the window duration and record-count flush threshold.
69    policy: FlushPolicy,
70    /// Downstream sink that receives drained windows.
71    inner: S,
72    /// Pipeline name attached to drained window metadata.
73    pipeline: Option<String>,
74    /// Whether the one-time startup replay has been attempted.
75    initialized: bool,
76    /// Aligned Unix timestamp identifying the window currently accepting records.
77    active_window: Option<i64>,
78    /// Number of records appended to the active window since it was opened.
79    active_count: usize,
80    /// Associates the tier with its record type without storing a record.
81    _record: PhantomData<fn() -> R>,
82}
83
84impl<R, St, S> Tier<R, St, S> {
85    /// Create a tier holding records in `store` until `policy` fires.
86    #[must_use]
87    pub fn new(store: St, policy: FlushPolicy, inner: S) -> Self {
88        Self {
89            store,
90            policy,
91            inner,
92            pipeline: None,
93            initialized: false,
94            active_window: None,
95            active_count: 0,
96            _record: PhantomData,
97        }
98    }
99
100    /// Override the pipeline name used in drained [`WindowMeta`] (default:
101    /// the first live meta seen, then the store's
102    /// [`pipeline_hint`](Store::pipeline_hint)).
103    #[must_use]
104    pub fn with_pipeline_name(mut self, name: impl Into<String>) -> Self {
105        self.pipeline = Some(name.into());
106        self
107    }
108
109    /// Access the wrapped sink.
110    #[must_use]
111    pub fn inner(&self) -> &S {
112        &self.inner
113    }
114
115    fn window_secs(&self) -> i64 {
116        i64::try_from(self.policy.every.as_secs())
117            .unwrap_or(i64::MAX)
118            .max(1)
119    }
120
121    fn align(&self, unix: i64) -> i64 {
122        unix - unix.rem_euclid(self.window_secs())
123    }
124}
125
126impl<R, St, S> Tier<R, St, S>
127where
128    R: Send + 'static,
129    St: Store<R>,
130    S: Sink<R>,
131{
132    fn pipeline_name(&self) -> String {
133        self.pipeline
134            .as_deref()
135            .or_else(|| self.store.pipeline_hint())
136            .unwrap_or("unknown")
137            .to_owned()
138    }
139
140    /// First-use crash recovery: drain whatever a previous run left in the
141    /// store. Failures are logged, not propagated — the windows stay in the
142    /// store to be retried at the next firing, and new records must still
143    /// be appended afterwards.
144    async fn ensure_init(&mut self) {
145        if self.initialized {
146            return;
147        }
148        self.initialized = true;
149        if let Err(error) = self.drain(true).await {
150            warn!(
151                pipeline = %self.pipeline_name(),
152                %error,
153                "startup replay failed; stored windows retained for retry"
154            );
155        }
156    }
157
158    /// Drain stored windows downstream, oldest first, removing each from
159    /// the store only after downstream success. A failed window —
160    /// unreadable, rejected downstream, or failing removal — is retained
161    /// for the next pass and does not block newer windows: the pass skips
162    /// past it, attempts the rest, and returns the first error at the end.
163    /// With `include_active == false`, the currently active window is
164    /// skipped, not drained.
165    async fn drain(&mut self, include_active: bool) -> Result<(), TierError<St::Error, S::Error>> {
166        // Hoisted so no `&self` method call overlaps the live segment
167        // borrow below.
168        let pipeline = self.pipeline_name();
169        let window_secs = self.window_secs();
170        // Windows at or below the cursor were skipped this pass (failed or
171        // still active); skipped segments drop uncommitted, so their
172        // records stay in the store.
173        let mut cursor = None;
174        let mut first_error = None;
175        loop {
176            let Some(mut seg) = self.store.oldest(cursor).await.map_err(TierError::Store)? else {
177                break;
178            };
179            let window = seg.window();
180            if !include_active && Some(window) == self.active_window {
181                // Still open: skip, but keep going — leftovers replayed
182                // from a previous run may be newer than the active window.
183                cursor = Some(window);
184                continue;
185            }
186            let records = match seg.records().await {
187                Ok(records) => records,
188                Err(error) => {
189                    warn!(
190                        pipeline = %pipeline,
191                        window,
192                        %error,
193                        "failed to read stored window; retained for retry"
194                    );
195                    first_error.get_or_insert(TierError::Store(error));
196                    cursor = Some(window);
197                    continue;
198                }
199            };
200            if !records.is_empty() {
201                let start = OffsetDateTime::from_unix_timestamp(window)
202                    .unwrap_or(OffsetDateTime::UNIX_EPOCH);
203                let meta = WindowMeta {
204                    pipeline: pipeline.clone(),
205                    start,
206                    // `saturating_add`: a time-unbounded policy (e.g.
207                    // `every == Duration::MAX`) makes `window_secs` equal
208                    // `i64::MAX`; plain `+` panics on `time` range overflow.
209                    end: start.saturating_add(time::Duration::seconds(window_secs)),
210                };
211                debug!(
212                    pipeline = %pipeline,
213                    window,
214                    records = records.len(),
215                    "tier draining window downstream"
216                );
217                if let Err(error) = self.inner.ingest(&meta, records).await {
218                    warn!(
219                        pipeline = %pipeline,
220                        window,
221                        %error,
222                        "downstream rejected window; retained for retry"
223                    );
224                    first_error.get_or_insert(TierError::Downstream(error));
225                    cursor = Some(window);
226                    continue;
227                }
228            }
229            if let Err(error) = seg.commit().await {
230                warn!(
231                    pipeline = %pipeline,
232                    window,
233                    %error,
234                    "failed to remove drained window from store"
235                );
236                first_error.get_or_insert(TierError::Store(error));
237                cursor = Some(window);
238                continue;
239            }
240            if Some(window) == self.active_window {
241                self.active_window = None;
242                self.active_count = 0;
243            }
244        }
245        match first_error {
246            None => Ok(()),
247            Some(error) => Err(error),
248        }
249    }
250}
251
252impl<R, St, S> Sink<R> for Tier<R, St, S>
253where
254    R: Send + 'static,
255    St: Store<R>,
256    S: Sink<R>,
257{
258    type Error = TierError<St::Error, S::Error>;
259
260    async fn ingest(&mut self, meta: &WindowMeta, records: Vec<R>) -> Result<(), Self::Error> {
261        if self.pipeline.is_none() {
262            self.pipeline = Some(meta.pipeline.clone());
263        }
264        self.ensure_init().await;
265
266        if !records.is_empty() {
267            let window = self.align(meta.end.unix_timestamp());
268            let count = records.len();
269            self.store
270                .append(window, records)
271                .await
272                .map_err(TierError::Store)?;
273            if self.active_window != Some(window) {
274                self.active_window = Some(window);
275                self.active_count = 0;
276            }
277            self.active_count += count;
278        }
279
280        if self.active_count >= self.policy.max_records {
281            self.drain(true).await
282        } else {
283            self.drain(false).await
284        }
285    }
286
287    async fn flush(&mut self) -> Result<(), Self::Error> {
288        // flush IS the recovery drain, so skip ensure_init's error-swallowing
289        // variant: errors here must propagate (final flush on shutdown).
290        self.initialized = true;
291        self.drain(true).await?;
292        self.inner.flush().await.map_err(TierError::Downstream)
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use std::sync::Arc;
299    use std::sync::atomic::{AtomicBool, Ordering};
300    use std::time::Duration;
301
302    use super::*;
303    use crate::store::MemStore;
304    use crate::test_util::{SharedSink, TestSinkFailure, meta_at};
305
306    /// Rejects any batch containing `13` while armed; accepted batches
307    /// land in the wrapped [`SharedSink`].
308    struct PoisonSink {
309        inner: SharedSink<i32>,
310        armed: Arc<AtomicBool>,
311    }
312
313    impl Sink<i32> for PoisonSink {
314        type Error = TestSinkFailure;
315
316        async fn ingest(
317            &mut self,
318            meta: &WindowMeta,
319            records: Vec<i32>,
320        ) -> Result<(), TestSinkFailure> {
321            if self.armed.load(Ordering::SeqCst) && records.contains(&13) {
322                return Err(TestSinkFailure);
323            }
324            self.inner.ingest(meta, records).await
325        }
326
327        async fn flush(&mut self) -> Result<(), TestSinkFailure> {
328            self.inner.flush().await
329        }
330    }
331
332    #[tokio::test]
333    async fn mem_tier_holds_until_max_records() {
334        let inner = SharedSink::new();
335        let mut sink = Tier::new(
336            MemStore::new(),
337            FlushPolicy::new(Duration::from_secs(3600), 3),
338            inner.clone(),
339        );
340
341        sink.ingest(&meta_at("p", 10), vec![1, 2]).await.unwrap();
342        assert!(inner.batches().is_empty());
343
344        sink.ingest(&meta_at("p", 20), vec![3]).await.unwrap();
345        let batches = inner.batches();
346        assert_eq!(batches.len(), 1);
347        assert_eq!(batches[0].1, vec![1, 2, 3]);
348    }
349
350    #[tokio::test]
351    async fn time_unbounded_policy_drains_on_record_count() {
352        let inner = SharedSink::new();
353        let mut sink = Tier::new(
354            MemStore::new(),
355            FlushPolicy::new(Duration::MAX, 2),
356            inner.clone(),
357        );
358
359        // All records land in the single epoch-anchored window; only the
360        // record cap can fire. Draining must not panic computing the
361        // window's `end` (epoch + i64::MAX seconds overflows `time`).
362        sink.ingest(&meta_at("p", 10), vec![1]).await.unwrap();
363        assert!(inner.batches().is_empty());
364
365        sink.ingest(&meta_at("p", 20), vec![2]).await.unwrap();
366        let batches = inner.batches();
367        assert_eq!(batches.len(), 1);
368        assert_eq!(batches[0].0.start.unix_timestamp(), 0);
369        // `end` saturates to `time`'s max representable datetime.
370        assert_eq!(
371            batches[0].0.end,
372            batches[0]
373                .0
374                .start
375                .saturating_add(time::Duration::seconds(i64::MAX))
376        );
377        assert_eq!(batches[0].1, vec![1, 2]);
378    }
379
380    #[tokio::test]
381    async fn mem_tier_drains_closed_window_on_next_ingest() {
382        let inner = SharedSink::new();
383        let mut sink = Tier::new(
384            MemStore::new(),
385            FlushPolicy::every(Duration::from_secs(300)),
386            inner.clone(),
387        );
388
389        sink.ingest(&meta_at("p", 10), vec![1]).await.unwrap();
390        assert!(inner.batches().is_empty());
391
392        // The next tick lands in the following aligned window: the previous
393        // window is now closed and ships; the new record stays held.
394        sink.ingest(&meta_at("p", 310), vec![2]).await.unwrap();
395        let batches = inner.batches();
396        assert_eq!(batches.len(), 1);
397        assert_eq!(batches[0].0.pipeline, "p");
398        assert_eq!(batches[0].0.start.unix_timestamp(), 0);
399        assert_eq!(batches[0].0.end.unix_timestamp(), 300);
400        assert_eq!(batches[0].1, vec![1]);
401    }
402
403    #[tokio::test]
404    async fn mem_tier_retains_records_across_failing_downstream() {
405        let inner = SharedSink::new();
406        let mut sink = Tier::new(
407            MemStore::new(),
408            FlushPolicy::new(Duration::from_secs(3600), 2),
409            inner.clone(),
410        );
411
412        inner.set_fail(true);
413        assert!(sink.ingest(&meta_at("p", 10), vec![1, 2]).await.is_err());
414        assert!(inner.batches().is_empty());
415
416        inner.set_fail(false);
417        sink.ingest(&meta_at("p", 20), vec![3]).await.unwrap();
418        let batches = inner.batches();
419        assert_eq!(batches.len(), 1);
420        assert_eq!(batches[0].1, vec![1, 2, 3]);
421    }
422
423    #[tokio::test]
424    async fn flush_drains_the_whole_stack() {
425        let bottom = SharedSink::new();
426        let mut sink = Tier::new(
427            MemStore::new(),
428            FlushPolicy::hourly(),
429            Tier::new(MemStore::new(), FlushPolicy::hourly(), bottom.clone()),
430        );
431
432        sink.ingest(&meta_at("p", 10), vec![1, 2, 3]).await.unwrap();
433        assert!(bottom.batches().is_empty());
434
435        sink.flush().await.unwrap();
436        assert_eq!(bottom.batches()[0].1, vec![1, 2, 3]);
437        assert!(bottom.flushed());
438    }
439
440    #[tokio::test]
441    async fn poison_window_does_not_block_newer_windows() {
442        let inner = SharedSink::new();
443        let armed = Arc::new(AtomicBool::new(true));
444        let mut sink = Tier::new(
445            MemStore::new(),
446            FlushPolicy::every(Duration::from_secs(300)),
447            PoisonSink {
448                inner: inner.clone(),
449                armed: Arc::clone(&armed),
450            },
451        );
452
453        // Window 0 gets the poison record; it closes on the next tick.
454        sink.ingest(&meta_at("p", 10), vec![13]).await.unwrap();
455        assert!(sink.ingest(&meta_at("p", 310), vec![2]).await.is_err());
456        assert!(inner.batches().is_empty());
457
458        // Next tick: window 0 still fails, but the now-closed window 300
459        // drains past it. The first error still surfaces.
460        assert!(sink.ingest(&meta_at("p", 610), vec![3]).await.is_err());
461        let batches = inner.batches();
462        assert_eq!(batches.len(), 1);
463        assert_eq!(batches[0].0.start.unix_timestamp(), 300);
464        assert_eq!(batches[0].1, vec![2]);
465
466        // The poison window was retained, not dropped: once the sink
467        // accepts it, it ships before newer held windows.
468        armed.store(false, Ordering::SeqCst);
469        sink.flush().await.unwrap();
470        let batches = inner.batches();
471        assert_eq!(batches.len(), 3);
472        assert_eq!(batches[1].0.start.unix_timestamp(), 0);
473        assert_eq!(batches[1].1, vec![13]);
474        assert_eq!(batches[2].0.start.unix_timestamp(), 600);
475        assert_eq!(batches[2].1, vec![3]);
476    }
477
478    #[tokio::test]
479    async fn outage_retains_all_windows_and_recovers_in_order() {
480        let inner = SharedSink::new();
481        let mut sink = Tier::new(
482            MemStore::new(),
483            FlushPolicy::every(Duration::from_secs(300)),
484            inner.clone(),
485        );
486
487        sink.ingest(&meta_at("p", 10), vec![1]).await.unwrap();
488        inner.set_fail(true);
489        assert!(sink.ingest(&meta_at("p", 310), vec![2]).await.is_err());
490        assert!(sink.ingest(&meta_at("p", 610), vec![3]).await.is_err());
491        assert!(inner.batches().is_empty());
492
493        inner.set_fail(false);
494        sink.flush().await.unwrap();
495        let starts = inner
496            .batches()
497            .iter()
498            .map(|(meta, records)| (meta.start.unix_timestamp(), records.clone()))
499            .collect::<Vec<_>>();
500        assert_eq!(starts, vec![(0, vec![1]), (300, vec![2]), (600, vec![3])]);
501    }
502}