Skip to main content

peat_mesh/storage/
ttl.rs

1//! TTL and Data Lifecycle Management
2//!
3//! Provides helpers for managing document lifecycle:
4//! - **Soft-delete pattern**: Avoids husking on high-churn data (beacons, positions)
5//! - **EVICT helpers**: Local storage cleanup for edge devices
6//! - **Tombstone TTL config**: Configure tombstone reaping interval
7//! - **Offline retention**: Connectivity-aware eviction policies
8//!
9//! ## Deletion Model
10//!
11//! Two deletion mechanisms:
12//!
13//! 1. **DELETE/EVICT**: Creates tombstones that sync mesh-wide and auto-reap after `TOMBSTONE_TTL_HOURS`
14//! 2. **EVICT (local)**: Removes documents from local storage only (no tombstone, may re-sync)
15//!
16//! ## Architectural Challenges
17//!
18//! ### Delete-Then-Disconnect Problem
19//!
20//! When a node deletes a document then goes offline, other nodes that were offline
21//! during the delete may resurrect the document when they come back online with stale data.
22//!
23//! **Solution**: Tombstones persist for `TOMBSTONE_TTL_HOURS`, ensuring deletions propagate
24//! even to nodes offline during the delete operation.
25//!
26//! ### Concurrent Delete-Update (Husking)
27//!
28//! When one node updates a field while another deletes the document, CRDT merge can
29//! create "husked documents" where updated fields exist but all others are null.
30//!
31//! **Solution**: Use soft-delete pattern for high-churn data (update `_deleted` flag instead
32//! of deleting).
33//!
34//! ## Usage
35//!
36//! ```ignore
37//! use peat_mesh::storage::ttl::{TtlConfig, EvictionStrategy};
38//! use std::time::Duration;
39//!
40//! // Configure for tactical operations
41//! let config = TtlConfig::tactical();
42//!
43//! // Or custom configuration
44//! let config = TtlConfig::new()
45//!     .with_beacon_ttl(Duration::from_secs(300))  // 5 min soft-delete
46//!     .with_eviction(EvictionStrategy::OldestFirst);
47//! ```
48
49use std::time::Duration;
50
51/// TTL configuration for data lifecycle management
52///
53/// Provides collection-specific TTLs and eviction strategies that coordinate
54/// with tombstone TTL-based reaping.
55#[derive(Debug, Clone)]
56pub struct TtlConfig {
57    /// Tombstone TTL (hours)
58    ///
59    /// Controls how long tombstones persist before being reaped.
60    pub tombstone_ttl_hours: u32,
61
62    /// Enable automatic tombstone reaping
63    pub tombstone_reaping_enabled: bool,
64
65    /// Days between tombstone reaping scans (default: 1)
66    pub days_between_reaping: u32,
67
68    /// Beacon soft-delete TTL (avoids husking)
69    ///
70    /// Beacons are high-churn data. Use soft-delete pattern to mark as deleted
71    /// rather than creating tombstones.
72    pub beacon_ttl: Duration,
73
74    /// Node position soft-delete TTL (avoids husking)
75    pub position_ttl: Duration,
76
77    /// Capability hard-delete TTL
78    ///
79    /// Capabilities are low-churn, coordinated updates. Safe to use hard delete (EVICT).
80    pub capability_ttl: Duration,
81
82    /// EVICT strategy for edge devices with storage constraints
83    pub evict_strategy: EvictionStrategy,
84
85    /// Offline retention policy (optional)
86    pub offline_policy: Option<OfflineRetentionPolicy>,
87}
88
89/// Eviction strategy when storage limits are reached or node is offline
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91#[allow(clippy::upper_case_acronyms)]
92pub enum EvictionStrategy {
93    /// Evict oldest documents first (by last_updated_at)
94    OldestFirst,
95
96    /// Evict based on storage pressure threshold
97    StoragePressure {
98        /// Percentage threshold (0-100) to trigger eviction
99        threshold_pct: u8,
100    },
101
102    /// Keep only last N documents per collection
103    KeepLastN(usize),
104
105    /// No automatic eviction
106    None,
107}
108
109/// Offline retention policy for connectivity-aware lifecycle
110///
111/// When a node is offline, it cannot contribute to the mesh anyway.
112/// More aggressive eviction saves storage and battery while preserving
113/// the ability to re-sync latest state from peers when reconnected.
114#[derive(Debug, Clone)]
115pub struct OfflineRetentionPolicy {
116    /// TTL when node is online (normal retention)
117    pub online_ttl: Duration,
118
119    /// TTL when node is offline (aggressive eviction)
120    ///
121    /// Typically 10x shorter than online_ttl to free resources
122    pub offline_ttl: Duration,
123
124    /// Always keep last N items per collection, even when offline
125    pub keep_last_n: usize,
126}
127
128impl TtlConfig {
129    /// Create a new TTL configuration with conservative defaults
130    pub fn new() -> Self {
131        Self {
132            tombstone_ttl_hours: 168, // 7 days (Edge SDK default)
133            tombstone_reaping_enabled: true,
134            days_between_reaping: 1,
135            beacon_ttl: Duration::from_secs(600),      // 10 min
136            position_ttl: Duration::from_secs(600),    // 10 min
137            capability_ttl: Duration::from_secs(7200), // 2 hours
138            evict_strategy: EvictionStrategy::None,
139            offline_policy: None,
140        }
141    }
142
143    /// Tactical operations preset (high-frequency updates, short TTLs)
144    ///
145    /// Optimized for:
146    /// - Rapid beacon discovery (5 min TTL)
147    /// - Frequent position updates (10 min TTL)
148    /// - Short-lived capabilities (2 hour TTL)
149    /// - Offline nodes keep only last 10 items
150    pub fn tactical() -> Self {
151        Self {
152            tombstone_ttl_hours: 168, // 7 days
153            tombstone_reaping_enabled: true,
154            days_between_reaping: 1,
155            beacon_ttl: Duration::from_secs(300), // 5 min soft-delete
156            position_ttl: Duration::from_secs(600), // 10 min soft-delete
157            capability_ttl: Duration::from_secs(7200), // 2 hours
158            evict_strategy: EvictionStrategy::OldestFirst,
159            offline_policy: Some(OfflineRetentionPolicy {
160                online_ttl: Duration::from_secs(600), // 10 min
161                offline_ttl: Duration::from_secs(60), // 1 min when offline
162                keep_last_n: 10,
163            }),
164        }
165    }
166
167    /// Long-duration operations preset (ISR, surveillance)
168    ///
169    /// Optimized for:
170    /// - Longer beacon retention (10 min)
171    /// - Extended position history (1 hour)
172    /// - Long-lived capabilities (48 hours)
173    pub fn long_duration() -> Self {
174        Self {
175            tombstone_ttl_hours: 168, // 7 days
176            tombstone_reaping_enabled: true,
177            days_between_reaping: 1,
178            beacon_ttl: Duration::from_secs(600),    // 10 min
179            position_ttl: Duration::from_secs(3600), // 1 hour
180            capability_ttl: Duration::from_secs(172800), // 48 hours
181            evict_strategy: EvictionStrategy::StoragePressure { threshold_pct: 80 },
182            offline_policy: None, // No special offline handling
183        }
184    }
185
186    /// Offline/storage-constrained node preset
187    ///
188    /// Optimized for:
189    /// - Minimal storage footprint
190    /// - Aggressive local eviction (EVICT, not DELETE)
191    /// - Short tombstone TTL (3 days instead of 7)
192    pub fn offline_node() -> Self {
193        Self {
194            tombstone_ttl_hours: 72, // 3 days (shorter for storage-constrained edge)
195            tombstone_reaping_enabled: true,
196            days_between_reaping: 1,
197            beacon_ttl: Duration::from_secs(30),   // 30 sec EVICT
198            position_ttl: Duration::from_secs(60), // 1 min EVICT
199            capability_ttl: Duration::from_secs(300), // 5 min EVICT
200            evict_strategy: EvictionStrategy::KeepLastN(10),
201            offline_policy: Some(OfflineRetentionPolicy {
202                online_ttl: Duration::from_secs(300), // 5 min
203                offline_ttl: Duration::from_secs(30), // 30 sec when offline
204                keep_last_n: 5,                       // Minimal retention
205            }),
206        }
207    }
208
209    /// Set beacon soft-delete TTL
210    pub fn with_beacon_ttl(mut self, ttl: Duration) -> Self {
211        self.beacon_ttl = ttl;
212        self
213    }
214
215    /// Set position soft-delete TTL
216    pub fn with_position_ttl(mut self, ttl: Duration) -> Self {
217        self.position_ttl = ttl;
218        self
219    }
220
221    /// Set capability hard-delete TTL
222    pub fn with_capability_ttl(mut self, ttl: Duration) -> Self {
223        self.capability_ttl = ttl;
224        self
225    }
226
227    /// Set eviction strategy
228    pub fn with_eviction(mut self, strategy: EvictionStrategy) -> Self {
229        self.evict_strategy = strategy;
230        self
231    }
232
233    /// Set offline retention policy
234    pub fn with_offline_policy(mut self, policy: OfflineRetentionPolicy) -> Self {
235        self.offline_policy = Some(policy);
236        self
237    }
238
239    /// Set tombstone TTL (hours)
240    pub fn with_tombstone_ttl(mut self, hours: u32) -> Self {
241        self.tombstone_ttl_hours = hours;
242        self
243    }
244
245    /// Get TTL for a specific collection
246    ///
247    /// Returns the appropriate TTL based on collection name and current configuration.
248    pub fn get_collection_ttl(&self, collection: &str) -> Option<Duration> {
249        match collection {
250            "beacons" => Some(self.beacon_ttl),
251            "node_positions" => Some(self.position_ttl),
252            "capabilities" => Some(self.capability_ttl),
253            "hierarchical_commands" => None, // Manual expiration via TimeoutManager
254            "cells" => Some(Duration::from_secs(3600)), // 1 hour
255            _ => None,
256        }
257    }
258}
259
260impl Default for TtlConfig {
261    fn default() -> Self {
262        Self::new()
263    }
264}
265
266impl OfflineRetentionPolicy {
267    /// Create a minimal retention policy for storage-constrained devices
268    pub fn minimal() -> Self {
269        Self {
270            online_ttl: Duration::from_secs(300), // 5 min
271            offline_ttl: Duration::from_secs(30), // 30 sec
272            keep_last_n: 5,
273        }
274    }
275
276    /// Create a conservative retention policy
277    pub fn conservative() -> Self {
278        Self {
279            online_ttl: Duration::from_secs(3600), // 1 hour
280            offline_ttl: Duration::from_secs(300), // 5 min
281            keep_last_n: 20,
282        }
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn test_tactical_preset() {
292        let config = TtlConfig::tactical();
293
294        assert_eq!(config.tombstone_ttl_hours, 168); // 7 days
295        assert_eq!(config.beacon_ttl, Duration::from_secs(300)); // 5 min
296        assert_eq!(config.position_ttl, Duration::from_secs(600)); // 10 min
297        assert!(config.offline_policy.is_some());
298
299        let offline = config.offline_policy.unwrap();
300        assert_eq!(offline.keep_last_n, 10);
301    }
302
303    #[test]
304    fn test_long_duration_preset() {
305        let config = TtlConfig::long_duration();
306
307        assert_eq!(config.beacon_ttl, Duration::from_secs(600)); // 10 min
308        assert_eq!(config.position_ttl, Duration::from_secs(3600)); // 1 hour
309        assert_eq!(config.capability_ttl, Duration::from_secs(172800)); // 48 hours
310        assert!(config.offline_policy.is_none());
311    }
312
313    #[test]
314    fn test_offline_node_preset() {
315        let config = TtlConfig::offline_node();
316
317        assert_eq!(config.tombstone_ttl_hours, 72); // 3 days (shorter)
318        assert_eq!(config.beacon_ttl, Duration::from_secs(30)); // 30 sec
319        assert_eq!(config.position_ttl, Duration::from_secs(60)); // 1 min
320
321        match config.evict_strategy {
322            EvictionStrategy::KeepLastN(n) => assert_eq!(n, 10),
323            _ => panic!("Expected KeepLastN strategy"),
324        }
325    }
326
327    #[test]
328    fn test_collection_ttl_lookup() {
329        let config = TtlConfig::tactical();
330
331        assert_eq!(
332            config.get_collection_ttl("beacons"),
333            Some(Duration::from_secs(300))
334        );
335        assert_eq!(
336            config.get_collection_ttl("node_positions"),
337            Some(Duration::from_secs(600))
338        );
339        assert_eq!(config.get_collection_ttl("hierarchical_commands"), None);
340    }
341
342    #[test]
343    fn test_builder_pattern() {
344        let config = TtlConfig::new()
345            .with_beacon_ttl(Duration::from_secs(120))
346            .with_eviction(EvictionStrategy::OldestFirst)
347            .with_tombstone_ttl(96); // 4 days
348
349        assert_eq!(config.beacon_ttl, Duration::from_secs(120));
350        assert_eq!(config.tombstone_ttl_hours, 96);
351        assert!(matches!(
352            config.evict_strategy,
353            EvictionStrategy::OldestFirst
354        ));
355    }
356
357    #[test]
358    fn test_offline_retention_presets() {
359        let minimal = OfflineRetentionPolicy::minimal();
360        assert_eq!(minimal.offline_ttl, Duration::from_secs(30));
361        assert_eq!(minimal.keep_last_n, 5);
362
363        let conservative = OfflineRetentionPolicy::conservative();
364        assert_eq!(conservative.online_ttl, Duration::from_secs(3600));
365        assert_eq!(conservative.keep_last_n, 20);
366    }
367
368    #[test]
369    fn test_eviction_strategy_variants() {
370        let oldest = EvictionStrategy::OldestFirst;
371        let pressure = EvictionStrategy::StoragePressure { threshold_pct: 80 };
372        let keep_n = EvictionStrategy::KeepLastN(100);
373        let none = EvictionStrategy::None;
374
375        assert!(matches!(oldest, EvictionStrategy::OldestFirst));
376        assert!(matches!(
377            pressure,
378            EvictionStrategy::StoragePressure { threshold_pct: 80 }
379        ));
380        assert!(matches!(keep_n, EvictionStrategy::KeepLastN(100)));
381        assert!(matches!(none, EvictionStrategy::None));
382    }
383
384    #[test]
385    fn test_tombstone_ttl_edge_constraint() {
386        // Edge SDK should never exceed server TTL
387        let edge_config = TtlConfig::tactical();
388        assert!(edge_config.tombstone_ttl_hours <= 720); // 30 days server default
389
390        // Offline node uses shorter TTL for storage constraints
391        let offline_config = TtlConfig::offline_node();
392        assert!(offline_config.tombstone_ttl_hours < edge_config.tombstone_ttl_hours);
393    }
394}