nklave_storage/
rotation.rs1use std::fs::{self, File};
7use std::io::{BufRead, BufReader};
8use std::path::{Path, PathBuf};
9use thiserror::Error;
10use tracing::{debug, info, warn};
11
12#[derive(Debug, Clone)]
14pub struct RotationConfig {
15 pub max_size_bytes: u64,
17
18 pub max_files: u32,
20
21 pub compress: bool,
23}
24
25impl Default for RotationConfig {
26 fn default() -> Self {
27 Self {
28 max_size_bytes: 100 * 1024 * 1024, max_files: 10,
30 compress: false, }
32 }
33}
34
35impl RotationConfig {
36 pub fn small() -> Self {
38 Self {
39 max_size_bytes: 1024 * 1024, max_files: 5,
41 compress: false,
42 }
43 }
44
45 pub fn production() -> Self {
47 Self {
48 max_size_bytes: 100 * 1024 * 1024, max_files: 10,
50 compress: false,
51 }
52 }
53}
54
55pub struct LogRotator {
57 base_path: PathBuf,
59
60 config: RotationConfig,
62}
63
64impl LogRotator {
65 pub fn new(base_path: impl AsRef<Path>, config: RotationConfig) -> Self {
67 Self {
68 base_path: base_path.as_ref().to_path_buf(),
69 config,
70 }
71 }
72
73 pub fn needs_rotation(&self) -> bool {
75 if let Ok(metadata) = fs::metadata(&self.base_path) {
76 metadata.len() >= self.config.max_size_bytes
77 } else {
78 false
79 }
80 }
81
82 pub fn current_size(&self) -> u64 {
84 fs::metadata(&self.base_path).map(|m| m.len()).unwrap_or(0)
85 }
86
87 pub fn rotate(&self) -> Result<PathBuf, RotationError> {
96 if !self.base_path.exists() {
97 return Err(RotationError::FileNotFound(
98 self.base_path.display().to_string(),
99 ));
100 }
101
102 let timestamp = std::time::SystemTime::now()
104 .duration_since(std::time::UNIX_EPOCH)
105 .map(|d| d.as_secs())
106 .unwrap_or(0);
107
108 let rotated_name = format!(
110 "{}.{}",
111 self.base_path.file_name().unwrap().to_string_lossy(),
112 timestamp
113 );
114
115 let rotated_path = self
116 .base_path
117 .parent()
118 .unwrap_or(Path::new("."))
119 .join(&rotated_name);
120
121 fs::rename(&self.base_path, &rotated_path)
123 .map_err(|e| RotationError::Io(format!("Failed to rename log: {}", e)))?;
124
125 info!(
126 from = %self.base_path.display(),
127 to = %rotated_path.display(),
128 "Rotated log file"
129 );
130
131 self.cleanup_old_files()?;
133
134 Ok(rotated_path)
135 }
136
137 pub fn list_rotated_files(&self) -> Result<Vec<PathBuf>, RotationError> {
139 let dir = self
140 .base_path
141 .parent()
142 .unwrap_or(Path::new("."));
143
144 let base_name = self
145 .base_path
146 .file_name()
147 .unwrap()
148 .to_string_lossy()
149 .to_string();
150
151 let mut files: Vec<PathBuf> = fs::read_dir(dir)
152 .map_err(|e| RotationError::Io(format!("Failed to read directory: {}", e)))?
153 .filter_map(|entry| entry.ok())
154 .map(|entry| entry.path())
155 .filter(|path| {
156 if let Some(name) = path.file_name() {
157 let name_str = name.to_string_lossy();
158 name_str.starts_with(&format!("{}.", base_name))
160 && name_str != base_name
161 } else {
162 false
163 }
164 })
165 .collect();
166
167 files.sort_by(|a, b| {
169 let a_time = fs::metadata(a)
170 .and_then(|m| m.modified())
171 .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
172 let b_time = fs::metadata(b)
173 .and_then(|m| m.modified())
174 .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
175 b_time.cmp(&a_time)
176 });
177
178 Ok(files)
179 }
180
181 fn cleanup_old_files(&self) -> Result<(), RotationError> {
183 let files = self.list_rotated_files()?;
184
185 if files.len() as u32 > self.config.max_files {
186 let to_delete = &files[self.config.max_files as usize..];
187
188 for file in to_delete {
189 debug!(path = %file.display(), "Deleting old rotated log");
190 fs::remove_file(file).map_err(|e| {
191 RotationError::Io(format!("Failed to delete {}: {}", file.display(), e))
192 })?;
193 }
194
195 info!(
196 deleted = to_delete.len(),
197 "Cleaned up old rotated log files"
198 );
199 }
200
201 Ok(())
202 }
203
204 pub fn total_log_size(&self) -> Result<u64, RotationError> {
206 let mut total = self.current_size();
207
208 for file in self.list_rotated_files()? {
209 if let Ok(metadata) = fs::metadata(&file) {
210 total += metadata.len();
211 }
212 }
213
214 Ok(total)
215 }
216
217 pub fn read_rotated_file<T, F>(&self, path: &Path, parse_fn: F) -> Result<Vec<T>, RotationError>
221 where
222 F: Fn(&str) -> Result<T, String>,
223 {
224 let file =
225 File::open(path).map_err(|e| RotationError::Io(format!("Failed to open: {}", e)))?;
226
227 let reader = BufReader::new(file);
228 let mut records = Vec::new();
229
230 for (line_num, line) in reader.lines().enumerate() {
231 let line = line.map_err(|e| RotationError::Io(e.to_string()))?;
232 if line.is_empty() {
233 continue;
234 }
235
236 match parse_fn(&line) {
237 Ok(record) => records.push(record),
238 Err(e) => {
239 warn!(
240 path = %path.display(),
241 line = line_num + 1,
242 error = %e,
243 "Failed to parse record in rotated log"
244 );
245 }
246 }
247 }
248
249 Ok(records)
250 }
251}
252
253#[derive(Debug, Error)]
255pub enum RotationError {
256 #[error("I/O error: {0}")]
257 Io(String),
258
259 #[error("File not found: {0}")]
260 FileNotFound(String),
261
262 #[error("Parse error: {0}")]
263 Parse(String),
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269 use std::fs::OpenOptions;
270 use std::io::Write;
271 use tempfile::TempDir;
272
273 #[test]
274 fn test_rotation_config_default() {
275 let config = RotationConfig::default();
276 assert_eq!(config.max_size_bytes, 100 * 1024 * 1024);
277 assert_eq!(config.max_files, 10);
278 assert!(!config.compress);
279 }
280
281 #[test]
282 fn test_needs_rotation() {
283 let dir = TempDir::new().unwrap();
284 let log_path = dir.path().join("test.log");
285
286 {
288 let mut file = File::create(&log_path).unwrap();
289 file.write_all(b"test data").unwrap();
290 }
291
292 let config = RotationConfig {
293 max_size_bytes: 100, max_files: 5,
295 compress: false,
296 };
297
298 let rotator = LogRotator::new(&log_path, config);
299 assert!(!rotator.needs_rotation()); {
303 let mut file = OpenOptions::new().append(true).open(&log_path).unwrap();
304 file.write_all(&[0u8; 200]).unwrap();
305 }
306
307 assert!(rotator.needs_rotation()); }
309
310 #[test]
311 fn test_rotate_file() {
312 let dir = TempDir::new().unwrap();
313 let log_path = dir.path().join("decisions.log");
314
315 {
317 let mut file = File::create(&log_path).unwrap();
318 file.write_all(b"original content").unwrap();
319 }
320
321 let config = RotationConfig::small();
322 let rotator = LogRotator::new(&log_path, config);
323
324 let rotated_path = rotator.rotate().unwrap();
325
326 assert!(!log_path.exists());
328
329 assert!(rotated_path.exists());
331
332 let content = fs::read_to_string(&rotated_path).unwrap();
334 assert_eq!(content, "original content");
335 }
336
337 #[test]
338 fn test_list_rotated_files() {
339 let dir = TempDir::new().unwrap();
340 let log_path = dir.path().join("test.log");
341
342 for i in 1..=3 {
344 let rotated = dir.path().join(format!("test.log.{}", 1000 + i));
345 File::create(&rotated).unwrap();
346 std::thread::sleep(std::time::Duration::from_millis(10));
348 }
349
350 File::create(&log_path).unwrap();
352
353 let config = RotationConfig::small();
354 let rotator = LogRotator::new(&log_path, config);
355
356 let files = rotator.list_rotated_files().unwrap();
357 assert_eq!(files.len(), 3);
358 }
359
360 #[test]
361 fn test_cleanup_old_files() {
362 let dir = TempDir::new().unwrap();
363 let log_path = dir.path().join("test.log");
364
365 for i in 1..=10 {
367 let rotated = dir.path().join(format!("test.log.{}", 1000 + i));
368 File::create(&rotated).unwrap();
369 std::thread::sleep(std::time::Duration::from_millis(10));
370 }
371
372 File::create(&log_path).unwrap();
374
375 let config = RotationConfig {
376 max_size_bytes: 1024,
377 max_files: 3, compress: false,
379 };
380
381 let rotator = LogRotator::new(&log_path, config);
382
383 rotator.cleanup_old_files().unwrap();
385
386 let files = rotator.list_rotated_files().unwrap();
388 assert_eq!(files.len(), 3);
389 }
390
391 #[test]
392 fn test_total_log_size() {
393 let dir = TempDir::new().unwrap();
394 let log_path = dir.path().join("test.log");
395
396 {
398 let mut file = File::create(&log_path).unwrap();
399 file.write_all(&[0u8; 100]).unwrap();
400 }
401
402 for i in 1..=2 {
404 let rotated = dir.path().join(format!("test.log.{}", 1000 + i));
405 let mut file = File::create(&rotated).unwrap();
406 file.write_all(&[0u8; 50]).unwrap();
407 }
408
409 let config = RotationConfig::small();
410 let rotator = LogRotator::new(&log_path, config);
411
412 let total = rotator.total_log_size().unwrap();
413 assert_eq!(total, 100 + 50 + 50); }
415
416 #[test]
417 fn test_read_rotated_file() {
418 let dir = TempDir::new().unwrap();
419 let log_path = dir.path().join("test.log");
420 let rotated_path = dir.path().join("test.log.12345");
421
422 {
424 let mut file = File::create(&rotated_path).unwrap();
425 writeln!(file, "line1").unwrap();
426 writeln!(file, "line2").unwrap();
427 writeln!(file, "line3").unwrap();
428 }
429
430 File::create(&log_path).unwrap();
432
433 let config = RotationConfig::small();
434 let rotator = LogRotator::new(&log_path, config);
435
436 let records: Vec<String> = rotator
437 .read_rotated_file(&rotated_path, |line| Ok(line.to_string()))
438 .unwrap();
439
440 assert_eq!(records.len(), 3);
441 assert_eq!(records[0], "line1");
442 assert_eq!(records[2], "line3");
443 }
444}