Skip to main content

remem/log/
write.rs

1use std::fs::{File, OpenOptions};
2use std::io::Write;
3use std::path::{Path, PathBuf};
4use std::time::{Duration, Instant};
5
6use fs2::FileExt;
7use serde::{Deserialize, Serialize};
8
9use super::config::{
10    log_lock_path, log_policy, log_rotation_issue_path, rotated_log_path, InvalidLogEnv, LogPolicy,
11};
12
13const ROTATION_ISSUE_FRESH_SECS: i64 = 24 * 60 * 60;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub(crate) struct LogRotationIssue {
17    pub kind: String,
18    pub message: String,
19    pub path: String,
20    pub at_epoch: i64,
21}
22
23#[derive(Debug, Clone)]
24pub(crate) struct LogHealthSnapshot {
25    pub path: PathBuf,
26    pub active_bytes: u64,
27    pub total_bytes: u64,
28    pub max_bytes: u64,
29    pub max_rotated_files: usize,
30    pub lock_timeout_ms: u64,
31    pub invalid_env: Vec<InvalidLogEnv>,
32    pub issue: Option<LogRotationIssue>,
33    pub issue_is_fresh: bool,
34    pub issue_read_error: Option<String>,
35}
36
37pub(crate) fn log_health_snapshot() -> Option<LogHealthSnapshot> {
38    let policy = log_policy()?;
39    let active_bytes = file_size(&policy.path);
40    let total_bytes = active_bytes + retained_log_bytes(&policy.path, policy.max_rotated_files);
41    let (issue, issue_read_error) = match read_rotation_issue(&policy) {
42        Ok(issue) => (issue, None),
43        Err(error) => (None, Some(error)),
44    };
45    let issue_is_fresh = issue
46        .as_ref()
47        .is_some_and(|issue| issue.at_epoch >= now_epoch() - ROTATION_ISSUE_FRESH_SECS);
48    Some(LogHealthSnapshot {
49        path: policy.path,
50        active_bytes,
51        total_bytes,
52        max_bytes: policy.max_bytes,
53        max_rotated_files: policy.max_rotated_files,
54        lock_timeout_ms: policy.lock_timeout_ms,
55        invalid_env: policy.invalid_env,
56        issue,
57        issue_is_fresh,
58        issue_read_error,
59    })
60}
61
62pub(crate) fn rotate_if_needed(
63    path: &Path,
64    max_bytes: u64,
65    max_rotated_files: usize,
66) -> std::io::Result<()> {
67    cleanup_suffixes_above(path, max_rotated_files)?;
68    let size = match std::fs::metadata(path) {
69        Ok(metadata) => metadata.len(),
70        Err(_) => 0,
71    };
72    if size < max_bytes {
73        return Ok(());
74    }
75
76    if max_rotated_files == 0 {
77        match std::fs::remove_file(path) {
78            Ok(()) => return Ok(()),
79            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
80            Err(error) => return Err(error),
81        }
82    }
83
84    for index in (1..=max_rotated_files).rev() {
85        let dst = rotated_log_path(path, index);
86        if index == max_rotated_files {
87            remove_if_exists(&dst)?;
88        }
89        let src = if index == 1 {
90            path.to_path_buf()
91        } else {
92            rotated_log_path(path, index - 1)
93        };
94        if src.exists() {
95            std::fs::rename(&src, &dst)?;
96            set_private_permissions(&dst);
97        }
98    }
99    Ok(())
100}
101
102fn write_log(level: &str, component: &str, msg: &str) {
103    let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
104    let line = format!("[{}] [{}] [{}] {}", now, level, component, msg);
105    if should_mirror_to_stderr(level, component) {
106        eprintln!("{}", line);
107    }
108    let Some(policy) = log_policy() else {
109        return;
110    };
111    if let Err(error) = write_line_locked(&policy, &line) {
112        eprintln!("[remem] log write failed: {}", error);
113    }
114}
115
116fn write_line_locked(policy: &LogPolicy, line: &str) -> std::io::Result<()> {
117    match with_prepared_log(policy, |mut file| {
118        writeln!(file, "{line}")?;
119        Ok(None::<()>)
120    }) {
121        Ok(_) => Ok(()),
122        Err(error) => Err(error),
123    }
124}
125
126fn with_prepared_log<T>(
127    policy: &LogPolicy,
128    action: impl FnOnce(File) -> std::io::Result<Option<T>>,
129) -> std::io::Result<Option<T>> {
130    let prepare_started_epoch = now_epoch();
131    create_parent_dir(policy)?;
132    let lock_path = log_lock_path(&policy.path);
133    let lock_file = match private_read_write_create_options().open(&lock_path) {
134        Ok(file) => file,
135        Err(error) => {
136            record_rotation_issue(
137                policy,
138                "lock_open_failed",
139                &format!("open log lock {} failed: {}", lock_path.display(), error),
140            );
141            return append_fallback(policy, action);
142        }
143    };
144    set_private_permissions(&lock_path);
145    match try_lock_until(&lock_file, Duration::from_millis(policy.lock_timeout_ms)) {
146        Ok(true) => {}
147        Ok(false) => {
148            record_rotation_issue(
149                policy,
150                "lock_timeout",
151                &format!(
152                    "timed out after {}ms waiting for {}",
153                    policy.lock_timeout_ms,
154                    lock_path.display()
155                ),
156            );
157            return append_fallback(policy, action);
158        }
159        Err(error) => {
160            record_rotation_issue(
161                policy,
162                "lock_failed",
163                &format!("lock {} failed: {}", lock_path.display(), error),
164            );
165            return append_fallback(policy, action);
166        }
167    }
168
169    let rotate_result = rotate_if_needed(&policy.path, policy.max_bytes, policy.max_rotated_files);
170    if let Err(error) = rotate_result {
171        record_rotation_issue(
172            policy,
173            "rotate_failed",
174            &format!("rotate {} failed: {}", policy.path.display(), error),
175        );
176        return append_fallback(policy, action);
177    }
178
179    match open_private_append(&policy.path) {
180        Ok(file) => {
181            let result = action(file);
182            if result.is_ok() {
183                clear_stale_rotation_issue(policy, prepare_started_epoch);
184            }
185            result
186        }
187        Err(error) => {
188            record_rotation_issue(
189                policy,
190                "open_failed",
191                &format!("open {} failed: {}", policy.path.display(), error),
192            );
193            append_fallback(policy, action)
194        }
195    }
196}
197
198fn should_mirror_to_stderr(level: &str, component: &str) -> bool {
199    should_mirror_to_stderr_with_env(
200        level,
201        component,
202        debug_enabled(),
203        std::env::var_os("REMEM_STDERR_TO_LOG").is_some(),
204    )
205}
206
207fn should_mirror_to_stderr_with_env(
208    level: &str,
209    component: &str,
210    debug_enabled: bool,
211    stderr_to_log: bool,
212) -> bool {
213    if stderr_to_log {
214        return false;
215    }
216    if level == "INFO" && component == "migrate" {
217        return debug_enabled;
218    }
219    true
220}
221
222pub fn open_log_append() -> Option<std::fs::File> {
223    let policy = log_policy()?;
224    match with_prepared_log(&policy, |file| Ok(Some(file))) {
225        Ok(file) => file,
226        Err(error) => {
227            eprintln!("[remem] open log for child stderr failed: {}", error);
228            None
229        }
230    }
231}
232
233pub fn debug_enabled() -> bool {
234    std::env::var("REMEM_DEBUG").is_ok()
235}
236
237pub fn debug(component: &str, msg: &str) {
238    if debug_enabled() {
239        write_log("DEBUG", component, msg);
240    }
241}
242
243pub fn info(component: &str, msg: &str) {
244    write_log("INFO", component, msg);
245}
246
247pub fn warn(component: &str, msg: &str) {
248    write_log("WARN", component, msg);
249}
250
251pub fn error(component: &str, msg: &str) {
252    write_log("ERROR", component, msg);
253}
254
255fn try_lock_until(file: &File, timeout: Duration) -> std::io::Result<bool> {
256    let started = Instant::now();
257    loop {
258        match file.try_lock_exclusive() {
259            Ok(()) => return Ok(true),
260            Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
261                if started.elapsed() >= timeout {
262                    return Ok(false);
263                }
264                std::thread::sleep(Duration::from_millis(5));
265            }
266            Err(error) => return Err(error),
267        }
268    }
269}
270
271fn append_fallback<T>(
272    policy: &LogPolicy,
273    action: impl FnOnce(File) -> std::io::Result<Option<T>>,
274) -> std::io::Result<Option<T>> {
275    create_parent_dir(policy)?;
276    let file = open_private_append(&policy.path)?;
277    action(file)
278}
279
280fn create_parent_dir(policy: &LogPolicy) -> std::io::Result<()> {
281    if let Some(parent) = policy.path.parent() {
282        std::fs::create_dir_all(parent)?;
283    }
284    Ok(())
285}
286
287fn open_private_append(path: &Path) -> std::io::Result<File> {
288    let file = private_append_create_options().open(path)?;
289    set_private_permissions(path);
290    Ok(file)
291}
292
293fn cleanup_suffixes_above(path: &Path, max_rotated_files: usize) -> std::io::Result<()> {
294    let Some(parent) = path.parent() else {
295        return Ok(());
296    };
297    let Some(base_name) = path.file_name().and_then(|name| name.to_str()) else {
298        return Ok(());
299    };
300    let prefix = format!("{base_name}.");
301    for entry in std::fs::read_dir(parent)? {
302        let entry = entry?;
303        let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
304            continue;
305        };
306        let Some(suffix) = name.strip_prefix(&prefix) else {
307            continue;
308        };
309        let Ok(index) = suffix.parse::<usize>() else {
310            continue;
311        };
312        if index > max_rotated_files {
313            remove_if_exists(&entry.path())?;
314        }
315    }
316    Ok(())
317}
318
319fn remove_if_exists(path: &Path) -> std::io::Result<()> {
320    match std::fs::remove_file(path) {
321        Ok(()) => Ok(()),
322        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
323        Err(error) => Err(error),
324    }
325}
326
327fn retained_log_bytes(path: &Path, max_rotated_files: usize) -> u64 {
328    let configured_bytes = (1..=max_rotated_files)
329        .map(|index| file_size(&rotated_log_path(path, index)))
330        .sum::<u64>();
331    configured_bytes + suffixes_above_bytes(path, max_rotated_files)
332}
333
334fn suffixes_above_bytes(path: &Path, max_rotated_files: usize) -> u64 {
335    let Some(parent) = path.parent() else {
336        return 0;
337    };
338    let Some(base_name) = path.file_name().and_then(|name| name.to_str()) else {
339        return 0;
340    };
341    let prefix = format!("{base_name}.");
342    let Ok(entries) = std::fs::read_dir(parent) else {
343        return 0;
344    };
345    entries
346        .filter_map(Result::ok)
347        .filter_map(|entry| {
348            let name = entry.file_name().to_string_lossy().into_owned();
349            let suffix = name.strip_prefix(&prefix)?;
350            let index = suffix.parse::<usize>().ok()?;
351            (index > max_rotated_files).then(|| file_size(&entry.path()))
352        })
353        .sum()
354}
355
356fn file_size(path: &Path) -> u64 {
357    std::fs::metadata(path).map(|meta| meta.len()).unwrap_or(0)
358}
359
360fn record_rotation_issue(policy: &LogPolicy, kind: &str, message: &str) {
361    let issue = LogRotationIssue {
362        kind: kind.to_string(),
363        message: message.to_string(),
364        path: policy.path.display().to_string(),
365        at_epoch: now_epoch(),
366    };
367    let path = log_rotation_issue_path(&policy.path);
368    if let Some(parent) = path.parent() {
369        if let Err(error) = std::fs::create_dir_all(parent) {
370            report_internal_io_error("create log rotation issue directory failed", &error);
371        }
372    }
373    let tmp = path.with_file_name(format!(
374        ".{}.{}.{}.tmp",
375        path.file_name()
376            .and_then(|name| name.to_str())
377            .unwrap_or("remem-log-issue"),
378        std::process::id(),
379        chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
380    ));
381    let write_result = (|| -> std::io::Result<()> {
382        let mut file = private_write_create_new_options().open(&tmp)?;
383        let bytes = serde_json::to_vec_pretty(&issue).map_err(std::io::Error::other)?;
384        file.write_all(&bytes)?;
385        file.write_all(b"\n")?;
386        file.sync_all()?;
387        std::fs::rename(&tmp, &path)?;
388        set_private_permissions(&path);
389        Ok(())
390    })();
391    if let Err(error) = write_result {
392        remove_temp_issue_file(&tmp);
393        eprintln!("[remem] log rotation issue write failed: {}", error);
394    }
395}
396
397fn read_rotation_issue(policy: &LogPolicy) -> Result<Option<LogRotationIssue>, String> {
398    let path = log_rotation_issue_path(&policy.path);
399    let bytes = match std::fs::read(&path) {
400        Ok(bytes) => bytes,
401        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
402        Err(error) => {
403            return Err(format!("read {} failed: {}", path.display(), error));
404        }
405    };
406    serde_json::from_slice(&bytes)
407        .map(Some)
408        .map_err(|error| format!("parse {} failed: {}", path.display(), error))
409}
410
411fn clear_stale_rotation_issue(policy: &LogPolicy, prepare_started_epoch: i64) {
412    let path = log_rotation_issue_path(&policy.path);
413    if let Ok(Some(issue)) = read_rotation_issue(policy) {
414        if issue.at_epoch < prepare_started_epoch {
415            remove_rotation_issue_file(&path);
416        }
417    }
418}
419
420fn now_epoch() -> i64 {
421    chrono::Utc::now().timestamp()
422}
423
424fn private_append_create_options() -> OpenOptions {
425    let mut options = OpenOptions::new();
426    options.create(true).append(true);
427    set_create_mode(&mut options);
428    options
429}
430
431fn private_read_write_create_options() -> OpenOptions {
432    let mut options = OpenOptions::new();
433    options.create(true).read(true).write(true).truncate(false);
434    set_create_mode(&mut options);
435    options
436}
437
438fn private_write_create_new_options() -> OpenOptions {
439    let mut options = OpenOptions::new();
440    options.create_new(true).write(true);
441    set_create_mode(&mut options);
442    options
443}
444
445#[cfg(unix)]
446fn set_create_mode(options: &mut OpenOptions) {
447    use std::os::unix::fs::OpenOptionsExt;
448    options.mode(0o600);
449}
450
451#[cfg(not(unix))]
452fn set_create_mode(_options: &mut OpenOptions) {}
453
454pub(crate) fn set_private_permissions(path: &Path) {
455    #[cfg(unix)]
456    {
457        use std::os::unix::fs::PermissionsExt;
458        if let Err(error) = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) {
459            report_internal_io_error("set private log permissions failed", &error);
460        }
461    }
462}
463
464fn remove_temp_issue_file(path: &Path) {
465    match std::fs::remove_file(path) {
466        Ok(()) => {}
467        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
468        Err(error) => {
469            report_internal_io_error("remove temporary log rotation issue failed", &error)
470        }
471    }
472}
473
474fn remove_rotation_issue_file(path: &Path) {
475    match std::fs::remove_file(path) {
476        Ok(()) => {}
477        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
478        Err(error) => report_internal_io_error("clear stale log rotation issue failed", &error),
479    }
480}
481
482fn report_internal_io_error(context: &str, error: &std::io::Error) {
483    eprintln!("[remem] {context}: {error}");
484}
485
486#[cfg(test)]
487mod tests {
488    #[test]
489    fn migrate_info_is_not_mirrored_to_stderr_by_default() {
490        assert!(!super::should_mirror_to_stderr_with_env(
491            "INFO", "migrate", false, false
492        ));
493        assert!(super::should_mirror_to_stderr_with_env(
494            "INFO", "install", false, false
495        ));
496        assert!(super::should_mirror_to_stderr_with_env(
497            "ERROR", "migrate", false, false
498        ));
499    }
500
501    #[test]
502    fn migrate_info_is_mirrored_to_stderr_when_debug_enabled() {
503        assert!(super::should_mirror_to_stderr_with_env(
504            "INFO", "migrate", true, false
505        ));
506    }
507
508    #[test]
509    fn stderr_to_log_disables_stderr_mirroring() {
510        assert!(!super::should_mirror_to_stderr_with_env(
511            "INFO", "migrate", true, true
512        ));
513        assert!(!super::should_mirror_to_stderr_with_env(
514            "ERROR", "migrate", true, true
515        ));
516    }
517}