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#[derive(Deserialize, Serialize)]
12pub struct LogWriterConfig {
13 pub log_dir: Utf8PathBuf,
15 #[serde(default = "default_max_file_size")]
18 pub max_file_size: u64,
19 #[serde(default = "default_compression_level")]
21 pub compression_level: i32,
22 #[serde(default = "default_max_record_size")]
26 pub max_record_size: usize,
27 #[serde(
30 default,
31 with = "duration_serde",
32 skip_serializing_if = "Option::is_none"
33 )]
34 pub max_segment_duration: Option<Duration>,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub suffix: Option<String>,
38 #[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 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
116struct OpenSegment {
118 encoder: Encoder<'static, std::fs::File>,
119 path: Utf8PathBuf,
120 bytes_written: u64,
121 opened_at: std::time::Instant,
122}
123
124pub 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 _registration: WriterRegistration,
138}
139
140impl LogWriter {
141 pub fn write_line(&mut self, line: &str) -> anyhow::Result<()> {
148 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 if !line.ends_with('\n') {
172 seg.encoder.write_all(b"\n")?;
173 seg.bytes_written += 1;
174 }
175
176 if self.should_roll() {
178 self.close_segment()?;
179 }
180
181 Ok(())
182 }
183
184 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 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 pub fn close(&mut self) -> anyhow::Result<()> {
203 if self.current.is_some() {
204 self.close_segment()?;
205 }
206 Ok(())
207 }
208
209 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 seg.encoder.finish()?;
271 mark_segment_done(&seg.path)?;
274 }
275 Ok(())
276 }
277}
278
279impl Drop for LogWriter {
280 fn drop(&mut self) {
281 let _ = self.close();
283 }
284}
285
286fn 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 #[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 #[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 #[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}