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}