kumo_jsonl/
writer.rs

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