Skip to main content

meathook/layer/
disk.rs

1//! [`DiskSpool`]: durable write-ahead tier with replay-on-restart.
2//!
3//! The **disk is the buffer**: `ingest` appends records as JSON lines to the
4//! active segment file and fsyncs before returning, so once a tick's ingest
5//! returns those records survive `SIGKILL`. `flush` reads segments back,
6//! pushes them downstream, and deletes each segment only after the
7//! downstream sink accepted it — a failed downstream leaves the segment in
8//! place to be retried at the next firing.
9//!
10//! On-disk layout (one directory per pipeline):
11//!
12//! ```text
13//! {spool_dir}/{pipeline}/{window_start_unix}.jsonl
14//! ```
15//!
16//! Segment files are named by the start of their flush window (unix seconds,
17//! aligned to the policy's `every`), so [`WindowMeta`] is reconstructed from
18//! the filename alone — leftover segments from a crashed run replay with
19//! their original window and land at the same storage path (idempotent).
20
21use std::error;
22use std::fs;
23use std::io;
24use std::io::Write;
25use std::marker::PhantomData;
26use std::path::{Path, PathBuf};
27
28use serde::Serialize;
29use serde::de::DeserializeOwned;
30use time::OffsetDateTime;
31use tracing::{debug, info, warn};
32
33use super::FlushPolicy;
34use crate::sink::{Sink, WindowMeta};
35
36/// Error from a [`DiskSpool`] layer.
37#[derive(Debug, thiserror::Error)]
38pub enum SpoolError<E>
39where
40    E: error::Error + Send + Sync + 'static,
41{
42    #[error("spool I/O error at {path}: {source}")]
43    Io {
44        path: PathBuf,
45        #[source]
46        source: io::Error,
47    },
48    #[error("failed to serialize record for spooling: {0}")]
49    Serialize(#[source] serde_json::Error),
50    #[error("downstream sink error: {0}")]
51    Downstream(#[source] E),
52}
53
54/// Durable write-ahead spool tier. See the [module docs](self) for the
55/// on-disk protocol and crash-recovery semantics.
56///
57/// Construction is infallible and does no I/O; the spool directory is
58/// created and leftover segments from a previous run are replayed
59/// downstream on the first `ingest` or `flush` call.
60///
61/// File I/O uses synchronous `std::fs` calls: appends are a few kilobytes
62/// plus an fsync, which is acceptable to block the runtime for at the
63/// collection rates this crate targets.
64pub struct DiskSpool<R, S> {
65    pipeline: String,
66    dir: PathBuf,
67    policy: FlushPolicy,
68    inner: S,
69    initialized: bool,
70    active_window: Option<i64>,
71    active_count: usize,
72    _record: PhantomData<fn() -> R>,
73}
74
75impl<R, S> DiskSpool<R, S> {
76    /// Create a spool rooted at `dir`. The pipeline name used when
77    /// reconstructing [`WindowMeta`] for replayed segments is the last
78    /// component of `dir`.
79    #[must_use]
80    pub fn new(dir: impl Into<PathBuf>, policy: FlushPolicy, inner: S) -> Self {
81        let dir = dir.into();
82        let pipeline = dir.file_name().map_or_else(
83            || "unknown".to_owned(),
84            |n| n.to_string_lossy().into_owned(),
85        );
86        Self {
87            pipeline,
88            dir,
89            policy,
90            inner,
91            initialized: false,
92            active_window: None,
93            active_count: 0,
94            _record: PhantomData,
95        }
96    }
97
98    /// Override the pipeline name derived from the directory.
99    #[must_use]
100    pub fn with_pipeline_name(mut self, name: impl Into<String>) -> Self {
101        self.pipeline = name.into();
102        self
103    }
104
105    fn window_secs(&self) -> i64 {
106        i64::try_from(self.policy.every.as_secs())
107            .unwrap_or(i64::MAX)
108            .max(1)
109    }
110
111    fn align(&self, unix: i64) -> i64 {
112        unix - unix.rem_euclid(self.window_secs())
113    }
114
115    fn segment_path(&self, window_start: i64) -> PathBuf {
116        self.dir.join(format!("{window_start}.jsonl"))
117    }
118}
119
120impl<R, S> DiskSpool<R, S>
121where
122    R: Serialize + DeserializeOwned + Send + 'static,
123    S: Sink<R>,
124{
125    fn io_err(path: &Path, source: io::Error) -> SpoolError<S::Error> {
126        SpoolError::Io {
127            path: path.to_owned(),
128            source,
129        }
130    }
131
132    /// Create the spool dir and replay anything left behind by a previous
133    /// run. Replay failures are logged, not propagated: the segments stay
134    /// on disk and are retried at the next firing.
135    async fn ensure_init(&mut self) -> Result<(), SpoolError<S::Error>> {
136        if self.initialized {
137            return Ok(());
138        }
139        fs::create_dir_all(&self.dir).map_err(|e| Self::io_err(&self.dir, e))?;
140        self.initialized = true;
141        let leftovers = self.list_segments()?;
142        if !leftovers.is_empty() {
143            info!(
144                pipeline = %self.pipeline,
145                segments = leftovers.len(),
146                "replaying spool segments left by a previous run"
147            );
148            if let Err(error) = self.drain(true).await {
149                warn!(
150                    pipeline = %self.pipeline,
151                    %error,
152                    "spool recovery failed; segments retained for retry"
153                );
154            }
155        }
156        Ok(())
157    }
158
159    /// All segment files in the spool dir, oldest first.
160    fn list_segments(&self) -> Result<Vec<(i64, PathBuf)>, SpoolError<S::Error>> {
161        let mut segments = vec![];
162        let entries = fs::read_dir(&self.dir).map_err(|e| Self::io_err(&self.dir, e))?;
163        for entry in entries {
164            let entry = entry.map_err(|e| Self::io_err(&self.dir, e))?;
165            let path = entry.path();
166            if path.extension().is_none_or(|ext| ext != "jsonl") {
167                continue;
168            }
169            let Some(start) = path
170                .file_stem()
171                .and_then(|s| s.to_str())
172                .and_then(|s| s.parse::<i64>().ok())
173            else {
174                warn!(path = %path.display(), "ignoring unrecognized file in spool dir");
175                continue;
176            };
177            segments.push((start, path));
178        }
179        segments.sort_unstable_by_key(|(start, _)| *start);
180        Ok(segments)
181    }
182
183    /// Append records to the active segment, fsyncing the file (and the
184    /// directory when the segment is new) before returning.
185    fn append(&mut self, records: &[R]) -> Result<(), SpoolError<S::Error>> {
186        let window = self.align(OffsetDateTime::now_utc().unix_timestamp());
187        let path = self.segment_path(window);
188
189        let mut lines = vec![];
190        for record in records {
191            serde_json::to_writer(&mut lines, record).map_err(SpoolError::Serialize)?;
192            lines.push(b'\n');
193        }
194
195        let is_new = !path.exists();
196        let mut file = fs::OpenOptions::new()
197            .create(true)
198            .append(true)
199            .open(&path)
200            .map_err(|e| Self::io_err(&path, e))?;
201        file.write_all(&lines).map_err(|e| Self::io_err(&path, e))?;
202        file.sync_all().map_err(|e| Self::io_err(&path, e))?;
203        if is_new {
204            fs::File::open(&self.dir)
205                .and_then(|d| d.sync_all())
206                .map_err(|e| Self::io_err(&self.dir, e))?;
207        }
208
209        if self.active_window != Some(window) {
210            self.active_window = Some(window);
211            self.active_count = 0;
212        }
213        self.active_count += records.len();
214        Ok(())
215    }
216
217    /// Read a segment, push it downstream, and delete it on success.
218    async fn flush_segment(
219        &mut self,
220        window_start: i64,
221        path: &Path,
222    ) -> Result<(), SpoolError<S::Error>> {
223        let contents = fs::read_to_string(path).map_err(|e| Self::io_err(path, e))?;
224        let lines = contents
225            .lines()
226            .filter(|l| !l.is_empty())
227            .collect::<Vec<&str>>();
228        let mut records = Vec::with_capacity(lines.len());
229        let last = lines.len().saturating_sub(1);
230        for (i, line) in lines.iter().enumerate() {
231            match serde_json::from_str::<R>(line) {
232                Ok(record) => records.push(record),
233                Err(error) if i == last => {
234                    warn!(
235                        path = %path.display(),
236                        %error,
237                        "skipping torn final line in spool segment (crash mid-append)"
238                    );
239                }
240                Err(error) => {
241                    warn!(
242                        path = %path.display(),
243                        line = i,
244                        %error,
245                        "skipping corrupt line in spool segment"
246                    );
247                }
248            }
249        }
250
251        if !records.is_empty() {
252            let start = OffsetDateTime::from_unix_timestamp(window_start)
253                .unwrap_or(OffsetDateTime::UNIX_EPOCH);
254            let meta = WindowMeta {
255                pipeline: self.pipeline.clone(),
256                start,
257                end: start + time::Duration::seconds(self.window_secs()),
258            };
259            debug!(
260                pipeline = %self.pipeline,
261                window_start,
262                records = records.len(),
263                "flushing spool segment downstream"
264            );
265            self.inner
266                .ingest(&meta, records)
267                .await
268                .map_err(SpoolError::Downstream)?;
269        }
270
271        fs::remove_file(path).map_err(|e| Self::io_err(path, e))?;
272        if Some(window_start) == self.active_window {
273            self.active_window = None;
274            self.active_count = 0;
275        }
276        Ok(())
277    }
278
279    /// Flush segments downstream, oldest first. Failed segments are left in
280    /// place and the remaining segments are still attempted; the first error
281    /// is returned.
282    async fn drain(&mut self, include_active: bool) -> Result<(), SpoolError<S::Error>> {
283        let mut first_error = None;
284        for (window_start, path) in self.list_segments()? {
285            if !include_active && Some(window_start) == self.active_window {
286                continue;
287            }
288            if let Err(error) = self.flush_segment(window_start, &path).await
289                && first_error.is_none()
290            {
291                first_error = Some(error);
292            }
293        }
294        match first_error {
295            None => Ok(()),
296            Some(error) => Err(error),
297        }
298    }
299
300    fn has_closed_segments(&self) -> Result<bool, SpoolError<S::Error>> {
301        Ok(self
302            .list_segments()?
303            .iter()
304            .any(|(start, _)| Some(*start) != self.active_window))
305    }
306}
307
308impl<R, S> Sink<R> for DiskSpool<R, S>
309where
310    R: Serialize + DeserializeOwned + Send + 'static,
311    S: Sink<R>,
312{
313    type Error = SpoolError<S::Error>;
314
315    async fn ingest(&mut self, _meta: &WindowMeta, records: Vec<R>) -> Result<(), Self::Error> {
316        self.ensure_init().await?;
317        if !records.is_empty() {
318            self.append(&records)?;
319        }
320        if self.active_count >= self.policy.max_records {
321            self.drain(true).await
322        } else if self.has_closed_segments()? {
323            self.drain(false).await
324        } else {
325            Ok(())
326        }
327    }
328
329    async fn flush(&mut self) -> Result<(), Self::Error> {
330        self.ensure_init().await?;
331        self.drain(true).await?;
332        self.inner.flush().await.map_err(SpoolError::Downstream)
333    }
334}
335
336#[cfg(test)]
337mod tests {
338    use std::time::Duration;
339
340    use super::*;
341    use crate::layer::SinkExt;
342    use crate::test_util::{SharedSink, meta};
343
344    fn policy() -> FlushPolicy {
345        FlushPolicy::new(Duration::from_secs(3600), usize::MAX)
346    }
347
348    #[tokio::test]
349    async fn ingest_is_write_ahead() {
350        let dir = tempfile::tempdir().unwrap();
351        let spool_dir = dir.path().join("p");
352        let inner = SharedSink::new();
353        let mut spool = inner.clone().spooled(&spool_dir, policy());
354
355        spool.ingest(&meta("p"), vec![1, 2]).await.unwrap();
356        spool.ingest(&meta("p"), vec![3]).await.unwrap();
357
358        // Records are on disk before any flush fires.
359        let files = fs::read_dir(&spool_dir).unwrap().collect::<Vec<_>>();
360        assert_eq!(files.len(), 1);
361        let contents = fs::read_to_string(files[0].as_ref().unwrap().path()).unwrap();
362        assert_eq!(contents.lines().count(), 3);
363        assert!(inner.batches().is_empty());
364    }
365
366    #[tokio::test]
367    async fn replays_leftover_segments_on_first_use() {
368        let dir = tempfile::tempdir().unwrap();
369        let spool_dir = dir.path().join("weather");
370        fs::create_dir_all(&spool_dir).unwrap();
371        // Two leftover segments from a "previous run", an hour apart.
372        fs::write(spool_dir.join("3600.jsonl"), "1\n2\n").unwrap();
373        fs::write(spool_dir.join("7200.jsonl"), "3\n").unwrap();
374
375        let inner = SharedSink::new();
376        let mut spool: DiskSpool<i32, _> = inner.clone().spooled(&spool_dir, policy());
377        spool.flush().await.unwrap();
378
379        let batches = inner.batches();
380        assert_eq!(batches.len(), 2);
381        // Oldest first, meta reconstructed from the filename.
382        assert_eq!(batches[0].0.start.unix_timestamp(), 3600);
383        assert_eq!(batches[0].0.pipeline, "weather");
384        assert_eq!(batches[0].1, vec![1, 2]);
385        assert_eq!(batches[1].0.start.unix_timestamp(), 7200);
386        assert_eq!(batches[1].1, vec![3]);
387        // Segments are gone after a successful replay.
388        assert_eq!(fs::read_dir(&spool_dir).unwrap().count(), 0);
389    }
390
391    #[tokio::test]
392    async fn tolerates_torn_final_line() {
393        let dir = tempfile::tempdir().unwrap();
394        let spool_dir = dir.path().join("p");
395        fs::create_dir_all(&spool_dir).unwrap();
396        fs::write(spool_dir.join("3600.jsonl"), "1\n2\n{\"trunc").unwrap();
397
398        let inner = SharedSink::new();
399        let mut spool: DiskSpool<i32, _> = inner.clone().spooled(&spool_dir, policy());
400        spool.flush().await.unwrap();
401
402        assert_eq!(inner.batches()[0].1, vec![1, 2]);
403    }
404
405    #[tokio::test]
406    async fn retains_segments_across_failing_downstream() {
407        let dir = tempfile::tempdir().unwrap();
408        let spool_dir = dir.path().join("p");
409        let inner = SharedSink::new();
410        let mut spool = inner.clone().spooled(&spool_dir, policy());
411
412        spool.ingest(&meta("p"), vec![1, 2]).await.unwrap();
413
414        inner.set_fail(true);
415        assert!(spool.flush().await.is_err());
416        assert_eq!(fs::read_dir(&spool_dir).unwrap().count(), 1);
417        assert!(inner.batches().is_empty());
418
419        inner.set_fail(false);
420        spool.flush().await.unwrap();
421        assert_eq!(inner.batches()[0].1, vec![1, 2]);
422        assert_eq!(fs::read_dir(&spool_dir).unwrap().count(), 0);
423    }
424
425    #[tokio::test]
426    async fn max_records_drains_active_segment() {
427        let dir = tempfile::tempdir().unwrap();
428        let spool_dir = dir.path().join("p");
429        let inner = SharedSink::new();
430        let mut spool = inner
431            .clone()
432            .spooled(&spool_dir, FlushPolicy::new(Duration::from_secs(3600), 3));
433
434        spool.ingest(&meta("p"), vec![1, 2]).await.unwrap();
435        assert!(inner.batches().is_empty());
436        spool.ingest(&meta("p"), vec![3]).await.unwrap();
437
438        assert_eq!(inner.batches()[0].1, vec![1, 2, 3]);
439        assert_eq!(fs::read_dir(&spool_dir).unwrap().count(), 0);
440    }
441}