Skip to main content

subcog/services/
tombstone.rs

1//! Tombstone operations for soft-delete functionality (ADR-0053).
2
3use 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
13/// Service for tombstone operations (soft deletes).
14pub struct TombstoneService {
15    persistence: Arc<dyn PersistenceBackend>,
16}
17
18impl TombstoneService {
19    /// Creates a new tombstone service.
20    #[must_use]
21    pub fn new(persistence: Arc<dyn PersistenceBackend>) -> Self {
22        Self { persistence }
23    }
24
25    /// Tombstones a memory (soft delete).
26    ///
27    /// Sets status to Tombstoned and records the timestamp.
28    ///
29    /// # Errors
30    ///
31    /// Returns an error if the memory cannot be found or updated.
32    #[instrument(skip(self), fields(memory_id = %id.as_str()))]
33    pub fn tombstone_memory(&self, id: &MemoryId) -> Result<()> {
34        // Get the current memory
35        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        // Set tombstone status and timestamp
44        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        // Update in persistence
56        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    /// Untombstones a memory (restore from soft delete).
75    ///
76    /// Sets status back to Active and clears the tombstone timestamp.
77    ///
78    /// # Errors
79    ///
80    /// Returns an error if the memory cannot be found or updated.
81    #[instrument(skip(self), fields(memory_id = %id.as_str()))]
82    pub fn untombstone_memory(&self, id: &MemoryId) -> Result<()> {
83        // Get the current memory
84        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        // Clear tombstone status and timestamp
93        memory.status = MemoryStatus::Active;
94        memory.tombstoned_at = None;
95        memory.updated_at = crate::current_timestamp();
96
97        // Update in persistence
98        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    /// Purges tombstoned memories older than the specified duration.
117    ///
118    /// Permanently deletes memories that have been tombstoned for longer
119    /// than the threshold.
120    ///
121    /// # Errors
122    ///
123    /// Returns an error if the deletion operation fails.
124    #[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        // List all memory IDs and check each
130        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        // Create and store a memory
201        let memory = create_test_memory("test-1");
202        service.persistence.store(&memory).unwrap();
203
204        // Tombstone it
205        service.tombstone_memory(&memory.id).unwrap();
206
207        // Verify status and timestamp
208        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        // Create, store, and tombstone
220        let memory = create_test_memory("test-2");
221        service.persistence.store(&memory).unwrap();
222        service.tombstone_memory(&memory.id).unwrap();
223
224        // Untombstone
225        service.untombstone_memory(&memory.id).unwrap();
226
227        // Verify status and timestamp cleared
228        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        // Create memories with different tombstone times
240        let old_memory = Memory {
241            id: MemoryId::new("old"),
242            status: MemoryStatus::Tombstoned,
243            tombstoned_at: Some(Utc.timestamp_opt(100, 0).unwrap()), // Very old
244            ..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        // Purge memories older than 30 days
258        let purged = service
259            .purge_tombstoned(Duration::from_secs(30 * 24 * 60 * 60))
260            .unwrap();
261
262        // Old should be purged, recent should remain
263        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}