kumo_jsonl/
writer.rs

1use camino::Utf8PathBuf;
2use chrono::{DateTime, Utc};
3use chrono_tz::Tz;
4use serde::{Deserialize, Serialize};
5use std::io::Write;
6use std::time::Duration;
7use zstd::stream::write::Encoder;
8
9/// Configuration for constructing a [`LogWriter`].
10#[derive(Deserialize, Serialize)]
11pub struct LogWriterConfig {
12    /// Directory where log segment files will be created.
13    pub log_dir: Utf8PathBuf,
14    /// Maximum number of uncompressed bytes written per segment
15    /// before rolling to a new file.
16    #[serde(default = "default_max_file_size")]
17    pub max_file_size: u64,
18    /// Zstd compression level.
19    #[serde(default = "default_compression_level")]
20    pub compression_level: i32,
21    /// The largest a record and its newline separator may be, in bytes. A
22    /// record that does not fit within it is rejected with an error. Defaults
23    /// to [`DEFAULT_MAX_LINE_SIZE`](crate::DEFAULT_MAX_LINE_SIZE).
24    #[serde(default = "default_max_record_size")]
25    pub max_record_size: usize,
26    /// If set, the segment will be closed after this duration
27    /// even if max_file_size has not been reached.
28    #[serde(
29        default,
30        with = "duration_serde",
31        skip_serializing_if = "Option::is_none"
32    )]
33    pub max_segment_duration: Option<Duration>,
34    /// Optional suffix appended to segment file names.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub suffix: Option<String>,
37    /// Timezone used when computing the segment file name.
38    /// Defaults to UTC.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub tz: Option<Tz>,
41}
42
43fn default_max_file_size() -> u64 {
44    128 * 1024 * 1024
45}
46
47fn default_compression_level() -> i32 {
48    3
49}
50
51fn default_max_record_size() -> usize {
52    crate::decompress::DEFAULT_MAX_LINE_SIZE
53}
54
55impl LogWriterConfig {
56    pub fn new(log_dir: Utf8PathBuf) -> Self {
57        Self {
58            log_dir,
59            max_file_size: default_max_file_size(),
60            compression_level: default_compression_level(),
61            max_record_size: default_max_record_size(),
62            max_segment_duration: None,
63            suffix: None,
64            tz: None,
65        }
66    }
67
68    pub fn max_file_size(mut self, size: u64) -> Self {
69        self.max_file_size = size;
70        self
71    }
72
73    pub fn compression_level(mut self, level: i32) -> Self {
74        self.compression_level = level;
75        self
76    }
77
78    pub fn max_record_size(mut self, size: usize) -> Self {
79        self.max_record_size = size;
80        self
81    }
82
83    pub fn max_segment_duration(mut self, duration: Duration) -> Self {
84        self.max_segment_duration = Some(duration);
85        self
86    }
87
88    pub fn suffix(mut self, suffix: impl Into<String>) -> Self {
89        self.suffix = Some(suffix.into());
90        self
91    }
92
93    pub fn tz(mut self, tz: Tz) -> Self {
94        self.tz = Some(tz);
95        self
96    }
97
98    /// Build the [`LogWriter`].
99    pub fn build(self) -> LogWriter {
100        LogWriter {
101            log_dir: self.log_dir,
102            max_file_size: self.max_file_size,
103            compression_level: self.compression_level,
104            max_record_size: self.max_record_size,
105            max_segment_duration: self.max_segment_duration,
106            suffix: self.suffix,
107            tz: self.tz,
108            current: None,
109        }
110    }
111}
112
113/// State for the currently open segment file.
114struct OpenSegment {
115    encoder: Encoder<'static, std::fs::File>,
116    path: Utf8PathBuf,
117    bytes_written: u64,
118    opened_at: std::time::Instant,
119}
120
121/// A log writer that produces zstd-compressed JSONL segment files
122/// in a directory, compatible with [`LogTailer`](crate::LogTailer).
123pub struct LogWriter {
124    log_dir: Utf8PathBuf,
125    max_file_size: u64,
126    compression_level: i32,
127    max_record_size: usize,
128    max_segment_duration: Option<Duration>,
129    suffix: Option<String>,
130    tz: Option<Tz>,
131    current: Option<OpenSegment>,
132}
133
134impl LogWriter {
135    /// Write a JSONL line (record) to the current segment.
136    ///
137    /// If no segment is open, one will be created.  After writing,
138    /// if the segment has exceeded `max_file_size` or
139    /// `max_segment_duration` it will be closed and a new segment
140    /// will be opened on the next write.
141    pub fn write_line(&mut self, line: &str) -> anyhow::Result<()> {
142        // A reader buffer holds at most `max_record_size` bytes, but a complete
143        // line needs the record content plus a one-byte newline separator.
144        // Content of exactly `max_record_size` bytes doesn't leave room for
145        // that newline, so it is refused along with anything larger.
146        let max = self.max_record_size;
147        let len = line.strip_suffix('\n').unwrap_or(line).len();
148        if len >= max {
149            anyhow::bail!(
150                "record of {len} bytes plus its newline separator does not fit \
151                 within max_record_size of {max} bytes"
152            );
153        }
154
155        if self.current.is_none() {
156            self.open_segment()?;
157        }
158
159        let seg = self.current.as_mut().expect("just opened");
160
161        seg.encoder.write_all(line.as_bytes())?;
162        seg.bytes_written += line.len() as u64;
163
164        // Ensure the record ends with a newline
165        if !line.ends_with('\n') {
166            seg.encoder.write_all(b"\n")?;
167            seg.bytes_written += 1;
168        }
169
170        // Check if we need to roll to a new segment
171        if self.should_roll() {
172            self.close_segment()?;
173        }
174
175        Ok(())
176    }
177
178    /// Serialize `value` to JSON and write it as a JSONL line.
179    pub fn write_value<S: serde::Serialize>(&mut self, value: &S) -> anyhow::Result<()> {
180        let json = serde_json::to_string(value)?;
181        self.write_line(&json)
182    }
183
184    /// Flush and close the current segment if it has exceeded
185    /// `max_segment_duration`.  This is a no-op if no segment is
186    /// open or if the duration has not been exceeded.
187    pub fn maintain(&mut self) -> anyhow::Result<()> {
188        if self.current.is_some() && self.duration_exceeded() {
189            self.close_segment()?;
190        }
191        Ok(())
192    }
193
194    /// Flush and close the current segment, regardless of whether
195    /// it has exceeded any configured constraints.
196    pub fn close(&mut self) -> anyhow::Result<()> {
197        if self.current.is_some() {
198            self.close_segment()?;
199        }
200        Ok(())
201    }
202
203    /// Finish the zstd stream so the data is readable, but do NOT
204    /// mark the file as done (readonly).  This is useful for tests
205    /// that need to simulate an in-progress segment.
206    pub fn flush_without_marking_done(&mut self) -> anyhow::Result<()> {
207        if let Some(seg) = self.current.take() {
208            seg.encoder.finish()?;
209        }
210        Ok(())
211    }
212
213    fn should_roll(&self) -> bool {
214        let Some(seg) = &self.current else {
215            return false;
216        };
217        if seg.bytes_written >= self.max_file_size {
218            return true;
219        }
220        self.duration_exceeded()
221    }
222
223    fn duration_exceeded(&self) -> bool {
224        let Some(seg) = &self.current else {
225            return false;
226        };
227        if let Some(max_dur) = self.max_segment_duration {
228            if seg.opened_at.elapsed() >= max_dur {
229                return true;
230            }
231        }
232        false
233    }
234
235    fn open_segment(&mut self) -> anyhow::Result<()> {
236        let now: DateTime<Utc> = Utc::now();
237        let mut base_name = match &self.tz {
238            Some(tz) => now.with_timezone(tz).format("%Y%m%d-%H%M%S%.f").to_string(),
239            None => now.format("%Y%m%d-%H%M%S%.f").to_string(),
240        };
241        if let Some(suffix) = &self.suffix {
242            base_name.push_str(suffix);
243        }
244        let path = self.log_dir.join(base_name);
245
246        std::fs::create_dir_all(&self.log_dir)?;
247
248        let file = std::fs::File::create(path.as_std_path())?;
249        let encoder = Encoder::new(file, self.compression_level)?;
250
251        self.current = Some(OpenSegment {
252            encoder,
253            path,
254            bytes_written: 0,
255            opened_at: std::time::Instant::now(),
256        });
257
258        Ok(())
259    }
260
261    fn close_segment(&mut self) -> anyhow::Result<()> {
262        if let Some(seg) = self.current.take() {
263            // Finish the zstd stream (flushes and writes the end frame)
264            seg.encoder.finish()?;
265            // Mark the file as done (readonly) to signal to the tailer
266            // that this segment is complete
267            mark_segment_done(&seg.path)?;
268        }
269        Ok(())
270    }
271}
272
273impl Drop for LogWriter {
274    fn drop(&mut self) {
275        // Best-effort close on drop
276        let _ = self.close();
277    }
278}
279
280/// Mark a segment file as done by removing write permissions.
281fn mark_segment_done(path: &Utf8PathBuf) -> std::io::Result<()> {
282    let meta = std::fs::metadata(path.as_std_path())?;
283    let mut perms = meta.permissions();
284    perms.set_readonly(true);
285    std::fs::set_permissions(path.as_std_path(), perms)
286}
287
288#[cfg(test)]
289mod test {
290    use super::*;
291
292    fn writer(dir: &tempfile::TempDir, max_record_size: usize) -> LogWriter {
293        LogWriterConfig::new(Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap())
294            .max_record_size(max_record_size)
295            .build()
296    }
297
298    #[test]
299    fn record_within_limit_is_written() {
300        let dir = tempfile::tempdir().unwrap();
301        let mut w = writer(&dir, 32);
302        w.write_line("small").unwrap();
303        w.close().unwrap();
304    }
305
306    /// Verifies the writer and a tailer configured with the same limit agree at
307    /// the boundary: a record one byte inside the limit is written and read
308    /// back unchanged, and a record at the limit is refused by the writer
309    /// before it can produce a segment the tailer would reject as corrupt.
310    #[test]
311    fn writer_and_tailer_limits_match() {
312        use crate::decompress::{FileDecompressor, NextLine};
313
314        let max = 64 * 1024;
315        let dir = tempfile::tempdir().unwrap();
316
317        let mut w = writer(&dir, max);
318        let largest = "z".repeat(max - 1);
319        w.write_line(&largest).unwrap();
320        w.write_line(&"z".repeat(max)).unwrap_err();
321        w.close().unwrap();
322
323        let seg = std::fs::read_dir(dir.path())
324            .unwrap()
325            .next()
326            .unwrap()
327            .unwrap()
328            .path();
329        let mut d = FileDecompressor::open_with_max_line_size(&seg, max).unwrap();
330        match d.next_line(0).unwrap() {
331            NextLine::Line(line) => {
332                k9::assert_equal!(line.text, largest);
333            }
334            other => panic!("expected a line, got {other:?}"),
335        }
336        assert!(matches!(d.next_line(0).unwrap(), NextLine::None));
337    }
338
339    /// Rejects a record whose content reaches the limit (its newline would not
340    /// fit): nothing is written, and the segment is not left open.
341    #[test]
342    fn record_at_limit_is_rejected() {
343        let dir = tempfile::tempdir().unwrap();
344        let mut w = writer(&dir, 8);
345        let err = w.write_line(&"x".repeat(8)).unwrap_err();
346        k9::assert_equal!(
347            err.to_string(),
348            "record of 8 bytes plus its newline separator does not fit \
349             within max_record_size of 8 bytes"
350        );
351        assert!(w.current.is_none());
352    }
353
354    /// The trailing newline of the record is not counted against the limit: a
355    /// record whose content is below the limit is accepted even when the input
356    /// already ends in a newline.
357    #[test]
358    fn trailing_newline_excluded_from_limit() {
359        let dir = tempfile::tempdir().unwrap();
360        let mut w = writer(&dir, 8);
361        w.write_line(&format!("{}\n", "x".repeat(7))).unwrap();
362        w.close().unwrap();
363    }
364}