use std::fs;
use std::io::{self, Write};
use std::marker::PhantomData;
use std::path::{Path, PathBuf};
use serde::Serialize;
use serde::de::DeserializeOwned;
use tracing::warn;
use super::{Segment, Store};
#[derive(Debug, thiserror::Error)]
pub enum JsonlStoreError {
#[error("store I/O error at {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("failed to serialize record for spooling: {0}")]
Serialize(#[source] serde_json::Error),
}
fn io_err(path: &Path, source: io::Error) -> JsonlStoreError {
JsonlStoreError::Io {
path: path.to_owned(),
source,
}
}
pub struct JsonlStore<R> {
dir: PathBuf,
pipeline: String,
initialized: bool,
_record: PhantomData<fn() -> R>,
}
impl<R> JsonlStore<R> {
#[must_use]
pub fn new(dir: impl Into<PathBuf>) -> Self {
let dir = dir.into();
let pipeline = dir.file_name().map_or_else(
|| "unknown".to_owned(),
|n| n.to_string_lossy().into_owned(),
);
Self {
dir,
pipeline,
initialized: false,
_record: PhantomData,
}
}
#[must_use]
pub fn with_pipeline_name(mut self, name: impl Into<String>) -> Self {
self.pipeline = name.into();
self
}
fn ensure_dir(&mut self) -> Result<(), JsonlStoreError> {
if !self.initialized {
fs::create_dir_all(&self.dir).map_err(|e| io_err(&self.dir, e))?;
self.initialized = true;
}
Ok(())
}
fn segment_path(&self, window: i64) -> PathBuf {
self.dir.join(format!("{window}.jsonl"))
}
fn list_segments(&self) -> Result<Vec<(i64, PathBuf)>, JsonlStoreError> {
let mut segments = vec![];
let entries = fs::read_dir(&self.dir).map_err(|e| io_err(&self.dir, e))?;
for entry in entries {
let entry = entry.map_err(|e| io_err(&self.dir, e))?;
let path = entry.path();
if path.extension().is_none_or(|ext| ext != "jsonl") {
continue;
}
let Some(start) = path
.file_stem()
.and_then(|s| s.to_str())
.and_then(|s| s.parse::<i64>().ok())
else {
warn!(path = %path.display(), "ignoring unrecognized file in store dir");
continue;
};
segments.push((start, path));
}
segments.sort_unstable_by_key(|(start, _)| *start);
Ok(segments)
}
}
impl<R> Store<R> for JsonlStore<R>
where
R: Serialize + DeserializeOwned + Send + 'static,
{
type Error = JsonlStoreError;
type Segment<'a>
= JsonlSegment<R>
where
Self: 'a;
async fn append(&mut self, window: i64, records: Vec<R>) -> Result<(), JsonlStoreError> {
self.ensure_dir()?;
let path = self.segment_path(window);
let mut lines = vec![];
for record in &records {
serde_json::to_writer(&mut lines, record).map_err(JsonlStoreError::Serialize)?;
lines.push(b'\n');
}
let is_new = !path.exists();
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|e| io_err(&path, e))?;
file.write_all(&lines).map_err(|e| io_err(&path, e))?;
file.sync_all().map_err(|e| io_err(&path, e))?;
if is_new {
fs::File::open(&self.dir)
.and_then(|d| d.sync_all())
.map_err(|e| io_err(&self.dir, e))?;
}
Ok(())
}
async fn oldest(
&mut self,
after: Option<i64>,
) -> Result<Option<JsonlSegment<R>>, JsonlStoreError> {
self.ensure_dir()?;
Ok(self
.list_segments()?
.into_iter()
.find(|(window, _)| after.is_none_or(|a| *window > a))
.map(|(window, path)| JsonlSegment {
window,
path,
_record: PhantomData,
}))
}
fn pipeline_hint(&self) -> Option<&str> {
Some(&self.pipeline)
}
}
pub struct JsonlSegment<R> {
window: i64,
path: PathBuf,
_record: PhantomData<fn() -> R>,
}
impl<R> Segment<R> for JsonlSegment<R>
where
R: DeserializeOwned + Send + 'static,
{
type Error = JsonlStoreError;
fn window(&self) -> i64 {
self.window
}
async fn records(&mut self) -> Result<Vec<R>, JsonlStoreError> {
let contents = fs::read_to_string(&self.path).map_err(|e| io_err(&self.path, e))?;
let lines = contents
.lines()
.filter(|l| !l.is_empty())
.collect::<Vec<&str>>();
let mut records = Vec::with_capacity(lines.len());
let last = lines.len().saturating_sub(1);
for (i, line) in lines.iter().enumerate() {
match serde_json::from_str::<R>(line) {
Ok(record) => records.push(record),
Err(error) if i == last => {
warn!(
path = %self.path.display(),
%error,
"skipping torn final line in store segment (crash mid-append)"
);
}
Err(error) => {
warn!(
path = %self.path.display(),
line = i,
%error,
"skipping corrupt line in store segment"
);
}
}
}
Ok(records)
}
async fn commit(self) -> Result<(), JsonlStoreError> {
fs::remove_file(&self.path).map_err(|e| io_err(&self.path, e))
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::*;
use crate::layer::{FlushPolicy, SinkExt};
use crate::sink::Sink;
use crate::test_util::{SharedSink, meta_at};
fn policy() -> FlushPolicy {
FlushPolicy::new(Duration::from_secs(3600), usize::MAX)
}
#[tokio::test]
async fn tier_ingest_is_write_ahead() {
let dir = tempfile::tempdir().unwrap();
let store_dir = dir.path().join("p");
let inner = SharedSink::new();
let mut tier = inner.clone().tier(JsonlStore::new(&store_dir), policy());
tier.ingest(&meta_at("p", 10), vec![1, 2]).await.unwrap();
tier.ingest(&meta_at("p", 20), vec![3]).await.unwrap();
let files = fs::read_dir(&store_dir).unwrap().collect::<Vec<_>>();
assert_eq!(files.len(), 1);
let contents = fs::read_to_string(files[0].as_ref().unwrap().path()).unwrap();
assert_eq!(contents.lines().count(), 3);
assert!(inner.batches().is_empty());
}
#[tokio::test]
async fn replays_leftover_segments_on_first_use() {
let dir = tempfile::tempdir().unwrap();
let store_dir = dir.path().join("weather");
fs::create_dir_all(&store_dir).unwrap();
fs::write(store_dir.join("3600.jsonl"), "1\n2\n").unwrap();
fs::write(store_dir.join("7200.jsonl"), "3\n").unwrap();
let inner = SharedSink::new();
let mut tier = inner
.clone()
.tier(JsonlStore::<i32>::new(&store_dir), policy());
tier.flush().await.unwrap();
let batches = inner.batches();
assert_eq!(batches.len(), 2);
assert_eq!(batches[0].0.start.unix_timestamp(), 3600);
assert_eq!(batches[0].0.pipeline, "weather");
assert_eq!(batches[0].1, vec![1, 2]);
assert_eq!(batches[1].0.start.unix_timestamp(), 7200);
assert_eq!(batches[1].1, vec![3]);
assert_eq!(fs::read_dir(&store_dir).unwrap().count(), 0);
}
#[tokio::test]
async fn tier_flush_tolerates_torn_final_line() {
let dir = tempfile::tempdir().unwrap();
let store_dir = dir.path().join("p");
fs::create_dir_all(&store_dir).unwrap();
fs::write(store_dir.join("3600.jsonl"), "1\n2\n{\"trunc").unwrap();
let inner = SharedSink::new();
let mut tier = inner
.clone()
.tier(JsonlStore::<i32>::new(&store_dir), policy());
tier.flush().await.unwrap();
assert_eq!(inner.batches()[0].1, vec![1, 2]);
assert_eq!(fs::read_dir(&store_dir).unwrap().count(), 0);
}
#[tokio::test]
async fn tier_retains_segments_across_failing_downstream() {
let dir = tempfile::tempdir().unwrap();
let store_dir = dir.path().join("p");
let inner = SharedSink::new();
let mut tier = inner.clone().tier(JsonlStore::new(&store_dir), policy());
tier.ingest(&meta_at("p", 10), vec![1, 2]).await.unwrap();
inner.set_fail(true);
assert!(tier.flush().await.is_err());
assert_eq!(fs::read_dir(&store_dir).unwrap().count(), 1);
assert!(inner.batches().is_empty());
inner.set_fail(false);
tier.flush().await.unwrap();
assert_eq!(inner.batches()[0].1, vec![1, 2]);
assert_eq!(fs::read_dir(&store_dir).unwrap().count(), 0);
}
#[tokio::test]
async fn tier_max_records_drains_active_segment() {
let dir = tempfile::tempdir().unwrap();
let store_dir = dir.path().join("p");
let inner = SharedSink::new();
let mut tier = inner.clone().tier(
JsonlStore::new(&store_dir),
FlushPolicy::new(Duration::from_secs(3600), 3),
);
tier.ingest(&meta_at("p", 10), vec![1, 2]).await.unwrap();
assert!(inner.batches().is_empty());
tier.ingest(&meta_at("p", 20), vec![3]).await.unwrap();
assert_eq!(inner.batches()[0].1, vec![1, 2, 3]);
assert_eq!(fs::read_dir(&store_dir).unwrap().count(), 0);
}
#[tokio::test]
async fn append_is_write_ahead_on_disk() {
let dir = tempfile::tempdir().unwrap();
let store_dir = dir.path().join("p");
let mut store = JsonlStore::new(&store_dir);
store.append(0, vec![1, 2]).await.unwrap();
store.append(0, vec![3]).await.unwrap();
let contents = fs::read_to_string(store_dir.join("0.jsonl")).unwrap();
assert_eq!(contents, "1\n2\n3\n");
}
#[tokio::test]
async fn oldest_is_oldest_window_and_commit_deletes() {
let dir = tempfile::tempdir().unwrap();
let store_dir = dir.path().join("p");
let mut store = JsonlStore::new(&store_dir);
store.append(7200, vec![3]).await.unwrap();
store.append(3600, vec![1, 2]).await.unwrap();
let mut seg = store.oldest(None).await.unwrap().unwrap();
assert_eq!(seg.window(), 3600);
assert_eq!(seg.records().await.unwrap(), vec![1, 2]);
seg.commit().await.unwrap();
assert!(!store_dir.join("3600.jsonl").exists());
let seg = store.oldest(None).await.unwrap().unwrap();
assert_eq!(seg.window(), 7200);
}
#[tokio::test]
async fn oldest_after_skips_windows_at_or_below_cursor() {
let dir = tempfile::tempdir().unwrap();
let store_dir = dir.path().join("p");
let mut store = JsonlStore::new(&store_dir);
store.append(3600, vec![1]).await.unwrap();
store.append(7200, vec![2]).await.unwrap();
let seg = store.oldest(Some(3600)).await.unwrap().unwrap();
assert_eq!(seg.window(), 7200);
assert!(store.oldest(Some(7200)).await.unwrap().is_none());
assert!(store_dir.join("3600.jsonl").exists());
}
#[tokio::test]
async fn ignores_non_jsonl_files() {
let dir = tempfile::tempdir().unwrap();
let store_dir = dir.path().join("p");
fs::create_dir_all(&store_dir).unwrap();
fs::write(store_dir.join("notes.txt"), "hi").unwrap();
fs::write(store_dir.join("weird.jsonl"), "1\n").unwrap();
fs::write(store_dir.join("100.jsonl"), "1\n").unwrap();
let mut store: JsonlStore<i32> = JsonlStore::new(&store_dir);
let seg = store.oldest(None).await.unwrap().unwrap();
assert_eq!(seg.window(), 100);
}
#[tokio::test]
async fn segment_tolerates_torn_final_line() {
let dir = tempfile::tempdir().unwrap();
let store_dir = dir.path().join("p");
fs::create_dir_all(&store_dir).unwrap();
fs::write(store_dir.join("3600.jsonl"), "1\n2\n{\"trunc").unwrap();
let mut store: JsonlStore<i32> = JsonlStore::new(&store_dir);
let mut seg = store.oldest(None).await.unwrap().unwrap();
assert_eq!(seg.records().await.unwrap(), vec![1, 2]);
}
#[test]
fn pipeline_hint_is_dir_derived_and_overridable() {
let store: JsonlStore<i32> = JsonlStore::new("/var/spool/weather");
assert_eq!(Store::<i32>::pipeline_hint(&store), Some("weather"));
let store = store.with_pipeline_name("rain");
assert_eq!(Store::<i32>::pipeline_hint(&store), Some("rain"));
}
}