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" && matches!(component, "migrate" | "search-perf") {
219 return debug_enabled;
220 }
221 true
222}
223
224pub fn open_log_append() -> Option<std::fs::File> {
225 let policy = log_policy()?;
226 match with_prepared_log(&policy, |file| Ok(Some(file))) {
227 Ok(file) => file,
228 Err(error) => {
229 eprintln!("[remem] open log for child stderr failed: {}", error);
230 None
231 }
232 }
233}
234
235pub fn debug_enabled() -> bool {
236 std::env::var("REMEM_DEBUG").is_ok()
237}
238
239pub fn debug(component: &str, msg: &str) {
240 if debug_enabled() {
241 write_log("DEBUG", component, msg);
242 }
243}
244
245pub fn info(component: &str, msg: &str) {
246 write_log("INFO", component, msg);
247}
248
249pub fn warn(component: &str, msg: &str) {
250 write_log("WARN", component, msg);
251}
252
253pub fn error(component: &str, msg: &str) {
254 write_log("ERROR", component, msg);
255}
256
257fn try_lock_until(file: &File, timeout: Duration) -> std::io::Result<bool> {
258 let started = Instant::now();
259 loop {
260 match file.try_lock_exclusive() {
261 Ok(()) => return Ok(true),
262 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
263 if started.elapsed() >= timeout {
264 return Ok(false);
265 }
266 std::thread::sleep(Duration::from_millis(5));
267 }
268 Err(error) => return Err(error),
269 }
270 }
271}
272
273fn append_fallback<T>(
274 policy: &LogPolicy,
275 action: impl FnOnce(File) -> std::io::Result<Option<T>>,
276) -> std::io::Result<Option<T>> {
277 create_parent_dir(policy)?;
278 let file = open_private_append(&policy.path)?;
279 action(file)
280}
281
282fn create_parent_dir(policy: &LogPolicy) -> std::io::Result<()> {
283 if let Some(parent) = policy.path.parent() {
284 std::fs::create_dir_all(parent)?;
285 }
286 Ok(())
287}
288
289fn open_private_append(path: &Path) -> std::io::Result<File> {
290 let file = private_append_create_options().open(path)?;
291 set_private_permissions(path);
292 Ok(file)
293}
294
295fn cleanup_suffixes_above(path: &Path, max_rotated_files: usize) -> std::io::Result<()> {
296 let Some(parent) = path.parent() else {
297 return Ok(());
298 };
299 let Some(base_name) = path.file_name().and_then(|name| name.to_str()) else {
300 return Ok(());
301 };
302 let prefix = format!("{base_name}.");
303 for entry in std::fs::read_dir(parent)? {
304 let entry = entry?;
305 let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
306 continue;
307 };
308 let Some(suffix) = name.strip_prefix(&prefix) else {
309 continue;
310 };
311 let Ok(index) = suffix.parse::<usize>() else {
312 continue;
313 };
314 if index > max_rotated_files {
315 remove_if_exists(&entry.path())?;
316 }
317 }
318 Ok(())
319}
320
321fn remove_if_exists(path: &Path) -> std::io::Result<()> {
322 match std::fs::remove_file(path) {
323 Ok(()) => Ok(()),
324 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
325 Err(error) => Err(error),
326 }
327}
328
329fn retained_log_bytes(path: &Path, max_rotated_files: usize) -> u64 {
330 let configured_bytes = (1..=max_rotated_files)
331 .map(|index| file_size(&rotated_log_path(path, index)))
332 .sum::<u64>();
333 configured_bytes + suffixes_above_bytes(path, max_rotated_files)
334}
335
336fn suffixes_above_bytes(path: &Path, max_rotated_files: usize) -> u64 {
337 let Some(parent) = path.parent() else {
338 return 0;
339 };
340 let Some(base_name) = path.file_name().and_then(|name| name.to_str()) else {
341 return 0;
342 };
343 let prefix = format!("{base_name}.");
344 let Ok(entries) = std::fs::read_dir(parent) else {
345 return 0;
346 };
347 entries
348 .filter_map(Result::ok)
349 .filter_map(|entry| {
350 let name = entry.file_name().to_string_lossy().into_owned();
351 let suffix = name.strip_prefix(&prefix)?;
352 let index = suffix.parse::<usize>().ok()?;
353 (index > max_rotated_files).then(|| file_size(&entry.path()))
354 })
355 .sum()
356}
357
358fn file_size(path: &Path) -> u64 {
359 std::fs::metadata(path).map(|meta| meta.len()).unwrap_or(0)
360}
361
362fn record_rotation_issue(policy: &LogPolicy, kind: &str, message: &str) {
363 let issue = LogRotationIssue {
364 kind: kind.to_string(),
365 message: message.to_string(),
366 path: policy.path.display().to_string(),
367 at_epoch: now_epoch(),
368 };
369 let path = log_rotation_issue_path(&policy.path);
370 if let Some(parent) = path.parent() {
371 if let Err(error) = std::fs::create_dir_all(parent) {
372 report_internal_io_error("create log rotation issue directory failed", &error);
373 }
374 }
375 let tmp = path.with_file_name(format!(
376 ".{}.{}.{}.tmp",
377 path.file_name()
378 .and_then(|name| name.to_str())
379 .unwrap_or("remem-log-issue"),
380 std::process::id(),
381 chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
382 ));
383 let write_result = (|| -> std::io::Result<()> {
384 let mut file = private_write_create_new_options().open(&tmp)?;
385 let bytes = serde_json::to_vec_pretty(&issue).map_err(std::io::Error::other)?;
386 file.write_all(&bytes)?;
387 file.write_all(b"\n")?;
388 file.sync_all()?;
389 std::fs::rename(&tmp, &path)?;
390 set_private_permissions(&path);
391 Ok(())
392 })();
393 if let Err(error) = write_result {
394 remove_temp_issue_file(&tmp);
395 eprintln!("[remem] log rotation issue write failed: {}", error);
396 }
397}
398
399fn read_rotation_issue(policy: &LogPolicy) -> Result<Option<LogRotationIssue>, String> {
400 let path = log_rotation_issue_path(&policy.path);
401 let bytes = match std::fs::read(&path) {
402 Ok(bytes) => bytes,
403 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
404 Err(error) => {
405 return Err(format!("read {} failed: {}", path.display(), error));
406 }
407 };
408 serde_json::from_slice(&bytes)
409 .map(Some)
410 .map_err(|error| format!("parse {} failed: {}", path.display(), error))
411}
412
413fn clear_stale_rotation_issue(policy: &LogPolicy, prepare_started_epoch: i64) {
414 let path = log_rotation_issue_path(&policy.path);
415 if let Ok(Some(issue)) = read_rotation_issue(policy) {
416 if issue.at_epoch < prepare_started_epoch {
417 remove_rotation_issue_file(&path);
418 }
419 }
420}
421
422fn now_epoch() -> i64 {
423 chrono::Utc::now().timestamp()
424}
425
426fn private_append_create_options() -> OpenOptions {
427 let mut options = OpenOptions::new();
428 options.create(true).append(true);
429 set_create_mode(&mut options);
430 options
431}
432
433fn private_read_write_create_options() -> OpenOptions {
434 let mut options = OpenOptions::new();
435 options.create(true).read(true).write(true).truncate(false);
436 set_create_mode(&mut options);
437 options
438}
439
440fn private_write_create_new_options() -> OpenOptions {
441 let mut options = OpenOptions::new();
442 options.create_new(true).write(true);
443 set_create_mode(&mut options);
444 options
445}
446
447#[cfg(unix)]
448fn set_create_mode(options: &mut OpenOptions) {
449 use std::os::unix::fs::OpenOptionsExt;
450 options.mode(0o600);
451}
452
453#[cfg(not(unix))]
454fn set_create_mode(_options: &mut OpenOptions) {}
455
456pub(crate) fn set_private_permissions(path: &Path) {
457 #[cfg(unix)]
458 {
459 use std::os::unix::fs::PermissionsExt;
460 if let Err(error) = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) {
461 report_internal_io_error("set private log permissions failed", &error);
462 }
463 }
464}
465
466fn remove_temp_issue_file(path: &Path) {
467 match std::fs::remove_file(path) {
468 Ok(()) => {}
469 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
470 Err(error) => {
471 report_internal_io_error("remove temporary log rotation issue failed", &error)
472 }
473 }
474}
475
476fn remove_rotation_issue_file(path: &Path) {
477 match std::fs::remove_file(path) {
478 Ok(()) => {}
479 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
480 Err(error) => report_internal_io_error("clear stale log rotation issue failed", &error),
481 }
482}
483
484fn report_internal_io_error(context: &str, error: &std::io::Error) {
485 eprintln!("[remem] {context}: {error}");
486}
487
488#[cfg(test)]
489mod tests {
490 #[test]
491 fn migrate_info_is_not_mirrored_to_stderr_by_default() {
492 assert!(!super::should_mirror_to_stderr_with_env(
493 "INFO", "migrate", false, false
494 ));
495 assert!(super::should_mirror_to_stderr_with_env(
496 "INFO", "install", false, false
497 ));
498 assert!(super::should_mirror_to_stderr_with_env(
499 "ERROR", "migrate", false, false
500 ));
501 }
502
503 #[test]
504 fn search_perf_info_requires_debug_like_migrate() {
505 assert!(!super::should_mirror_to_stderr_with_env(
506 "INFO",
507 "search-perf",
508 false,
509 false
510 ));
511 assert!(super::should_mirror_to_stderr_with_env(
512 "INFO",
513 "search-perf",
514 true,
515 false
516 ));
517 assert!(super::should_mirror_to_stderr_with_env(
518 "DEBUG",
519 "search-perf",
520 false,
521 false
522 ));
523 }
524
525 #[test]
526 fn migrate_info_is_mirrored_to_stderr_when_debug_enabled() {
527 assert!(super::should_mirror_to_stderr_with_env(
528 "INFO", "migrate", true, false
529 ));
530 }
531
532 #[test]
533 fn stderr_to_log_disables_stderr_mirroring() {
534 assert!(!super::should_mirror_to_stderr_with_env(
535 "INFO", "migrate", true, true
536 ));
537 assert!(!super::should_mirror_to_stderr_with_env(
538 "ERROR", "migrate", true, true
539 ));
540 }
541}