lean_ctx/core/session/
housekeeping.rs1use std::path::Path;
15
16use super::SessionState;
17
18pub(crate) const AUTO_PRUNE_MIN_SESSIONS: usize = 300;
21
22const DAY_MARKER: &str = "session_housekeeping_day";
23
24#[derive(Debug, Default, Clone, PartialEq, Eq)]
26pub struct HousekeepingReport {
27 pub orphan_locks_removed: usize,
28 pub sessions_pruned: u32,
29}
30
31pub fn run_daily() -> Option<HousekeepingReport> {
34 let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
35 let marker = crate::core::paths::state_dir().ok()?.join(DAY_MARKER);
36 if std::fs::read_to_string(&marker).is_ok_and(|day| day.trim() == today) {
37 return None;
38 }
39 let dir = super::paths::sessions_dir()?;
40 let retention = crate::core::config::Config::load().session_retention_days_effective();
41 let report = run_in(&dir, retention);
42 if let Some(parent) = marker.parent() {
43 let _ = std::fs::create_dir_all(parent);
44 }
45 let _ = std::fs::write(&marker, format!("{today}\n"));
46 Some(report)
47}
48
49fn run_in(dir: &Path, retention_days: u32) -> HousekeepingReport {
50 let mut report = HousekeepingReport {
51 orphan_locks_removed: remove_orphan_locks(dir),
52 ..HousekeepingReport::default()
53 };
54 if auto_prune_allowed(count_sessions(dir), retention_days) {
55 report.sessions_pruned = SessionState::cleanup_old_sessions(i64::from(retention_days));
56 }
57 report
58}
59
60fn auto_prune_allowed(sessions: usize, retention_days: u32) -> bool {
66 sessions > AUTO_PRUNE_MIN_SESSIONS && retention_days >= 1
67}
68
69fn count_sessions(dir: &Path) -> usize {
71 std::fs::read_dir(dir)
72 .map(|entries| {
73 entries
74 .flatten()
75 .filter(|entry| {
76 let name = entry.file_name();
77 let name = name.to_string_lossy();
78 name.ends_with(".json") && !name.starts_with('.') && name != "latest.json"
79 })
80 .count()
81 })
82 .unwrap_or(0)
83}
84
85fn remove_orphan_locks(dir: &Path) -> usize {
88 use fs2::FileExt;
89 let Ok(entries) = std::fs::read_dir(dir) else {
90 return 0;
91 };
92 let mut removed = 0;
93 for entry in entries.flatten() {
94 let name = entry.file_name();
95 let name = name.to_string_lossy();
96 let Some(id) = name
97 .strip_prefix('.')
98 .and_then(|rest| rest.strip_suffix(".save.lock"))
99 else {
100 continue;
101 };
102 if id.is_empty() || dir.join(format!("{id}.json")).exists() {
103 continue;
104 }
105 let path = entry.path();
106 let Ok(lock) = std::fs::OpenOptions::new().write(true).open(&path) else {
107 continue;
108 };
109 if lock.try_lock_exclusive().is_ok() && std::fs::remove_file(&path).is_ok() {
110 removed += 1;
111 }
112 }
113 removed
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119
120 #[test]
121 fn orphan_locks_are_removed_and_live_ones_kept() {
122 let tmp = tempfile::tempdir().unwrap();
123 let dir = tmp.path();
124 std::fs::write(dir.join("alive.json"), "{}").unwrap();
125 std::fs::write(dir.join(".alive.save.lock"), "").unwrap();
126 std::fs::write(dir.join(".gone.save.lock"), "").unwrap();
127 std::fs::write(dir.join(".other.tmp"), "").unwrap();
128
129 assert_eq!(remove_orphan_locks(dir), 1);
130 assert!(dir.join(".alive.save.lock").exists());
131 assert!(!dir.join(".gone.save.lock").exists());
132 assert!(dir.join(".other.tmp").exists());
133 }
134
135 #[test]
136 fn a_held_orphan_lock_is_kept() {
137 use fs2::FileExt;
138 let tmp = tempfile::tempdir().unwrap();
139 let path = tmp.path().join(".busy.save.lock");
140 let holder = std::fs::File::create(&path).unwrap();
141 holder.lock_exclusive().unwrap();
142
143 assert_eq!(remove_orphan_locks(tmp.path()), 0);
144 assert!(path.exists());
145 }
146
147 #[test]
148 fn auto_prune_needs_a_large_store_and_a_positive_retention() {
149 assert!(!auto_prune_allowed(AUTO_PRUNE_MIN_SESSIONS, 7));
150 assert!(auto_prune_allowed(AUTO_PRUNE_MIN_SESSIONS + 1, 7));
151 assert!(auto_prune_allowed(AUTO_PRUNE_MIN_SESSIONS + 1, 1));
152 assert!(
153 !auto_prune_allowed(10_000, 0),
154 "retention 0 would prune up to now; never automatically"
155 );
156 }
157
158 #[test]
159 fn small_stores_are_never_pruned_automatically() {
160 let tmp = tempfile::tempdir().unwrap();
161 for i in 0..AUTO_PRUNE_MIN_SESSIONS {
162 std::fs::write(tmp.path().join(format!("s{i}.json")), "{}").unwrap();
163 }
164 std::fs::write(tmp.path().join("latest.json"), "{}").unwrap();
165 assert_eq!(count_sessions(tmp.path()), AUTO_PRUNE_MIN_SESSIONS);
166 assert_eq!(run_in(tmp.path(), 7).sessions_pruned, 0);
167 }
168}