Skip to main content

observer_storage/
retention.rs

1//! 会话保留策略:超龄/孤儿/超量清理(带保护名单变体)。
2//!
3//! 供 observer-server 后台清扫线程(多租户 per-tenant)、console 本地模式(单租户)
4//! 与 tauri-plugin-observer Local 清扫线程(P23)共用。纯逻辑,吃 `&Path`,零 tauri 依赖。
5
6use std::collections::HashSet;
7use std::fs;
8use std::path::Path;
9
10use serde::{Deserialize, Serialize};
11
12use crate::bundle::{list_sessions, validate_session_id};
13use crate::storage::now_ms;
14
15/// 保留策略:任一字段为 None 表示不限该维度。
16#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "camelCase", default)]
18pub struct RetentionPolicy {
19    /// 会话 endedAt 超过 max_age_days 被清理(未结束的会话不在本路径——孤儿见
20    /// [`RetentionPolicy::orphan_max_age_days`])。
21    pub max_age_days: Option<u32>,
22    /// 按 startedAt 倒序保留 max_sessions 条,余者清理。
23    pub max_sessions: Option<u32>,
24    /// 孤儿清理(P23):无 endedAt 且 startedAt 超过 orphan_max_age_days 的会话被清理,
25    /// 覆盖崩溃/强杀/未登出直接退出产生的无收口会话(活跃会话由 protect 名单豁免)。
26    /// None = 不清孤儿(0.1.x 语义)。
27    pub orphan_max_age_days: Option<u32>,
28}
29
30impl RetentionPolicy {
31    pub fn is_empty(&self) -> bool {
32        self.max_age_days.is_none()
33            && self.max_sessions.is_none()
34            && self.orphan_max_age_days.is_none()
35    }
36}
37
38/// `Some(0)` 视同 `None`:策略生效首启 30s 内删光存量不可能是合法意图,防宿主笔误。
39fn effective(v: Option<u32>) -> Option<u32> {
40    v.filter(|&x| x > 0)
41}
42
43/// 删除纪律:meta.id 先过 [`validate_session_id`](id 取自 session.json,防 `../`
44/// 路径穿越与 id≠目录名误删),`remove_dir_all` 实际成功才算删除。
45fn remove_session_dir(root: &Path, id: &str) -> bool {
46    if !validate_session_id(id) {
47        return false;
48    }
49    fs::remove_dir_all(root.join(id)).is_ok()
50}
51
52/// 按策略清理 root 下的会话,`protect` 名单中的会话 id 三条路径均跳过(如插件侧
53/// 活跃会话,P23)。返回实际删除数(删除失败不计,如 Windows 文件锁,下轮重试)。
54///
55/// - 超龄:`endedAt < now - max_age_days` 删除(无 endedAt 的会话不在本路径)。
56/// - 孤儿:无 endedAt 且 `startedAt < now - orphan_max_age_days` 删除(起点不明保守保留)。
57/// - 超量:非 protect 会话按 startedAt 倒序保留前 max_sessions 条(protect 不占名额),
58///   余者删除。
59///
60/// 纪律:`Some(0)` 一律视同 `None`;meta.id 非法跳过不删不计数;session.json 缺失/
61/// 损坏的目录不在扫描结果内,三条路径均不可见(已知边界)。策略为空(三维全
62/// None/0)直接返回 0,不做扫描。
63pub fn enforce_retention_protect(
64    root: &Path,
65    policy: &RetentionPolicy,
66    protect: &[&str],
67) -> Result<usize, String> {
68    let max_age = effective(policy.max_age_days);
69    let orphan_age = effective(policy.orphan_max_age_days);
70    let max_sessions = effective(policy.max_sessions);
71    if max_age.is_none() && orphan_age.is_none() && max_sessions.is_none() {
72        return Ok(0);
73    }
74    let protected: HashSet<&str> = protect.iter().copied().collect();
75    let mut sessions = list_sessions(root);
76    let now = now_ms();
77    let mut removed = 0usize;
78
79    // 超龄清理(只认 endedAt)
80    if let Some(days) = max_age {
81        let cutoff = now - (days as i64) * 86_400_000;
82        sessions.retain(|s| {
83            let Some(id) = s["id"].as_str() else {
84                return true;
85            };
86            if protected.contains(id) {
87                return true;
88            }
89            let overage = matches!(s["endedAt"].as_i64(), Some(t) if t < cutoff);
90            if overage && remove_session_dir(root, id) {
91                removed += 1;
92                return false;
93            }
94            true
95        });
96    }
97
98    // 孤儿清理(无 endedAt 且 startedAt 超龄)
99    if let Some(days) = orphan_age {
100        let cutoff = now - (days as i64) * 86_400_000;
101        sessions.retain(|s| {
102            let Some(id) = s["id"].as_str() else {
103                return true;
104            };
105            if protected.contains(id) {
106                return true;
107            }
108            if s["endedAt"].as_i64().is_some() {
109                return true; // 有收口归超龄/超量路径
110            }
111            let Some(started) = s["startedAt"].as_i64() else {
112                return true;
113            };
114            if started < cutoff && remove_session_dir(root, id) {
115                removed += 1;
116                return false;
117            }
118            true
119        });
120    }
121
122    // 超量清理:非 protect 集合按 startedAt 倒序,保留前 max_sessions
123    if let Some(max) = max_sessions {
124        let mut eligible: Vec<&serde_json::Value> = sessions
125            .iter()
126            .filter(|s| s["id"].as_str().map_or(true, |id| !protected.contains(id)))
127            .collect();
128        eligible.sort_by(|a, b| b["startedAt"].as_i64().cmp(&a["startedAt"].as_i64()));
129        for s in eligible.iter().skip(max as usize) {
130            if let Some(id) = s["id"].as_str() {
131                if remove_session_dir(root, id) {
132                    removed += 1;
133                }
134            }
135        }
136    }
137
138    Ok(removed)
139}
140
141/// 兼容保留(0.1.x 签名):等价 protect 空名单。既有调用方(server 清扫线程 /
142/// console 单租户)策略不含 [`RetentionPolicy::orphan_max_age_days`](serde default
143/// None)→ 行为不变,仅删除纪律(防呆/校验/实际计数)随变体生效。
144pub fn enforce_retention(root: &Path, policy: &RetentionPolicy) -> Result<usize, String> {
145    enforce_retention_protect(root, policy, &[])
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use serde_json::json;
152    use tempfile::tempdir;
153
154    fn make_session(root: &Path, id: &str, started: i64, ended: Option<i64>) {
155        let dir = root.join(id);
156        fs::create_dir_all(dir.join("segments")).unwrap();
157        let mut s = json!({ "id": id, "startedAt": started });
158        if let Some(e) = ended {
159            s["endedAt"] = json!(e);
160        }
161        fs::write(dir.join("session.json"), s.to_string()).unwrap();
162    }
163
164    /// 超龄会话被清,活跃会话(无 endedAt)与未超龄保留。
165    #[test]
166    fn enforces_max_age() {
167        let root = tempdir().unwrap();
168        let now = now_ms();
169        make_session(root.path(), "900000000002", now - 86_400_000 * 40, Some(now - 86_400_000 * 40));
170        make_session(root.path(), "900000000003", now - 1000, Some(now - 1000));
171        make_session(root.path(), "900000000004", now - 2000, None); // 活跃,不清理
172
173        let removed = enforce_retention(
174            root.path(),
175            &RetentionPolicy {
176                max_age_days: Some(30),
177                max_sessions: None,
178                orphan_max_age_days: None,
179            },
180        )
181        .unwrap();
182        assert_eq!(removed, 1);
183        assert!(!root.path().join("900000000002").exists());
184        assert!(root.path().join("900000000003").exists());
185        assert!(root.path().join("900000000004").exists());
186    }
187
188    /// 超量按 startedAt 倒序淘汰。
189    #[test]
190    fn enforces_max_sessions() {
191        let root = tempdir().unwrap();
192        make_session(root.path(), "1001", 1000, Some(1100));
193        make_session(root.path(), "1002", 2000, Some(2100));
194        make_session(root.path(), "1003", 3000, Some(3100));
195
196        let removed = enforce_retention(
197            root.path(),
198            &RetentionPolicy {
199                max_age_days: None,
200                max_sessions: Some(2),
201                orphan_max_age_days: None,
202            },
203        )
204        .unwrap();
205        assert_eq!(removed, 1);
206        // 保留最新两条 s3/s2,淘汰 s1
207        assert!(!root.path().join("1001").exists());
208        assert!(root.path().join("1002").exists());
209        assert!(root.path().join("1003").exists());
210    }
211
212    /// 空策略不扫描、不删。
213    #[test]
214    fn empty_policy_noop() {
215        let root = tempdir().unwrap();
216        make_session(root.path(), "1001", 1000, Some(1100));
217        let removed = enforce_retention(root.path(), &RetentionPolicy::default()).unwrap();
218        assert_eq!(removed, 0);
219        assert!(root.path().join("1001").exists());
220    }
221
222    /// protect 名单中的已收口超龄会话保留(超龄路径被 protect 收窄)。
223    #[test]
224    fn protect_skips_in_max_age() {
225        let root = tempdir().unwrap();
226        let now = now_ms();
227        make_session(root.path(), "900000000001", now - 86_400_000 * 40, Some(now - 86_400_000 * 40));
228        make_session(root.path(), "900000000002", now - 86_400_000 * 40, Some(now - 86_400_000 * 40));
229
230        let policy = RetentionPolicy {
231            max_age_days: Some(30),
232            max_sessions: None,
233            orphan_max_age_days: None,
234        };
235        let removed = enforce_retention_protect(root.path(), &policy, &["900000000001"]).unwrap();
236        assert_eq!(removed, 1);
237        assert!(root.path().join("900000000001").exists());
238        assert!(!root.path().join("900000000002").exists());
239    }
240
241    /// 超量路径 protect 不占名额:受保护的最老会话保留,顺位多删一条。
242    #[test]
243    fn protect_skips_in_max_sessions() {
244        let root = tempdir().unwrap();
245        make_session(root.path(), "1001", 1000, Some(1100));
246        make_session(root.path(), "1002", 2000, Some(2100));
247        make_session(root.path(), "1003", 3000, Some(3100));
248        make_session(root.path(), "1004", 4000, Some(4100));
249
250        let policy = RetentionPolicy {
251            max_age_days: None,
252            max_sessions: Some(2),
253            orphan_max_age_days: None,
254        };
255        let removed = enforce_retention_protect(root.path(), &policy, &["1001"]).unwrap();
256        // eligible = s2/s3/s4(s1 受保护不占名额),保留 s4/s3,删 s2
257        assert_eq!(removed, 1);
258        assert!(root.path().join("1001").exists());
259        assert!(!root.path().join("1002").exists());
260        assert!(root.path().join("1003").exists());
261        assert!(root.path().join("1004").exists());
262    }
263
264    /// 孤儿:无 endedAt 且 startedAt 超龄可清(orphan_max_age_days 显式开启)。
265    #[test]
266    fn orphan_swept_by_started_at() {
267        let root = tempdir().unwrap();
268        let now = now_ms();
269        make_session(root.path(), "800000000001", now - 86_400_000 * 40, None);
270        make_session(root.path(), "800000000002", now - 86_400_000 * 40, Some(now - 86_400_000 * 40));
271
272        let policy = RetentionPolicy {
273            max_age_days: None,
274            max_sessions: None,
275            orphan_max_age_days: Some(30),
276        };
277        let removed = enforce_retention_protect(root.path(), &policy, &[]).unwrap();
278        assert_eq!(removed, 1);
279        assert!(!root.path().join("800000000001").exists());
280        assert!(root.path().join("800000000002").exists()); // 有 endedAt 不归孤儿路径
281    }
282
283    /// 孤儿路径同样受 protect 豁免(宿主合法长活会话,P22 D5)。
284    #[test]
285    fn orphan_skips_protected() {
286        let root = tempdir().unwrap();
287        let now = now_ms();
288        make_session(root.path(), "800000000003", now - 86_400_000 * 40, None);
289
290        let policy = RetentionPolicy {
291            max_age_days: None,
292            max_sessions: None,
293            orphan_max_age_days: Some(30),
294        };
295        let removed = enforce_retention_protect(root.path(), &policy, &["800000000003"]).unwrap();
296        assert_eq!(removed, 0);
297        assert!(root.path().join("800000000003").exists());
298    }
299
300    /// orphan 字段 None = 0.1.x 语义:无 endedAt 一律保留(legacy 回归锚)。
301    #[test]
302    fn orphan_none_keeps_legacy_semantics() {
303        let root = tempdir().unwrap();
304        let now = now_ms();
305        make_session(root.path(), "800000000001", now - 86_400_000 * 40, None);
306
307        let policy = RetentionPolicy {
308            max_age_days: Some(30),
309            max_sessions: None,
310            orphan_max_age_days: None,
311        };
312        let removed = enforce_retention_protect(root.path(), &policy, &[]).unwrap();
313        assert_eq!(removed, 0);
314        assert!(root.path().join("800000000001").exists());
315    }
316
317    /// `Some(0)` 三维一律视同 None:不删任何东西(防宿主笔误)。
318    #[test]
319    fn zero_values_treated_as_noop() {
320        let root = tempdir().unwrap();
321        let now = now_ms();
322        make_session(root.path(), "900000000002", now - 86_400_000 * 40, Some(now - 86_400_000 * 40));
323        make_session(root.path(), "800000000001", now - 86_400_000 * 40, None);
324
325        let policy = RetentionPolicy {
326            max_age_days: Some(0),
327            max_sessions: Some(0),
328            orphan_max_age_days: Some(0),
329        };
330        let removed = enforce_retention_protect(root.path(), &policy, &[]).unwrap();
331        assert_eq!(removed, 0);
332        assert!(root.path().join("900000000002").exists());
333        assert!(root.path().join("800000000001").exists());
334    }
335
336    /// meta.id 非法(路径穿越 / 与目录名不符)跳过不删不计数。
337    #[test]
338    fn invalid_session_id_skipped() {
339        let root = tempdir().unwrap();
340        let now = now_ms();
341        let dir = root.path().join("dir-a");
342        fs::create_dir_all(dir.join("segments")).unwrap();
343        fs::write(
344            dir.join("session.json"),
345            json!({
346                "id": "../evil",
347                "startedAt": now - 86_400_000 * 40,
348                "endedAt": now - 86_400_000 * 40
349            })
350            .to_string(),
351        )
352        .unwrap();
353
354        let policy = RetentionPolicy {
355            max_age_days: Some(30),
356            max_sessions: None,
357            orphan_max_age_days: None,
358        };
359        let removed = enforce_retention_protect(root.path(), &policy, &[]).unwrap();
360        assert_eq!(removed, 0);
361        assert!(root.path().join("dir-a").exists());
362    }
363
364    /// 原函数 = protect 空名单(委托等价回归锚)。
365    #[test]
366    fn delegate_empty_protect_equivalent() {
367        let root = tempdir().unwrap();
368        let now = now_ms();
369        make_session(root.path(), "900000000002", now - 86_400_000 * 40, Some(now - 86_400_000 * 40));
370
371        let policy = RetentionPolicy {
372            max_age_days: Some(30),
373            max_sessions: None,
374            orphan_max_age_days: None,
375        };
376        let removed = enforce_retention(root.path(), &policy).unwrap();
377        assert_eq!(removed, 1);
378        assert!(!root.path().join("900000000002").exists());
379    }
380}