1use 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#[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
54pub 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 #[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 #[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 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 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 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 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 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 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 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 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 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}