Skip to main content

meathook/
layer.rs

1//! Sink combinators: the tower-layer part of meathook.
2//!
3//! Flush tiers stack: `Buffered(mem) → DiskSpool → HfSink`, each tier with
4//! its own [`FlushPolicy`]. Each layer owns its records until *its* policy
5//! fires, then pushes downstream.
6
7use std::error;
8use std::path;
9use std::time::Duration;
10
11use ::time::OffsetDateTime;
12use tokio::time::Instant;
13use tracing::debug;
14
15use crate::sink::{Sink, WindowMeta};
16
17mod disk;
18
19pub use disk::{DiskSpool, SpoolError};
20
21/// When a buffering layer pushes its held records downstream.
22///
23/// A layer fires when its window has been open for `every`, or when it holds
24/// at least `max_records`, whichever comes first.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub struct FlushPolicy {
27    /// Maximum age of a window before it is flushed downstream.
28    pub every: Duration,
29    /// Maximum records held before flushing early (a safety valve; size it
30    /// well above one window's worth of records).
31    pub max_records: usize,
32}
33
34impl FlushPolicy {
35    #[must_use]
36    pub const fn new(every: Duration, max_records: usize) -> Self {
37        Self { every, max_records }
38    }
39
40    /// Flush on age only, never on record count.
41    #[must_use]
42    pub const fn every(every: Duration) -> Self {
43        Self::new(every, usize::MAX)
44    }
45
46    /// One-hour windows, no record cap.
47    #[must_use]
48    pub const fn hourly() -> Self {
49        Self::every(Duration::from_secs(3600))
50    }
51}
52
53/// Builder-style composition for sinks, mirroring tower's `ServiceBuilder`.
54///
55/// ```ignore
56/// let sink = hf_sink.spooled("/var/lib/meathook/spool/pm25", FlushPolicy::hourly());
57/// ```
58pub trait SinkExt<R>: Sink<R> + Sized {
59    /// Wrap `self` in an in-memory buffering tier.
60    #[must_use]
61    fn buffered(self, policy: FlushPolicy) -> Buffered<R, Self> {
62        Buffered::new(policy, self)
63    }
64
65    /// Wrap `self` in a durable write-ahead spool rooted at `dir`.
66    ///
67    /// The pipeline name recorded in replayed [`WindowMeta`] is derived from
68    /// the last component of `dir`, so point each pipeline at
69    /// `spool_root.join(pipeline_name)`.
70    #[must_use]
71    fn spooled(self, dir: impl Into<path::PathBuf>, policy: FlushPolicy) -> DiskSpool<R, Self> {
72        DiskSpool::new(dir, policy, self)
73    }
74
75    /// Fan out: every batch is ingested into both `self` and `other`.
76    #[must_use]
77    fn tee<B: Sink<R>>(self, other: B) -> Tee<Self, B> {
78        Tee(self, other)
79    }
80}
81
82impl<R, S: Sink<R>> SinkExt<R> for S {}
83
84/// In-memory buffering tier: holds records and pushes them downstream when
85/// its [`FlushPolicy`] fires.
86///
87/// The policy is checked on each `ingest` (collector ticks are frequent
88/// compared to flush windows, so no extra timer task is needed);
89/// [`flush`](Sink::flush) force-drains. On downstream failure the records
90/// are **kept** and retried at the next firing — a transient outage of the
91/// terminal sink does not lose data held in this tier.
92///
93/// Requires `R: Clone` so retained records survive a failed downstream
94/// ingest.
95pub struct Buffered<R, S> {
96    buf: Vec<R>,
97    window: Option<Window>,
98    policy: FlushPolicy,
99    inner: S,
100}
101
102#[derive(Debug, Clone)]
103struct Window {
104    pipeline: String,
105    start: OffsetDateTime,
106    opened_at: Instant,
107}
108
109impl<R, S> Buffered<R, S> {
110    #[must_use]
111    pub fn new(policy: FlushPolicy, inner: S) -> Self {
112        Self {
113            buf: vec![],
114            window: None,
115            policy,
116            inner,
117        }
118    }
119
120    /// Access the wrapped sink.
121    #[must_use]
122    pub fn inner(&self) -> &S {
123        &self.inner
124    }
125}
126
127impl<R, S> Buffered<R, S>
128where
129    R: Clone + Send + 'static,
130    S: Sink<R>,
131{
132    async fn drain(&mut self) -> Result<(), S::Error> {
133        let Some(window) = &self.window else {
134            return Ok(());
135        };
136        let meta = WindowMeta {
137            pipeline: window.pipeline.clone(),
138            start: window.start,
139            end: OffsetDateTime::now_utc(),
140        };
141        debug!(
142            pipeline = %meta.pipeline,
143            records = self.buf.len(),
144            "buffered tier draining downstream"
145        );
146        self.inner.ingest(&meta, self.buf.clone()).await?;
147        self.buf.clear();
148        self.window = None;
149        Ok(())
150    }
151
152    fn should_fire(&self) -> bool {
153        let Some(window) = &self.window else {
154            return false;
155        };
156        self.buf.len() >= self.policy.max_records || window.opened_at.elapsed() >= self.policy.every
157    }
158}
159
160impl<R, S> Sink<R> for Buffered<R, S>
161where
162    R: Clone + Send + 'static,
163    S: Sink<R>,
164{
165    type Error = S::Error;
166
167    async fn ingest(&mut self, meta: &WindowMeta, records: Vec<R>) -> Result<(), Self::Error> {
168        if self.window.is_none() {
169            self.window = Some(Window {
170                pipeline: meta.pipeline.clone(),
171                start: meta.start,
172                opened_at: Instant::now(),
173            });
174        }
175        self.buf.extend(records);
176        if self.should_fire() {
177            self.drain().await?;
178        }
179        Ok(())
180    }
181
182    async fn flush(&mut self) -> Result<(), Self::Error> {
183        self.drain().await?;
184        self.inner.flush().await
185    }
186}
187
188/// Fan-out combinator: ingests every batch into both sinks.
189///
190/// Both branches are always attempted; errors are reported per branch via
191/// [`TeeError`].
192pub struct Tee<A, B>(pub A, pub B);
193
194/// Error from a [`Tee`]: one or both branches failed.
195#[derive(Debug, thiserror::Error)]
196pub enum TeeError<A, B>
197where
198    A: error::Error + Send + Sync + 'static,
199    B: error::Error + Send + Sync + 'static,
200{
201    #[error("tee: first sink failed: {0}")]
202    First(#[source] A),
203    #[error("tee: second sink failed: {0}")]
204    Second(#[source] B),
205    #[error("tee: both sinks failed: first: {first}; second: {second}")]
206    Both { first: A, second: B },
207}
208
209impl<R, A, B> Sink<R> for Tee<A, B>
210where
211    R: Clone + Send + 'static,
212    A: Sink<R>,
213    B: Sink<R>,
214{
215    type Error = TeeError<A::Error, B::Error>;
216
217    async fn ingest(&mut self, meta: &WindowMeta, records: Vec<R>) -> Result<(), Self::Error> {
218        let first = self.0.ingest(meta, records.clone()).await;
219        let second = self.1.ingest(meta, records).await;
220        match (first, second) {
221            (Ok(()), Ok(())) => Ok(()),
222            (Err(a), Ok(())) => Err(TeeError::First(a)),
223            (Ok(()), Err(b)) => Err(TeeError::Second(b)),
224            (Err(a), Err(b)) => Err(TeeError::Both {
225                first: a,
226                second: b,
227            }),
228        }
229    }
230
231    async fn flush(&mut self) -> Result<(), Self::Error> {
232        let first = self.0.flush().await;
233        let second = self.1.flush().await;
234        match (first, second) {
235            (Ok(()), Ok(())) => Ok(()),
236            (Err(a), Ok(())) => Err(TeeError::First(a)),
237            (Ok(()), Err(b)) => Err(TeeError::Second(b)),
238            (Err(a), Err(b)) => Err(TeeError::Both {
239                first: a,
240                second: b,
241            }),
242        }
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use crate::test_util::{SharedSink, meta};
250    use tokio::time;
251
252    #[tokio::test]
253    async fn buffered_holds_until_max_records() {
254        let inner = SharedSink::new();
255        let mut sink = inner
256            .clone()
257            .buffered(FlushPolicy::new(Duration::from_secs(3600), 3));
258
259        sink.ingest(&meta("p"), vec![1, 2]).await.unwrap();
260        assert!(inner.batches().is_empty());
261
262        sink.ingest(&meta("p"), vec![3]).await.unwrap();
263        let batches = inner.batches();
264        assert_eq!(batches.len(), 1);
265        assert_eq!(batches[0].1, vec![1, 2, 3]);
266    }
267
268    #[tokio::test(start_paused = true)]
269    async fn buffered_fires_on_elapsed_window() {
270        let inner = SharedSink::new();
271        let mut sink = inner
272            .clone()
273            .buffered(FlushPolicy::every(Duration::from_secs(300)));
274
275        sink.ingest(&meta("p"), vec![1]).await.unwrap();
276        assert!(inner.batches().is_empty());
277
278        time::advance(Duration::from_secs(301)).await;
279        sink.ingest(&meta("p"), vec![2]).await.unwrap();
280
281        let batches = inner.batches();
282        assert_eq!(batches.len(), 1);
283        assert_eq!(batches[0].1, vec![1, 2]);
284    }
285
286    #[tokio::test]
287    async fn buffered_retains_records_across_failing_downstream() {
288        let inner = SharedSink::new();
289        let mut sink = inner
290            .clone()
291            .buffered(FlushPolicy::new(Duration::from_secs(3600), 2));
292
293        inner.set_fail(true);
294        assert!(sink.ingest(&meta("p"), vec![1, 2]).await.is_err());
295        assert!(inner.batches().is_empty());
296
297        inner.set_fail(false);
298        sink.ingest(&meta("p"), vec![3]).await.unwrap();
299        let batches = inner.batches();
300        assert_eq!(batches.len(), 1);
301        assert_eq!(batches[0].1, vec![1, 2, 3]);
302    }
303
304    #[tokio::test]
305    async fn tee_fans_out_to_both_branches() {
306        let a = SharedSink::new();
307        let b = SharedSink::new();
308        let mut sink = a.clone().tee(b.clone());
309
310        sink.ingest(&meta("p"), vec![1, 2]).await.unwrap();
311        assert_eq!(a.batches()[0].1, vec![1, 2]);
312        assert_eq!(b.batches()[0].1, vec![1, 2]);
313    }
314
315    #[tokio::test]
316    async fn tee_reports_failing_branch_but_feeds_the_other() {
317        let a = SharedSink::new();
318        let b = SharedSink::new();
319        a.set_fail(true);
320        let mut sink = a.clone().tee(b.clone());
321
322        let err = sink.ingest(&meta("p"), vec![1]).await.unwrap_err();
323        assert!(matches!(err, TeeError::First(_)));
324        assert_eq!(b.batches().len(), 1);
325    }
326
327    #[tokio::test]
328    async fn flush_drains_the_whole_stack() {
329        let bottom = SharedSink::new();
330        let mut sink = bottom
331            .clone()
332            .buffered(FlushPolicy::hourly())
333            .buffered(FlushPolicy::hourly());
334
335        sink.ingest(&meta("p"), vec![1, 2, 3]).await.unwrap();
336        assert!(bottom.batches().is_empty());
337
338        sink.flush().await.unwrap();
339        assert_eq!(bottom.batches()[0].1, vec![1, 2, 3]);
340        assert!(bottom.flushed());
341    }
342}