/rust/registry/src/index.crates.io-1949cf8c6b5b557f/logforth-append-file-0.3.0/src/rolling.rs
Line | Count | Source |
1 | | // Copyright 2024 FastLabs Developers |
2 | | // |
3 | | // Licensed under the Apache License, Version 2.0 (the "License"); |
4 | | // you may not use this file except in compliance with the License. |
5 | | // You may obtain a copy of the License at |
6 | | // |
7 | | // http://www.apache.org/licenses/LICENSE-2.0 |
8 | | // |
9 | | // Unless required by applicable law or agreed to in writing, software |
10 | | // distributed under the License is distributed on an "AS IS" BASIS, |
11 | | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
12 | | // See the License for the specific language governing permissions and |
13 | | // limitations under the License. |
14 | | |
15 | | use std::fs; |
16 | | use std::fs::File; |
17 | | use std::fs::Metadata; |
18 | | use std::fs::OpenOptions; |
19 | | use std::io; |
20 | | use std::io::Write; |
21 | | use std::num::NonZeroUsize; |
22 | | use std::path::Path; |
23 | | use std::path::PathBuf; |
24 | | use std::str::FromStr; |
25 | | |
26 | | use jiff::Zoned; |
27 | | use jiff::civil::DateTime; |
28 | | use logforth_core::Error; |
29 | | use logforth_core::Trap; |
30 | | use logforth_core::trap::BestEffortTrap; |
31 | | |
32 | | use crate::clock::Clock; |
33 | | use crate::rotation::Rotation; |
34 | | |
35 | | /// A writer for rolling files. |
36 | | #[derive(Debug)] |
37 | | pub struct RollingFileWriter { |
38 | | state: State, |
39 | | writer: File, |
40 | | } |
41 | | |
42 | | impl Drop for RollingFileWriter { |
43 | 0 | fn drop(&mut self) { |
44 | 0 | if let Err(err) = self.writer.flush() { |
45 | 0 | let err = Error::new("failed to flush file writer on dropped").set_source(err); |
46 | 0 | self.state.trap.trap(&err); |
47 | 0 | } |
48 | 0 | } |
49 | | } |
50 | | |
51 | | impl Write for RollingFileWriter { |
52 | 0 | fn write(&mut self, buf: &[u8]) -> io::Result<usize> { |
53 | 0 | let now = self.state.clock.now(); |
54 | 0 | let writer = &mut self.writer; |
55 | | |
56 | 0 | if self.state.should_rollover_on_date(&now) { |
57 | 0 | self.state.current_filesize = 0; |
58 | 0 | self.state.next_date_timestamp = self.state.rotation.next_date_timestamp(&now); |
59 | 0 | let current = &self.state.this_date_timestamp; |
60 | 0 | self.state.refresh_writer(current, writer); |
61 | 0 | } |
62 | | |
63 | 0 | if self.state.should_rollover_on_size() { |
64 | 0 | self.state.current_filesize = 0; |
65 | 0 | self.state.refresh_writer(&now, writer); |
66 | 0 | } |
67 | | |
68 | 0 | self.state.this_date_timestamp = now; |
69 | | |
70 | 0 | writer |
71 | 0 | .write(buf) |
72 | 0 | .inspect(|&n| self.state.current_filesize += n) |
73 | 0 | } |
74 | | |
75 | 0 | fn flush(&mut self) -> io::Result<()> { |
76 | 0 | self.writer.flush() |
77 | 0 | } |
78 | | } |
79 | | |
80 | | /// A builder for configuring [`RollingFileWriter`]. |
81 | | #[derive(Debug)] |
82 | | pub struct RollingFileWriterBuilder { |
83 | | // required |
84 | | basedir: PathBuf, |
85 | | filename: String, |
86 | | |
87 | | // has default |
88 | | rotation: Rotation, |
89 | | filename_suffix: Option<String>, |
90 | | max_size: Option<NonZeroUsize>, |
91 | | max_files: Option<NonZeroUsize>, |
92 | | clock: Clock, |
93 | | trap: Box<dyn Trap>, |
94 | | } |
95 | | |
96 | | impl RollingFileWriterBuilder { |
97 | | /// Creates a new [`RollingFileWriterBuilder`]. |
98 | | #[must_use] |
99 | 0 | pub fn new(basedir: impl Into<PathBuf>, filename: impl Into<String>) -> Self { |
100 | 0 | Self { |
101 | 0 | basedir: basedir.into(), |
102 | 0 | filename: filename.into(), |
103 | 0 | rotation: Rotation::Never, |
104 | 0 | filename_suffix: None, |
105 | 0 | max_size: None, |
106 | 0 | max_files: None, |
107 | 0 | clock: Clock::DefaultClock, |
108 | 0 | trap: Box::new(BestEffortTrap::default()), |
109 | 0 | } |
110 | 0 | } |
111 | | |
112 | | /// Set the trap for the rolling file writer. |
113 | 0 | pub fn trap(mut self, trap: impl Into<Box<dyn Trap>>) -> Self { |
114 | 0 | self.trap = trap.into(); |
115 | 0 | self |
116 | 0 | } |
117 | | |
118 | | /// Set the rotation policy. |
119 | | #[must_use] |
120 | 0 | pub fn rotation(mut self, rotation: Rotation) -> Self { |
121 | 0 | self.rotation = rotation; |
122 | 0 | self |
123 | 0 | } |
124 | | |
125 | | /// Set the filename suffix. |
126 | | #[must_use] |
127 | 0 | pub fn filename_suffix(mut self, suffix: impl Into<String>) -> Self { |
128 | 0 | let suffix = suffix.into(); |
129 | 0 | self.filename_suffix = if suffix.is_empty() { |
130 | 0 | None |
131 | | } else { |
132 | 0 | Some(suffix) |
133 | | }; |
134 | 0 | self |
135 | 0 | } |
136 | | |
137 | | /// Set the maximum number of log files to keep. |
138 | | #[must_use] |
139 | 0 | pub fn max_log_files(mut self, n: NonZeroUsize) -> Self { |
140 | 0 | self.max_files = Some(n); |
141 | 0 | self |
142 | 0 | } |
143 | | |
144 | | /// Set the maximum size of a log file in bytes. |
145 | | #[must_use] |
146 | 0 | pub fn max_file_size(mut self, n: NonZeroUsize) -> Self { |
147 | 0 | self.max_size = Some(n); |
148 | 0 | self |
149 | 0 | } |
150 | | |
151 | | #[cfg(test)] |
152 | | fn clock(mut self, clock: Clock) -> Self { |
153 | | self.clock = clock; |
154 | | self |
155 | | } |
156 | | |
157 | | /// Builds the [`RollingFileWriter`]. |
158 | 0 | pub fn build(self) -> Result<RollingFileWriter, Error> { |
159 | | let Self { |
160 | 0 | basedir, |
161 | 0 | rotation, |
162 | 0 | filename, |
163 | 0 | filename_suffix, |
164 | 0 | max_size, |
165 | 0 | max_files, |
166 | 0 | clock, |
167 | 0 | trap, |
168 | 0 | } = self; |
169 | | |
170 | 0 | if filename.is_empty() { |
171 | 0 | return Err(Error::new("filename must not be empty")); |
172 | 0 | } |
173 | | |
174 | 0 | let (state, writer) = State::new( |
175 | 0 | rotation, |
176 | 0 | basedir, |
177 | 0 | filename, |
178 | 0 | filename_suffix, |
179 | 0 | max_size, |
180 | 0 | max_files, |
181 | 0 | clock, |
182 | 0 | trap, |
183 | 0 | )?; |
184 | | |
185 | 0 | Ok(RollingFileWriter { state, writer }) |
186 | 0 | } |
187 | | } |
188 | | |
189 | | #[derive(Debug)] |
190 | | struct LogFile { |
191 | | filepath: PathBuf, |
192 | | metadata: Metadata, |
193 | | datetime: DateTime, |
194 | | count: usize, |
195 | | } |
196 | | |
197 | | // oldest is the least |
198 | 0 | fn compare_logfile(a: &LogFile, b: &LogFile) -> std::cmp::Ordering { |
199 | 0 | match a.datetime.cmp(&b.datetime) { |
200 | | std::cmp::Ordering::Equal => { |
201 | 0 | let a_rev = usize::MAX - a.count; |
202 | 0 | let b_rev = usize::MAX - b.count; |
203 | 0 | a_rev.cmp(&b_rev) |
204 | | } |
205 | 0 | ord => ord, |
206 | | } |
207 | 0 | } |
208 | | |
209 | | #[derive(Debug)] |
210 | | struct State { |
211 | | log_dir: PathBuf, |
212 | | log_filename: String, |
213 | | log_filename_suffix: Option<String>, |
214 | | date_format: &'static str, |
215 | | rotation: Rotation, |
216 | | current_filesize: usize, |
217 | | this_date_timestamp: Zoned, |
218 | | next_date_timestamp: Option<usize>, |
219 | | max_size: Option<NonZeroUsize>, |
220 | | max_files: Option<NonZeroUsize>, |
221 | | clock: Clock, |
222 | | trap: Box<dyn Trap>, |
223 | | } |
224 | | |
225 | | impl State { |
226 | | #[allow(clippy::too_many_arguments)] |
227 | 0 | fn new( |
228 | 0 | rotation: Rotation, |
229 | 0 | dir: impl AsRef<Path>, |
230 | 0 | log_filename: String, |
231 | 0 | log_filename_suffix: Option<String>, |
232 | 0 | max_size: Option<NonZeroUsize>, |
233 | 0 | max_files: Option<NonZeroUsize>, |
234 | 0 | clock: Clock, |
235 | 0 | trap: Box<dyn Trap>, |
236 | 0 | ) -> Result<(Self, File), Error> { |
237 | 0 | let now = clock.now(); |
238 | 0 | let log_dir = dir.as_ref().to_path_buf(); |
239 | 0 | fs::create_dir_all(&log_dir) |
240 | 0 | .map_err(|err| Error::new("failed to create log directory").set_source(err))?; |
241 | | |
242 | 0 | let mut state = State { |
243 | 0 | log_dir, |
244 | 0 | log_filename, |
245 | 0 | log_filename_suffix, |
246 | 0 | date_format: rotation.date_format(), |
247 | 0 | current_filesize: 0, |
248 | 0 | this_date_timestamp: clock.now(), |
249 | 0 | next_date_timestamp: rotation.next_date_timestamp(&now), |
250 | 0 | rotation, |
251 | 0 | max_size, |
252 | 0 | max_files, |
253 | 0 | clock, |
254 | 0 | trap, |
255 | 0 | }; |
256 | | |
257 | 0 | let files = { |
258 | 0 | let mut files = state.list_logfiles()?; |
259 | 0 | files.sort_by(compare_logfile); |
260 | 0 | files |
261 | | }; |
262 | | |
263 | 0 | let file = match files.last() { |
264 | | None => { |
265 | | // brand-new directory |
266 | 0 | state.create_log_writer()? |
267 | | } |
268 | 0 | Some(last) => { |
269 | 0 | let filename = state.current_filename(); |
270 | 0 | if last.filepath != filename { |
271 | | // for some reason, the `filename.suffix` file does not exist; create a new one |
272 | 0 | state.create_log_writer()? |
273 | | } else { |
274 | 0 | state.current_filesize = last.metadata.len() as usize; |
275 | | |
276 | 0 | if let Ok(mtime) = last.metadata.modified() { |
277 | 0 | if let Ok(mtime) = Zoned::try_from(mtime) { |
278 | 0 | state.next_date_timestamp = state.rotation.next_date_timestamp(&mtime); |
279 | 0 | state.this_date_timestamp = mtime; |
280 | 0 | } |
281 | 0 | } |
282 | | |
283 | | // continue to use the existing current log file |
284 | 0 | OpenOptions::new() |
285 | 0 | .append(true) |
286 | 0 | .open(&filename) |
287 | 0 | .map_err(|err| Error::new("failed to open current log").set_source(err))? |
288 | | } |
289 | | } |
290 | | }; |
291 | | |
292 | 0 | Ok((state, file)) |
293 | 0 | } |
294 | | |
295 | 0 | fn current_filename(&self) -> PathBuf { |
296 | 0 | let filename = &self.log_filename; |
297 | 0 | match self.log_filename_suffix.as_ref() { |
298 | 0 | None => self.log_dir.join(filename), |
299 | 0 | Some(suffix) => self.log_dir.join(format!("{filename}.{suffix}")), |
300 | | } |
301 | 0 | } |
302 | | |
303 | 0 | fn create_log_writer(&self) -> Result<File, Error> { |
304 | 0 | let filename = self.current_filename(); |
305 | 0 | OpenOptions::new() |
306 | 0 | .write(true) |
307 | 0 | .create_new(true) |
308 | 0 | .open(&filename) |
309 | 0 | .map_err(|err| Error::new("failed to create log file").set_source(err)) |
310 | 0 | } |
311 | | |
312 | 0 | fn join_date(&self, date: &Zoned, cnt: usize) -> PathBuf { |
313 | 0 | let date = date.strftime(self.date_format); |
314 | 0 | let filename = match ( |
315 | 0 | &self.rotation, |
316 | 0 | &self.log_filename, |
317 | 0 | &self.log_filename_suffix, |
318 | | ) { |
319 | 0 | (&Rotation::Never, filename, None) => format!("{filename}.{cnt}"), |
320 | 0 | (&Rotation::Never, filename, Some(suffix)) => { |
321 | 0 | format!("{filename}.{cnt}.{suffix}") |
322 | | } |
323 | 0 | (_, filename, Some(suffix)) => format!("{filename}.{date}.{cnt}.{suffix}"), |
324 | 0 | (_, filename, None) => format!("{filename}.{date}.{cnt}"), |
325 | | }; |
326 | 0 | self.log_dir.join(filename) |
327 | 0 | } |
328 | | |
329 | 0 | fn list_logfiles(&self) -> Result<Vec<LogFile>, Error> { |
330 | 0 | let read_dir = fs::read_dir(&self.log_dir).map_err(|err| { |
331 | 0 | Error::new(format!( |
332 | 0 | "failed to read log dir: {}", |
333 | 0 | self.log_dir.display() |
334 | | )) |
335 | 0 | .set_source(err) |
336 | 0 | })?; |
337 | | |
338 | 0 | let files = read_dir |
339 | 0 | .filter_map(|entry| { |
340 | 0 | let entry = entry.ok()?; |
341 | 0 | let filepath = entry.path(); |
342 | | |
343 | 0 | let metadata = entry.metadata().ok()?; |
344 | | // the appender only creates files, not directories or symlinks, |
345 | 0 | if !metadata.is_file() { |
346 | 0 | return None; |
347 | 0 | } |
348 | | |
349 | 0 | let filename = entry.file_name(); |
350 | | // if the filename is not a UTF-8 string, skip it. |
351 | 0 | let mut filename = filename.to_str()?; |
352 | 0 | if !filename.starts_with(&self.log_filename) { |
353 | 0 | return None; |
354 | 0 | } |
355 | 0 | filename = &filename[self.log_filename.len()..]; |
356 | | |
357 | 0 | if let Some(suffix) = &self.log_filename_suffix { |
358 | 0 | if !filename.ends_with(suffix) { |
359 | 0 | return None; |
360 | 0 | } |
361 | 0 | filename = &filename[..filename.len() - suffix.len() - 1]; |
362 | 0 | } |
363 | | |
364 | 0 | if filename.is_empty() { |
365 | | // the current log file is the largest |
366 | 0 | return Some(LogFile { |
367 | 0 | filepath, |
368 | 0 | metadata, |
369 | 0 | datetime: DateTime::MAX, |
370 | 0 | count: 0, |
371 | 0 | }); |
372 | 0 | } |
373 | | |
374 | 0 | if filename.starts_with(".") { |
375 | 0 | filename = &filename[1..]; |
376 | 0 | } else { |
377 | 0 | return None; |
378 | | } |
379 | | |
380 | 0 | let datetime = if self.rotation != Rotation::Never { |
381 | | // mandatory datetime part |
382 | 0 | let pos = filename.find('.')?; |
383 | 0 | let datetime = DateTime::strptime(self.date_format, &filename[..pos]).ok()?; |
384 | 0 | filename = &filename[pos + 1..]; |
385 | 0 | datetime |
386 | | } else { |
387 | 0 | DateTime::MAX |
388 | | }; |
389 | | |
390 | 0 | let count = usize::from_str(&filename[..filename.len()]).ok()?; |
391 | | |
392 | 0 | Some(LogFile { |
393 | 0 | filepath, |
394 | 0 | metadata, |
395 | 0 | datetime, |
396 | 0 | count, |
397 | 0 | }) |
398 | 0 | }) |
399 | 0 | .collect::<Vec<_>>(); |
400 | | |
401 | 0 | Ok(files) |
402 | 0 | } |
403 | | |
404 | 0 | fn delete_oldest_logs(&self, max_files: usize) -> Result<(), Error> { |
405 | 0 | let mut files = self.list_logfiles()?; |
406 | 0 | if files.len() < max_files { |
407 | 0 | return Ok(()); |
408 | 0 | } |
409 | | |
410 | | // delete files, so that (n-1) files remain, because we will create another log file |
411 | 0 | files.sort_by(compare_logfile); |
412 | 0 | for file in files.iter().take(files.len() - (max_files - 1)) { |
413 | 0 | let filepath = &file.filepath; |
414 | 0 | fs::remove_file(filepath).map_err(|err| { |
415 | 0 | Error::new(format!("failed to remove old log: {}", filepath.display())) |
416 | 0 | .set_source(err) |
417 | 0 | })?; |
418 | | } |
419 | | |
420 | 0 | Ok(()) |
421 | 0 | } |
422 | | |
423 | 0 | fn rotate_log_writer(&self, now: &Zoned) -> Result<File, Error> { |
424 | 0 | let mut renames = vec![]; |
425 | 0 | for i in 1..self.max_files.map_or(usize::MAX, |n| n.get()) { |
426 | 0 | let filepath = self.join_date(now, i); |
427 | 0 | if fs::exists(&filepath).is_ok_and(|ok| ok) { |
428 | 0 | let next = self.join_date(now, i + 1); |
429 | 0 | renames.push((filepath, next)); |
430 | 0 | } else { |
431 | 0 | break; |
432 | | } |
433 | | } |
434 | | |
435 | 0 | for (old, new) in renames.iter().rev() { |
436 | 0 | fs::rename(old, new).map_err(|err| { |
437 | 0 | Error::new(format!("failed to rotate log: {}", old.display())).set_source(err) |
438 | 0 | })? |
439 | | } |
440 | | |
441 | 0 | let archive_filepath = self.join_date(now, 1); |
442 | 0 | let current_filepath = self.current_filename(); |
443 | 0 | fs::rename(¤t_filepath, &archive_filepath).map_err(|err| { |
444 | 0 | Error::new(format!( |
445 | 0 | "failed to archive log: {}", |
446 | 0 | current_filepath.display() |
447 | | )) |
448 | 0 | .set_source(err) |
449 | 0 | })?; |
450 | | |
451 | 0 | if let Some(max_files) = self.max_files { |
452 | 0 | if let Err(err) = self.delete_oldest_logs(max_files.get()) { |
453 | 0 | let err = Error::new("failed to delete oldest logs").set_source(err); |
454 | 0 | self.trap.trap(&err); |
455 | 0 | } |
456 | 0 | } |
457 | | |
458 | 0 | self.create_log_writer() |
459 | 0 | } |
460 | | |
461 | 0 | fn refresh_writer(&self, now: &Zoned, file: &mut File) { |
462 | 0 | match self.rotate_log_writer(now) { |
463 | 0 | Ok(new_file) => { |
464 | 0 | if let Err(err) = file.flush() { |
465 | 0 | let err = Error::new("failed to flush previous writer").set_source(err); |
466 | 0 | self.trap.trap(&err); |
467 | 0 | } |
468 | 0 | *file = new_file; |
469 | | } |
470 | 0 | Err(err) => { |
471 | 0 | let err = Error::new("failed to rotate log writer").set_source(err); |
472 | 0 | self.trap.trap(&err); |
473 | 0 | } |
474 | | } |
475 | 0 | } |
476 | | |
477 | 0 | fn should_rollover_on_date(&self, date: &Zoned) -> bool { |
478 | 0 | self.next_date_timestamp |
479 | 0 | .is_some_and(|ts| date.timestamp().as_millisecond() as usize >= ts) |
480 | 0 | } |
481 | | |
482 | 0 | fn should_rollover_on_size(&self) -> bool { |
483 | 0 | self.max_size |
484 | 0 | .is_some_and(|n| self.current_filesize >= n.get()) |
485 | 0 | } |
486 | | } |
487 | | |
488 | | #[cfg(test)] |
489 | | mod tests { |
490 | | use std::cmp::min; |
491 | | use std::fs; |
492 | | use std::io::Write; |
493 | | use std::num::NonZeroUsize; |
494 | | use std::ops::Add; |
495 | | use std::str::FromStr; |
496 | | |
497 | | use jiff::Span; |
498 | | use jiff::Zoned; |
499 | | use rand::Rng; |
500 | | use rand::distr::Alphanumeric; |
501 | | use tempfile::TempDir; |
502 | | |
503 | | use crate::clock::Clock; |
504 | | use crate::clock::ManualClock; |
505 | | use crate::rolling::RollingFileWriterBuilder; |
506 | | use crate::rotation::Rotation; |
507 | | |
508 | | #[test] |
509 | | fn test_file_rolling_via_file_size() { |
510 | | test_file_rolling_for_specific_file_size(3, 1000); |
511 | | test_file_rolling_for_specific_file_size(3, 10000); |
512 | | test_file_rolling_for_specific_file_size(10, 8888); |
513 | | test_file_rolling_for_specific_file_size(10, 10000); |
514 | | test_file_rolling_for_specific_file_size(20, 6666); |
515 | | test_file_rolling_for_specific_file_size(20, 10000); |
516 | | } |
517 | | |
518 | | fn test_file_rolling_for_specific_file_size(max_files: usize, max_size: usize) { |
519 | | let max_files = NonZeroUsize::new(max_files).unwrap(); |
520 | | let max_size = NonZeroUsize::new(max_size).unwrap(); |
521 | | let temp_dir = TempDir::new().unwrap(); |
522 | | |
523 | | let mut writer = RollingFileWriterBuilder::new(temp_dir.as_ref(), "test_file") |
524 | | .rotation(Rotation::Never) |
525 | | .filename_suffix("log") |
526 | | .max_log_files(max_files) |
527 | | .max_file_size(max_size) |
528 | | .build() |
529 | | .unwrap(); |
530 | | |
531 | | for i in 1..=(max_files.get() * 2) { |
532 | | let mut expected_file_size = 0; |
533 | | while expected_file_size < max_size.get() { |
534 | | let rand_str = generate_random_string(); |
535 | | expected_file_size += rand_str.len(); |
536 | | assert_eq!(writer.write(rand_str.as_bytes()).unwrap(), rand_str.len()); |
537 | | assert_eq!(writer.state.current_filesize, expected_file_size); |
538 | | } |
539 | | |
540 | | writer.flush().unwrap(); |
541 | | assert_eq!( |
542 | | fs::read_dir(&writer.state.log_dir).unwrap().count(), |
543 | | min(i, max_files.get()) |
544 | | ); |
545 | | } |
546 | | } |
547 | | |
548 | | #[test] |
549 | | fn test_file_rolling_via_time_rotation() { |
550 | | test_file_rolling_for_specific_time_rotation( |
551 | | Rotation::Minutely, |
552 | | Span::new().minutes(1), |
553 | | Span::new().seconds(1), |
554 | | ); |
555 | | test_file_rolling_for_specific_time_rotation( |
556 | | Rotation::Hourly, |
557 | | Span::new().hours(1), |
558 | | Span::new().minutes(1), |
559 | | ); |
560 | | test_file_rolling_for_specific_time_rotation( |
561 | | Rotation::Daily, |
562 | | Span::new().days(1), |
563 | | Span::new().hours(1), |
564 | | ); |
565 | | } |
566 | | |
567 | | fn test_file_rolling_for_specific_time_rotation( |
568 | | rotation: Rotation, |
569 | | rotation_duration: Span, |
570 | | write_interval: Span, |
571 | | ) { |
572 | | let max_files = NonZeroUsize::new(10).unwrap(); |
573 | | let temp_dir = TempDir::new().unwrap(); |
574 | | |
575 | | let start_time = Zoned::from_str("2024-08-10T00:00:00[UTC]").unwrap(); |
576 | | let mut writer = RollingFileWriterBuilder::new(temp_dir.as_ref(), "test_file") |
577 | | .rotation(rotation) |
578 | | .filename_suffix("log") |
579 | | .max_log_files(max_files) |
580 | | .clock(Clock::ManualClock(ManualClock::new(start_time.clone()))) |
581 | | .build() |
582 | | .unwrap(); |
583 | | |
584 | | let mut cur_time = start_time; |
585 | | |
586 | | for i in 1..=(max_files.get() * 2) { |
587 | | let mut expected_file_size = 0; |
588 | | let end_time = cur_time.add(rotation_duration); |
589 | | while cur_time < end_time { |
590 | | writer.state.clock.set_now(cur_time.clone()); |
591 | | |
592 | | let rand_str = generate_random_string(); |
593 | | expected_file_size += rand_str.len(); |
594 | | |
595 | | assert_eq!(writer.write(rand_str.as_bytes()).unwrap(), rand_str.len()); |
596 | | assert_eq!(writer.state.current_filesize, expected_file_size); |
597 | | |
598 | | cur_time = cur_time.add(write_interval); |
599 | | } |
600 | | |
601 | | writer.flush().unwrap(); |
602 | | assert_eq!( |
603 | | fs::read_dir(&writer.state.log_dir).unwrap().count(), |
604 | | min(i, max_files.get()) |
605 | | ); |
606 | | } |
607 | | } |
608 | | |
609 | | #[test] |
610 | | fn test_file_rolling_via_file_size_and_time_rotation() { |
611 | | test_file_size_and_time_rotation_for_specific_time_rotation( |
612 | | Rotation::Minutely, |
613 | | Span::new().minutes(1), |
614 | | Span::new().seconds(1), |
615 | | ); |
616 | | test_file_size_and_time_rotation_for_specific_time_rotation( |
617 | | Rotation::Hourly, |
618 | | Span::new().hours(1), |
619 | | Span::new().minutes(1), |
620 | | ); |
621 | | test_file_size_and_time_rotation_for_specific_time_rotation( |
622 | | Rotation::Daily, |
623 | | Span::new().days(1), |
624 | | Span::new().hours(1), |
625 | | ); |
626 | | } |
627 | | |
628 | | fn test_file_size_and_time_rotation_for_specific_time_rotation( |
629 | | rotation: Rotation, |
630 | | rotation_duration: Span, |
631 | | write_interval: Span, |
632 | | ) { |
633 | | let max_files = NonZeroUsize::new(10).unwrap(); |
634 | | let file_size = NonZeroUsize::new(500).unwrap(); |
635 | | // Small file size and too many files to ensure both of file size and time rotation can be |
636 | | // triggered. |
637 | | let total_files = 100; |
638 | | let temp_dir = TempDir::new().unwrap(); |
639 | | |
640 | | let start_time = Zoned::from_str("2024-08-10T00:00:00[UTC]").unwrap(); |
641 | | let mut writer = RollingFileWriterBuilder::new(temp_dir.as_ref(), "test_file") |
642 | | .rotation(rotation) |
643 | | .filename_suffix("log") |
644 | | .max_log_files(max_files) |
645 | | .max_file_size(file_size) |
646 | | .clock(Clock::ManualClock(ManualClock::new(start_time.clone()))) |
647 | | .build() |
648 | | .unwrap(); |
649 | | |
650 | | let mut cur_time = start_time; |
651 | | let mut end_time = cur_time.add(rotation_duration); |
652 | | let mut time_rotation_trigger = false; |
653 | | let mut file_size_rotation_trigger = false; |
654 | | |
655 | | for i in 1..=total_files { |
656 | | let mut expected_file_size = 0; |
657 | | loop { |
658 | | writer.state.clock.set_now(cur_time.clone()); |
659 | | |
660 | | let rand_str = generate_random_string(); |
661 | | expected_file_size += rand_str.len(); |
662 | | |
663 | | assert_eq!(writer.write(rand_str.as_bytes()).unwrap(), rand_str.len()); |
664 | | assert_eq!(writer.state.current_filesize, expected_file_size); |
665 | | |
666 | | cur_time = cur_time.add(write_interval); |
667 | | |
668 | | if cur_time >= end_time { |
669 | | end_time = end_time.add(rotation_duration); |
670 | | time_rotation_trigger = true; |
671 | | break; |
672 | | } |
673 | | if expected_file_size >= file_size.get() { |
674 | | file_size_rotation_trigger = true; |
675 | | break; |
676 | | } |
677 | | } |
678 | | |
679 | | writer.flush().unwrap(); |
680 | | assert_eq!( |
681 | | fs::read_dir(&writer.state.log_dir).unwrap().count(), |
682 | | min(i, max_files.get()) |
683 | | ); |
684 | | } |
685 | | assert!(file_size_rotation_trigger); |
686 | | assert!(time_rotation_trigger); |
687 | | } |
688 | | |
689 | | fn generate_random_string() -> String { |
690 | | let mut rng = rand::rng(); |
691 | | let len = rng.random_range(50..=100); |
692 | | let random_string: String = std::iter::repeat(()) |
693 | | .map(|()| rng.sample(Alphanumeric)) |
694 | | .map(char::from) |
695 | | .take(len) |
696 | | .collect(); |
697 | | |
698 | | random_string |
699 | | } |
700 | | } |