subcog/services/
tombstone.rs1use crate::models::{EventMeta, MemoryEvent, MemoryId, MemoryStatus};
4use crate::observability::current_request_id;
5use crate::security::record_event;
6use crate::storage::traits::PersistenceBackend;
7use crate::{Error, Result};
8use chrono::{TimeZone, Utc};
9use std::sync::Arc;
10use std::time::Duration;
11use tracing::instrument;
12
13pub struct TombstoneService {
15 persistence: Arc<dyn PersistenceBackend>,
16}
17
18impl TombstoneService {
19 #[must_use]
21 pub fn new(persistence: Arc<dyn PersistenceBackend>) -> Self {
22 Self { persistence }
23 }
24
25 #[instrument(skip(self), fields(memory_id = %id.as_str()))]
33 pub fn tombstone_memory(&self, id: &MemoryId) -> Result<()> {
34 let mut memory = self
36 .persistence
37 .get(id)?
38 .ok_or_else(|| Error::OperationFailed {
39 operation: "tombstone_memory".to_string(),
40 cause: format!("Memory not found: {}", id.as_str()),
41 })?;
42
43 let now = crate::current_timestamp();
45 let now_i64 = i64::try_from(now).unwrap_or(i64::MAX);
46 let now_dt = Utc
47 .timestamp_opt(now_i64, 0)
48 .single()
49 .unwrap_or_else(Utc::now);
50
51 memory.status = MemoryStatus::Tombstoned;
52 memory.tombstoned_at = Some(now_dt);
53 memory.updated_at = now;
54
55 self.persistence.store(&memory)?;
57
58 record_event(MemoryEvent::Updated {
59 meta: EventMeta::with_timestamp("tombstone", current_request_id(), now),
60 memory_id: memory.id,
61 modified_fields: vec!["status".to_string(), "tombstoned_at".to_string()],
62 });
63
64 tracing::info!(
65 memory_id = %id.as_str(),
66 tombstoned_at = now,
67 "Tombstoned memory"
68 );
69
70 metrics::counter!("tombstone_memory_total").increment(1);
71 Ok(())
72 }
73
74 #[instrument(skip(self), fields(memory_id = %id.as_str()))]
82 pub fn untombstone_memory(&self, id: &MemoryId) -> Result<()> {
83 let mut memory = self
85 .persistence
86 .get(id)?
87 .ok_or_else(|| Error::OperationFailed {
88 operation: "untombstone_memory".to_string(),
89 cause: format!("Memory not found: {}", id.as_str()),
90 })?;
91
92 memory.status = MemoryStatus::Active;
94 memory.tombstoned_at = None;
95 memory.updated_at = crate::current_timestamp();
96
97 self.persistence.store(&memory)?;
99
100 let updated_at = memory.updated_at;
101 record_event(MemoryEvent::Updated {
102 meta: EventMeta::with_timestamp("tombstone", current_request_id(), updated_at),
103 memory_id: memory.id,
104 modified_fields: vec!["status".to_string(), "tombstoned_at".to_string()],
105 });
106
107 tracing::info!(
108 memory_id = %id.as_str(),
109 "Untombstoned memory"
110 );
111
112 metrics::counter!("untombstone_memory_total").increment(1);
113 Ok(())
114 }
115
116 #[instrument(skip(self), fields(older_than_secs = older_than.as_secs()))]
125 pub fn purge_tombstoned(&self, older_than: Duration) -> Result<usize> {
126 let threshold = crate::current_timestamp().saturating_sub(older_than.as_secs());
127 let threshold_i64 = i64::try_from(threshold).unwrap_or(i64::MAX);
128
129 let all_ids = self.persistence.list_ids()?;
131
132 let mut purged = 0;
133 for id in all_ids {
134 if let Some(memory) = self.persistence.get(&id)?
135 && memory.status == MemoryStatus::Tombstoned
136 && let Some(ts) = memory.tombstoned_at
137 && ts.timestamp() < threshold_i64
138 {
139 let memory_id = memory.id;
140 self.persistence.delete(&memory_id)?;
141 record_event(MemoryEvent::Deleted {
142 meta: EventMeta::new("tombstone", current_request_id()),
143 memory_id,
144 reason: "purge_tombstoned".to_string(),
145 });
146 purged += 1;
147 }
148 }
149
150 tracing::info!(
151 purged,
152 threshold,
153 older_than_secs = older_than.as_secs(),
154 "Purged tombstoned memories"
155 );
156
157 metrics::counter!("purge_tombstoned_total").increment(purged as u64);
158 Ok(purged)
159 }
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165 use crate::models::{Domain, Memory, Namespace};
166 use crate::storage::persistence::FilesystemBackend;
167 use tempfile::TempDir;
168
169 fn create_test_memory(id: &str) -> Memory {
170 Memory {
171 id: MemoryId::new(id),
172 content: "Test content".to_string(),
173 namespace: Namespace::Decisions,
174 domain: Domain::new(),
175 project_id: None,
176 branch: None,
177 file_path: None,
178 status: MemoryStatus::Active,
179 created_at: 1_000_000,
180 updated_at: 1_000_000,
181 tombstoned_at: None,
182 expires_at: None,
183 embedding: None,
184 tags: vec![],
185 #[cfg(feature = "group-scope")]
186 group_id: None,
187 source: None,
188 is_summary: false,
189 source_memory_ids: None,
190 consolidation_timestamp: None,
191 }
192 }
193
194 #[test]
195 fn test_tombstone_memory() {
196 let dir = TempDir::new().unwrap();
197 let backend = FilesystemBackend::new(dir.path());
198 let service = TombstoneService::new(Arc::new(backend));
199
200 let memory = create_test_memory("test-1");
202 service.persistence.store(&memory).unwrap();
203
204 service.tombstone_memory(&memory.id).unwrap();
206
207 let retrieved = service.persistence.get(&memory.id).unwrap().unwrap();
209 assert_eq!(retrieved.status, MemoryStatus::Tombstoned);
210 assert!(retrieved.tombstoned_at.is_some());
211 }
212
213 #[test]
214 fn test_untombstone_memory() {
215 let dir = TempDir::new().unwrap();
216 let backend = FilesystemBackend::new(dir.path());
217 let service = TombstoneService::new(Arc::new(backend));
218
219 let memory = create_test_memory("test-2");
221 service.persistence.store(&memory).unwrap();
222 service.tombstone_memory(&memory.id).unwrap();
223
224 service.untombstone_memory(&memory.id).unwrap();
226
227 let retrieved = service.persistence.get(&memory.id).unwrap().unwrap();
229 assert_eq!(retrieved.status, MemoryStatus::Active);
230 assert_eq!(retrieved.tombstoned_at, None);
231 }
232
233 #[test]
234 fn test_purge_tombstoned() {
235 let dir = TempDir::new().unwrap();
236 let backend = FilesystemBackend::new(dir.path());
237 let service = TombstoneService::new(Arc::new(backend));
238
239 let old_memory = Memory {
241 id: MemoryId::new("old"),
242 status: MemoryStatus::Tombstoned,
243 tombstoned_at: Some(Utc.timestamp_opt(100, 0).unwrap()), ..create_test_memory("old")
245 };
246
247 let recent_memory = Memory {
248 id: MemoryId::new("recent"),
249 status: MemoryStatus::Tombstoned,
250 tombstoned_at: Some(Utc::now() - chrono::Duration::seconds(1)),
251 ..create_test_memory("recent")
252 };
253
254 service.persistence.store(&old_memory).unwrap();
255 service.persistence.store(&recent_memory).unwrap();
256
257 let purged = service
259 .purge_tombstoned(Duration::from_secs(30 * 24 * 60 * 60))
260 .unwrap();
261
262 assert_eq!(purged, 1);
264 assert!(service.persistence.get(&old_memory.id).unwrap().is_none());
265 assert!(
266 service
267 .persistence
268 .get(&recent_memory.id)
269 .unwrap()
270 .is_some()
271 );
272 }
273}