observer_storage/
retention.rs1use std::fs;
7use std::path::Path;
8
9use serde::{Deserialize, Serialize};
10
11use crate::bundle::list_sessions;
12use crate::storage::now_ms;
13
14#[derive(Clone, Debug, Default, Serialize, Deserialize)]
16#[serde(rename_all = "camelCase")]
17pub struct RetentionPolicy {
18 pub max_age_days: Option<u32>,
20 pub max_sessions: Option<u32>,
22}
23
24impl RetentionPolicy {
25 pub fn is_empty(&self) -> bool {
26 self.max_age_days.is_none() && self.max_sessions.is_none()
27 }
28}
29
30pub fn enforce_retention(root: &Path, policy: &RetentionPolicy) -> Result<usize, String> {
37 if policy.is_empty() {
38 return Ok(0);
39 }
40 let mut sessions = list_sessions(root);
41 let now = now_ms();
42 let mut removed = 0usize;
43
44 if let Some(days) = policy.max_age_days {
46 let cutoff = now - (days as i64) * 86_400_000;
47 sessions.retain(|s| {
48 let keep = s["endedAt"].as_i64().map_or(true, |t| t >= cutoff);
49 if !keep {
50 if let Some(id) = s["id"].as_str() {
51 let _ = fs::remove_dir_all(root.join(id));
52 removed += 1;
53 }
54 }
55 keep
56 });
57 }
58
59 if let Some(max) = policy.max_sessions {
61 sessions.sort_by(|a, b| b["startedAt"].as_i64().cmp(&a["startedAt"].as_i64()));
62 for s in sessions.iter().skip(max as usize) {
63 if let Some(id) = s["id"].as_str() {
64 let _ = fs::remove_dir_all(root.join(id));
65 removed += 1;
66 }
67 }
68 }
69
70 Ok(removed)
71}
72
73#[cfg(test)]
74mod tests {
75 use super::*;
76 use serde_json::json;
77 use tempfile::tempdir;
78
79 fn make_session(root: &Path, id: &str, started: i64, ended: Option<i64>) {
80 let dir = root.join(id);
81 fs::create_dir_all(dir.join("segments")).unwrap();
82 let mut s = json!({ "id": id, "startedAt": started });
83 if let Some(e) = ended {
84 s["endedAt"] = json!(e);
85 }
86 fs::write(dir.join("session.json"), s.to_string()).unwrap();
87 }
88
89 #[test]
91 fn enforces_max_age() {
92 let root = tempdir().unwrap();
93 let now = now_ms();
94 make_session(root.path(), "old", now - 86_400_000 * 40, Some(now - 86_400_000 * 40));
95 make_session(root.path(), "fresh", now - 1000, Some(now - 1000));
96 make_session(root.path(), "active", now - 2000, None); let removed = enforce_retention(
99 root.path(),
100 &RetentionPolicy {
101 max_age_days: Some(30),
102 max_sessions: None,
103 },
104 )
105 .unwrap();
106 assert_eq!(removed, 1);
107 assert!(!root.path().join("old").exists());
108 assert!(root.path().join("fresh").exists());
109 assert!(root.path().join("active").exists());
110 }
111
112 #[test]
114 fn enforces_max_sessions() {
115 let root = tempdir().unwrap();
116 make_session(root.path(), "s1", 1000, Some(1100));
117 make_session(root.path(), "s2", 2000, Some(2100));
118 make_session(root.path(), "s3", 3000, Some(3100));
119
120 let removed = enforce_retention(
121 root.path(),
122 &RetentionPolicy {
123 max_age_days: None,
124 max_sessions: Some(2),
125 },
126 )
127 .unwrap();
128 assert_eq!(removed, 1);
129 assert!(!root.path().join("s1").exists());
131 assert!(root.path().join("s2").exists());
132 assert!(root.path().join("s3").exists());
133 }
134
135 #[test]
137 fn empty_policy_noop() {
138 let root = tempdir().unwrap();
139 make_session(root.path(), "s1", 1000, Some(1100));
140 let removed = enforce_retention(root.path(), &RetentionPolicy::default()).unwrap();
141 assert_eq!(removed, 0);
142 assert!(root.path().join("s1").exists());
143 }
144}