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#[derive(Deserialize, Serialize)]
11pub struct LogWriterConfig {
12 pub log_dir: Utf8PathBuf,
14 #[serde(default = "default_max_file_size")]
17 pub max_file_size: u64,
18 #[serde(default = "default_compression_level")]
20 pub compression_level: i32,
21 #[serde(default = "default_max_record_size")]
25 pub max_record_size: usize,
26 #[serde(
29 default,
30 with = "duration_serde",
31 skip_serializing_if = "Option::is_none"
32 )]
33 pub max_segment_duration: Option<Duration>,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub suffix: Option<String>,
37 #[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 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
113struct OpenSegment {
115 encoder: Encoder<'static, std::fs::File>,
116 path: Utf8PathBuf,
117 bytes_written: u64,
118 opened_at: std::time::Instant,
119}
120
121pub 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 pub fn write_line(&mut self, line: &str) -> anyhow::Result<()> {
142 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 if !line.ends_with('\n') {
166 seg.encoder.write_all(b"\n")?;
167 seg.bytes_written += 1;
168 }
169
170 if self.should_roll() {
172 self.close_segment()?;
173 }
174
175 Ok(())
176 }
177
178 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 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 pub fn close(&mut self) -> anyhow::Result<()> {
197 if self.current.is_some() {
198 self.close_segment()?;
199 }
200 Ok(())
201 }
202
203 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 seg.encoder.finish()?;
265 mark_segment_done(&seg.path)?;
268 }
269 Ok(())
270 }
271}
272
273impl Drop for LogWriter {
274 fn drop(&mut self) {
275 let _ = self.close();
277 }
278}
279
280fn 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 #[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 #[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 #[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}