Skip to main content

lean_ctx/core/session/
housekeeping.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Bounded upkeep of the session store, run off the MCP handshake (GH #2006).
3//!
4//! Nothing pruned the store automatically, and every save left its
5//! `.<id>.save.lock` behind, so long-lived installs collected thousands of
6//! session files (1,617 sessions and 894 orphaned locks in the report). Once a
7//! day, from the server's background housekeeping:
8//! - lock files whose session JSON is gone are removed, if nobody holds them;
9//! - a store above `AUTO_PRUNE_MIN_SESSIONS` is pruned with the explicit
10//!   cleanup rules (`session_retention_days`; the newest session per project
11//!   and the global latest pointer are always kept, and facts are persisted
12//!   before a session is removed).
13
14use std::path::Path;
15
16use super::SessionState;
17
18/// Stores at or below this size are left alone: the explicit cleanup command
19/// stays the only path that removes their sessions.
20pub(crate) const AUTO_PRUNE_MIN_SESSIONS: usize = 300;
21
22const DAY_MARKER: &str = "session_housekeeping_day";
23
24/// What one housekeeping pass did.
25#[derive(Debug, Default, Clone, PartialEq, Eq)]
26pub struct HousekeepingReport {
27    pub orphan_locks_removed: usize,
28    pub sessions_pruned: u32,
29}
30
31/// Run the pass at most once per UTC day. `None` when it already ran today or
32/// there is no session store.
33pub 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
60/// Only large stores, and only with a retention of at least one day. The
61/// cleanup removes sessions last updated before `now - retention_days`, so a
62/// session in use (saved within that window) and everything newer survive; a
63/// retention of 0 would move that cutoff to "now", which an automatic pass
64/// must never do — `0` stays an explicit-cleanup-only setting.
65fn auto_prune_allowed(sessions: usize, retention_days: u32) -> bool {
66    sessions > AUTO_PRUNE_MIN_SESSIONS && retention_days >= 1
67}
68
69/// Session JSON files, counted by name only (no parsing).
70fn 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
85/// Remove `.<id>.save.lock` files whose `<id>.json` no longer exists. A lock
86/// that another process holds is kept; a held lock means a save is running.
87fn 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}