spatio/
config.rs

1//! Configuration and database settings for Spatio
2//!
3//! This module provides configuration types and re-exports spatial types
4//! from the `spatio-types` crate for convenience.
5use bytes::Bytes;
6use serde::de::Error;
7use serde::{Deserialize, Serialize};
8use std::time::{Duration, SystemTime};
9
10pub use spatio_types::bbox::{
11    BoundingBox2D, BoundingBox3D, TemporalBoundingBox2D, TemporalBoundingBox3D,
12};
13pub use spatio_types::point::{Point3d, TemporalPoint, TemporalPoint3D};
14pub use spatio_types::polygon::{Polygon3D, PolygonDynamic, PolygonDynamic3D};
15pub use spatio_types::trajectory::{Trajectory, Trajectory3D};
16
17/// Synchronization policy for persistence.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
19#[serde(rename_all = "snake_case")]
20pub enum SyncPolicy {
21    Never,
22    #[default]
23    EverySecond,
24    Always,
25}
26
27/// File synchronization strategy (fsync vs fdatasync).
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
29#[serde(rename_all = "snake_case")]
30pub enum SyncMode {
31    #[default]
32    All,
33    Data,
34}
35
36/// Database configuration
37#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
38#[serde(deny_unknown_fields)]
39pub struct Config {
40    #[serde(default = "Config::default_sync_policy")]
41    pub sync_policy: SyncPolicy,
42
43    #[serde(default)]
44    pub default_ttl_seconds: Option<f64>,
45
46    #[serde(default)]
47    pub sync_mode: SyncMode,
48
49    #[serde(default = "Config::default_sync_batch_size")]
50    pub sync_batch_size: usize,
51
52    #[cfg(feature = "time-index")]
53    #[serde(default)]
54    pub history_capacity: Option<usize>,
55
56    #[cfg(feature = "snapshot")]
57    #[serde(default)]
58    pub snapshot_auto_ops: Option<usize>,
59}
60
61impl Config {
62    const fn default_sync_batch_size() -> usize {
63        1
64    }
65
66    const fn default_sync_policy() -> SyncPolicy {
67        SyncPolicy::EverySecond
68    }
69
70    #[cfg(feature = "snapshot")]
71    pub fn with_snapshot_auto_ops(mut self, ops: usize) -> Self {
72        self.snapshot_auto_ops = Some(ops);
73        self
74    }
75
76    pub fn with_default_ttl(mut self, ttl: Duration) -> Self {
77        let ttl_secs = ttl.as_secs();
78
79        if ttl_secs > 365 * 24 * 3600 {
80            log::warn!(
81                "TTL of {} days is very large. This may indicate a misconfiguration.",
82                ttl_secs / (24 * 3600)
83            );
84        } else if ttl_secs < 60 {
85            log::warn!(
86                "TTL of {} seconds is very short. Consider if this is intentional.",
87                ttl_secs
88            );
89        }
90
91        self.default_ttl_seconds = Some(ttl.as_secs_f64());
92        self
93    }
94
95    pub fn with_sync_policy(mut self, policy: SyncPolicy) -> Self {
96        self.sync_policy = policy;
97        self
98    }
99
100    pub fn with_sync_mode(mut self, mode: SyncMode) -> Self {
101        self.sync_mode = mode;
102        self
103    }
104
105    pub fn with_sync_batch_size(mut self, batch_size: usize) -> Self {
106        assert!(batch_size > 0, "Sync batch size must be greater than zero");
107        self.sync_batch_size = batch_size;
108        self
109    }
110
111    #[cfg(feature = "time-index")]
112    pub fn with_history_capacity(mut self, capacity: usize) -> Self {
113        assert!(capacity > 0, "History capacity must be greater than zero");
114
115        if capacity > 100_000 {
116            log::warn!(
117                "History capacity of {} is very large and may consume significant memory. \
118                Each entry stores key + value + timestamp.",
119                capacity
120            );
121        }
122
123        self.history_capacity = Some(capacity);
124        self
125    }
126
127    pub fn default_ttl(&self) -> Option<Duration> {
128        self.default_ttl_seconds.and_then(|ttl| {
129            if ttl.is_finite() && ttl > 0.0 && ttl <= u64::MAX as f64 {
130                Some(Duration::from_secs_f64(ttl))
131            } else {
132                None
133            }
134        })
135    }
136
137    pub fn validate(&self) -> Result<(), String> {
138        if let Some(ttl) = self.default_ttl_seconds {
139            if !ttl.is_finite() {
140                return Err("Default TTL must be finite (not NaN or infinity)".to_string());
141            }
142            if ttl <= 0.0 {
143                return Err("Default TTL must be positive".to_string());
144            }
145            if ttl > u64::MAX as f64 {
146                return Err("Default TTL is too large".to_string());
147            }
148        }
149
150        #[cfg(feature = "time-index")]
151        if let Some(capacity) = self.history_capacity
152            && capacity == 0
153        {
154            return Err("History capacity must be greater than zero".to_string());
155        }
156
157        if self.sync_batch_size == 0 {
158            return Err("Sync batch size must be greater than zero".to_string());
159        }
160
161        Ok(())
162    }
163
164    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
165        let config: Config = serde_json::from_str(json)?;
166        if let Err(e) = config.validate() {
167            return Err(Error::custom(e));
168        }
169        Ok(config)
170    }
171
172    pub fn to_json(&self) -> Result<String, serde_json::Error> {
173        serde_json::to_string_pretty(self)
174    }
175
176    #[cfg(feature = "toml")]
177    pub fn from_toml(toml_str: &str) -> Result<Self, toml::de::Error> {
178        let config: Config = toml::from_str(toml_str)?;
179        if let Err(e) = config.validate() {
180            return Err(toml::de::Error::custom(e));
181        }
182        Ok(config)
183    }
184
185    #[cfg(feature = "toml")]
186    pub fn to_toml(&self) -> Result<String, toml::ser::Error> {
187        toml::to_string_pretty(self)
188    }
189}
190
191impl Default for Config {
192    fn default() -> Self {
193        Self {
194            sync_policy: SyncPolicy::default(),
195            default_ttl_seconds: None,
196            sync_mode: SyncMode::default(),
197            sync_batch_size: Self::default_sync_batch_size(),
198            #[cfg(feature = "time-index")]
199            history_capacity: None,
200            #[cfg(feature = "snapshot")]
201            snapshot_auto_ops: None,
202        }
203    }
204}
205
206/// Options for setting values with TTL.
207///
208/// TTL is **lazy/passive**: expired items are filtered on read operations
209/// (`get()`, spatial queries) but remain in storage until manually cleaned up
210/// with `cleanup_expired()` or overwritten.
211#[derive(Debug, Clone, Default)]
212pub struct SetOptions {
213    /// Time-to-live for this item
214    pub ttl: Option<Duration>,
215    /// Absolute expiration time (takes precedence over TTL)
216    pub expires_at: Option<SystemTime>,
217}
218
219impl SetOptions {
220    /// Create options with TTL (time-to-live).
221    ///
222    /// # Important: Manual Cleanup Required
223    ///
224    /// Expired items are treated as non-existent on reads (passive expiration),
225    /// but they remain in memory and storage until either:
226    ///
227    /// 1. Overwritten by a new value with the same key
228    /// 2. Manually cleaned with `db.cleanup_expired()`
229    /// 3. Database is restarted (snapshot won't restore expired items)
230    ///
231    /// **For production systems with TTL**, you MUST periodically call `cleanup_expired()`
232    /// to prevent unbounded memory growth.
233    pub fn with_ttl(ttl: Duration) -> Self {
234        Self {
235            ttl: Some(ttl),
236            expires_at: None,
237        }
238    }
239
240    /// Create options with absolute expiration time.
241    ///
242    /// # Important: Manual Cleanup Required
243    ///
244    /// Like `with_ttl()`, expired items remain in storage until manually cleaned.
245    /// See `with_ttl()` documentation for cleanup requirements.
246    pub fn with_expiration(expires_at: SystemTime) -> Self {
247        Self {
248            ttl: None,
249            expires_at: Some(expires_at),
250        }
251    }
252
253    /// Get the effective expiration time
254    pub fn effective_expires_at(&self) -> Option<SystemTime> {
255        self.expires_at
256            .or_else(|| self.ttl.map(|ttl| SystemTime::now() + ttl))
257    }
258}
259
260/// Internal representation of a database item.
261///
262/// Note: Items with expired `expires_at` are not automatically deleted.
263/// They are filtered out during reads and can be removed with `cleanup_expired()`.
264#[derive(Debug, Clone)]
265pub struct DbItem {
266    /// The value bytes
267    pub value: Bytes,
268    pub created_at: SystemTime,
269    /// Expiration time (if any). Item is considered expired when SystemTime::now() >= expires_at.
270    pub expires_at: Option<SystemTime>,
271}
272
273/// Operation types captured in history tracking.
274#[derive(Debug, Clone, PartialEq, Eq)]
275pub enum HistoryEventKind {
276    Set,
277    Delete,
278}
279
280/// Historical record for key mutations.
281#[derive(Debug, Clone)]
282pub struct HistoryEntry {
283    pub timestamp: SystemTime,
284    pub kind: HistoryEventKind,
285    pub value: Option<Bytes>,
286    pub expires_at: Option<SystemTime>,
287}
288
289impl DbItem {
290    /// Create a new item without expiration
291    pub fn new(value: impl Into<Bytes>) -> Self {
292        Self {
293            value: value.into(),
294            created_at: SystemTime::now(),
295            expires_at: None,
296        }
297    }
298
299    /// Create an item with absolute expiration
300    pub fn with_expiration(value: impl Into<Bytes>, expires_at: SystemTime) -> Self {
301        Self {
302            value: value.into(),
303            created_at: SystemTime::now(),
304            expires_at: Some(expires_at),
305        }
306    }
307
308    /// Create an item with TTL
309    pub fn with_ttl(value: impl Into<Bytes>, ttl: Duration) -> Self {
310        let expires_at = SystemTime::now() + ttl;
311        Self::with_expiration(value, expires_at)
312    }
313
314    /// Create from SetOptions
315    pub fn from_options(value: impl Into<Bytes>, options: Option<&SetOptions>) -> Self {
316        let value = value.into();
317
318        match options {
319            Some(opts) => {
320                let expires_at = opts.effective_expires_at();
321                Self {
322                    value,
323                    created_at: SystemTime::now(),
324                    expires_at,
325                }
326            }
327            None => Self::new(value),
328        }
329    }
330
331    pub fn is_expired(&self) -> bool {
332        self.is_expired_at(SystemTime::now())
333    }
334
335    /// Check if this item has expired at a specific time
336    pub fn is_expired_at(&self, now: SystemTime) -> bool {
337        match self.expires_at {
338            Some(expires_at) => now >= expires_at,
339            None => false,
340        }
341    }
342
343    /// Get remaining TTL
344    pub fn remaining_ttl(&self) -> Option<Duration> {
345        self.remaining_ttl_at(SystemTime::now())
346    }
347
348    /// Get remaining TTL at a specific time
349    pub fn remaining_ttl_at(&self, now: SystemTime) -> Option<Duration> {
350        match self.expires_at {
351            Some(expires_at) => {
352                if now < expires_at {
353                    expires_at.duration_since(now).ok()
354                } else {
355                    Some(Duration::ZERO)
356                }
357            }
358            None => None,
359        }
360    }
361}
362
363/// Database statistics
364#[derive(Debug, Clone, Default, Serialize, Deserialize)]
365pub struct DbStats {
366    /// Number of keys in the database
367    pub key_count: usize,
368    /// Number of items that have expired
369    pub expired_count: u64,
370    /// Total number of operations performed
371    pub operations_count: u64,
372    /// Total size in bytes (approximate)
373    pub size_bytes: usize,
374}
375
376impl DbStats {
377    /// Create new empty statistics
378    pub fn new() -> Self {
379        Self::default()
380    }
381
382    /// Record an operation
383    pub fn record_operation(&mut self) {
384        self.operations_count += 1;
385    }
386
387    /// Record expired items cleanup
388    pub fn record_expired(&mut self, count: u64) {
389        self.expired_count += count;
390    }
391
392    /// Update key count
393    pub fn set_key_count(&mut self, count: usize) {
394        self.key_count = count;
395    }
396
397    /// Update size estimate
398    pub fn set_size_bytes(&mut self, bytes: usize) {
399        self.size_bytes = bytes;
400    }
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406    use std::time::Duration;
407
408    #[test]
409    fn test_config_default() {
410        let config = Config::default();
411        assert_eq!(config.sync_policy, SyncPolicy::EverySecond);
412        assert_eq!(config.sync_mode, SyncMode::All);
413        assert_eq!(config.sync_batch_size, 1);
414        assert!(config.default_ttl_seconds.is_none());
415        #[cfg(feature = "time-index")]
416        assert!(config.history_capacity.is_none());
417    }
418
419    #[test]
420    fn test_config_serialization() {
421        let config = Config::default()
422            .with_default_ttl(Duration::from_secs(3600))
423            .with_sync_policy(SyncPolicy::Always)
424            .with_sync_mode(SyncMode::Data)
425            .with_sync_batch_size(8);
426
427        let json = config.to_json().unwrap();
428        let deserialized: Config = Config::from_json(&json).unwrap();
429
430        assert_eq!(deserialized.sync_policy, SyncPolicy::Always);
431        assert_eq!(deserialized.sync_mode, SyncMode::Data);
432        assert_eq!(deserialized.sync_batch_size, 8);
433        assert_eq!(
434            deserialized.default_ttl().unwrap(),
435            Duration::from_secs(3600)
436        );
437    }
438
439    #[cfg(feature = "time-index")]
440    #[test]
441    fn test_config_history_capacity() {
442        let config = Config::default().with_history_capacity(5);
443        assert_eq!(config.history_capacity, Some(5));
444    }
445
446    #[test]
447    fn test_set_options() {
448        let ttl_opts = SetOptions::with_ttl(Duration::from_secs(60));
449        assert!(ttl_opts.ttl.is_some());
450        assert!(ttl_opts.expires_at.is_none());
451
452        let exp_opts = SetOptions::with_expiration(SystemTime::now());
453        assert!(exp_opts.ttl.is_none());
454        assert!(exp_opts.expires_at.is_some());
455    }
456
457    #[test]
458    fn test_db_item_expiration() {
459        let item = DbItem::new("test");
460        assert!(!item.is_expired());
461
462        let past = SystemTime::now() - Duration::from_secs(60);
463        let expired_item = DbItem::with_expiration("test", past);
464        assert!(expired_item.is_expired());
465
466        let future = SystemTime::now() + Duration::from_secs(60);
467        let future_item = DbItem::with_expiration("test", future);
468        assert!(!future_item.is_expired());
469    }
470
471    #[test]
472    fn test_db_item_ttl() {
473        let item = DbItem::with_ttl("test", Duration::from_secs(60));
474        let remaining = item.remaining_ttl().unwrap();
475
476        // Should be close to 60 seconds (allowing for small timing differences)
477        assert!(remaining.as_secs() >= 59 && remaining.as_secs() <= 60);
478    }
479
480    #[test]
481    fn test_db_item_from_options() {
482        let opts = SetOptions::with_ttl(Duration::from_secs(300));
483        let item = DbItem::from_options("test", Some(&opts));
484
485        assert!(item.expires_at.is_some());
486        assert!(!item.is_expired());
487    }
488
489    #[test]
490    fn test_db_stats() {
491        let mut stats = DbStats::new();
492        assert_eq!(stats.operations_count, 0);
493
494        stats.record_operation();
495        assert_eq!(stats.operations_count, 1);
496
497        stats.record_expired(5);
498        assert_eq!(stats.expired_count, 5);
499
500        stats.set_key_count(100);
501        assert_eq!(stats.key_count, 100);
502    }
503
504    #[test]
505    fn test_config_validation() {
506        let config = Config::default();
507        assert!(config.validate().is_ok());
508
509        let config = Config {
510            default_ttl_seconds: Some(-1.0),
511            ..Default::default()
512        };
513        assert!(config.validate().is_err());
514    }
515
516    #[test]
517    fn test_config_ttl_validation() {
518        let mut config = Config::default();
519        assert!(config.validate().is_ok());
520
521        // Valid TTL
522        config = Config {
523            default_ttl_seconds: Some(60.0),
524            ..Default::default()
525        };
526        assert!(config.validate().is_ok());
527
528        // Negative TTL
529        config.default_ttl_seconds = Some(-1.0);
530        assert!(config.validate().is_err());
531
532        // Zero TTL
533        config.default_ttl_seconds = Some(0.0);
534        assert!(config.validate().is_err());
535
536        // NaN TTL
537        config.default_ttl_seconds = Some(f64::NAN);
538        assert!(config.validate().is_err());
539
540        // Positive infinity TTL
541        config.default_ttl_seconds = Some(f64::INFINITY);
542        assert!(config.validate().is_err());
543
544        // Negative infinity TTL
545        config.default_ttl_seconds = Some(f64::NEG_INFINITY);
546        assert!(config.validate().is_err());
547
548        // Too large TTL (use 1e20 which is definitely larger than u64::MAX as f64)
549        config.default_ttl_seconds = Some(1e20);
550        assert!(config.validate().is_err());
551    }
552
553    #[test]
554    fn test_config_default_ttl_safe_conversion() {
555        let mut config = Config {
556            default_ttl_seconds: Some(60.0),
557            ..Default::default()
558        };
559
560        // Valid TTL should convert successfully
561        assert!(config.default_ttl().is_some());
562
563        // NaN should return None (safe fallback)
564        config.default_ttl_seconds = Some(f64::NAN);
565        assert!(config.default_ttl().is_none());
566
567        // Infinity should return None (safe fallback)
568        config.default_ttl_seconds = Some(f64::INFINITY);
569        assert!(config.default_ttl().is_none());
570
571        // Negative values should return None (safe fallback)
572        config.default_ttl_seconds = Some(-1.0);
573        assert!(config.default_ttl().is_none());
574
575        // Too large values should return None (safe fallback)
576        config.default_ttl_seconds = Some(1e20);
577        assert!(config.default_ttl().is_none());
578
579        // Zero should return None (safe fallback)
580        config.default_ttl_seconds = Some(0.0);
581        assert!(config.default_ttl().is_none());
582    }
583}