Skip to main content

dol/
sqlite_store.rs

1//! SQLite-backed telemetry store.
2//!
3//! Implements [`TelemetryStore`] using an embedded SQLite database.
4//! Persists metrics, events, snapshots, and alert history with
5//! configurable retention policies. Supports time-range queries and
6//! automatic purging of old data.
7//!
8//! # Example
9//!
10//! ```ignore
11//! let store = SqliteTelemetryStore::open("./dol.db")?;
12//! store.write_metric(sample)?;
13//! let latest = store.latest_metrics()?;
14//! ```
15
16use std::path::Path;
17
18use rusqlite::{Connection, params};
19use serde_json;
20
21use crate::{
22    docker::{DockerEvent, MetricSample},
23    storage::{TelemetryError, TelemetrySnapshot, TelemetryStore},
24};
25
26/// Retention policy controlling how long historical data is kept.
27#[derive(Debug, Clone)]
28pub struct RetentionPolicy {
29    /// Maximum age of metric samples in seconds.
30    pub metric_max_age_secs: u64,
31    /// Maximum age of events in seconds.
32    pub event_max_age_secs: u64,
33    /// Maximum age of snapshots in seconds.
34    pub snapshot_max_age_secs: u64,
35}
36
37impl Default for RetentionPolicy {
38    fn default() -> Self {
39        Self {
40            metric_max_age_secs: 7 * 24 * 3600,    // 7 days
41            event_max_age_secs: 30 * 24 * 3600,    // 30 days
42            snapshot_max_age_secs: 30 * 24 * 3600, // 30 days
43        }
44    }
45}
46
47/// A `TelemetryStore` backed by an embedded `SQLite` database.
48///
49/// All data (metrics, events, snapshots) is persisted to a single `SQLite` file
50/// and can be queried by timestamp range. A configurable `RetentionPolicy`
51/// controls automatic purging of old data.
52pub struct SqliteTelemetryStore {
53    conn: Connection,
54    retention: RetentionPolicy,
55}
56
57impl SqliteTelemetryStore {
58    /// Open (or create) a `SQLite` telemetry store at the given path.
59    pub fn open(path: impl AsRef<Path>) -> Result<Self, TelemetryError> {
60        Self::open_with_retention(path, RetentionPolicy::default())
61    }
62
63    /// Open with a custom retention policy.
64    pub fn open_with_retention(
65        path: impl AsRef<Path>,
66        retention: RetentionPolicy,
67    ) -> Result<Self, TelemetryError> {
68        let conn = Connection::open(path).map_err(|e| TelemetryError::Storage(e.to_string()))?;
69        let store = Self { conn, retention };
70        store.init_schema()?;
71        Ok(store)
72    }
73
74    /// Create an in-memory `SQLite` store (useful for testing).
75    pub fn in_memory() -> Result<Self, TelemetryError> {
76        let conn =
77            Connection::open_in_memory().map_err(|e| TelemetryError::Storage(e.to_string()))?;
78        let store = Self {
79            conn,
80            retention: RetentionPolicy::default(),
81        };
82        store.init_schema()?;
83        Ok(store)
84    }
85
86    /// Set the retention policy.
87    pub const fn set_retention(&mut self, retention: RetentionPolicy) {
88        self.retention = retention;
89    }
90
91    /// Apply the retention policy, deleting records older than the configured thresholds.
92    pub fn apply_retention(&mut self) -> Result<RetentionStats, TelemetryError> {
93        let now = chrono::Utc::now();
94        let metric_cutoff =
95            now - chrono::Duration::seconds(self.retention.metric_max_age_secs as i64);
96        let event_cutoff =
97            now - chrono::Duration::seconds(self.retention.event_max_age_secs as i64);
98        let snapshot_cutoff =
99            now - chrono::Duration::seconds(self.retention.snapshot_max_age_secs as i64);
100
101        let metric_cutoff_str = metric_cutoff.to_rfc3339();
102        let event_cutoff_str = event_cutoff.to_rfc3339();
103        let snapshot_cutoff_str = snapshot_cutoff.to_rfc3339();
104
105        let metrics_deleted = self
106            .conn
107            .execute(
108                "DELETE FROM metrics WHERE timestamp < ?1",
109                params![metric_cutoff_str],
110            )
111            .map_err(|e| TelemetryError::Storage(e.to_string()))?;
112
113        let events_deleted = self
114            .conn
115            .execute(
116                "DELETE FROM events WHERE time < ?1",
117                params![event_cutoff_str],
118            )
119            .map_err(|e| TelemetryError::Storage(e.to_string()))?;
120
121        let snapshots_deleted = self
122            .conn
123            .execute(
124                "DELETE FROM snapshots WHERE timestamp < ?1",
125                params![snapshot_cutoff_str],
126            )
127            .map_err(|e| TelemetryError::Storage(e.to_string()))?;
128
129        Ok(RetentionStats {
130            metrics_deleted,
131            events_deleted,
132            snapshots_deleted,
133        })
134    }
135
136    /// Return database statistics.
137    pub fn stats(&self) -> Result<StoreStats, TelemetryError> {
138        let metric_count: i64 = self
139            .conn
140            .query_row("SELECT COUNT(*) FROM metrics", [], |row| row.get(0))
141            .map_err(|e| TelemetryError::Storage(e.to_string()))?;
142        let event_count: i64 = self
143            .conn
144            .query_row("SELECT COUNT(*) FROM events", [], |row| row.get(0))
145            .map_err(|e| TelemetryError::Storage(e.to_string()))?;
146        let snapshot_count: i64 = self
147            .conn
148            .query_row("SELECT COUNT(*) FROM snapshots", [], |row| row.get(0))
149            .map_err(|e| TelemetryError::Storage(e.to_string()))?;
150
151        Ok(StoreStats {
152            metric_count: metric_count as u64,
153            event_count: event_count as u64,
154            snapshot_count: snapshot_count as u64,
155        })
156    }
157
158    fn init_schema(&self) -> Result<(), TelemetryError> {
159        self.conn
160            .execute_batch(
161                "
162            CREATE TABLE IF NOT EXISTS metrics (
163                id              INTEGER PRIMARY KEY AUTOINCREMENT,
164                container_id    TEXT NOT NULL,
165                container_name  TEXT NOT NULL,
166                timestamp       TEXT NOT NULL,
167                cpu_percent     REAL,
168                memory_usage    INTEGER,
169                memory_limit    INTEGER,
170                network_rx      INTEGER,
171                network_tx      INTEGER,
172                disk_read       INTEGER,
173                disk_write      INTEGER
174            );
175
176            CREATE INDEX IF NOT EXISTS idx_metrics_timestamp ON metrics(timestamp);
177            CREATE INDEX IF NOT EXISTS idx_metrics_container ON metrics(container_id);
178
179            CREATE TABLE IF NOT EXISTS events (
180                id          INTEGER PRIMARY KEY AUTOINCREMENT,
181                time        TEXT NOT NULL,
182                event_type  TEXT NOT NULL,
183                action      TEXT NOT NULL,
184                actor_id    TEXT NOT NULL,
185                container   TEXT,
186                image       TEXT,
187                attributes  TEXT NOT NULL
188            );
189
190            CREATE INDEX IF NOT EXISTS idx_events_time ON events(time);
191            CREATE INDEX IF NOT EXISTS idx_events_action ON events(action);
192
193            CREATE TABLE IF NOT EXISTS snapshots (
194                id          INTEGER PRIMARY KEY AUTOINCREMENT,
195                timestamp   TEXT NOT NULL,
196                data        TEXT NOT NULL
197            );
198
199            CREATE INDEX IF NOT EXISTS idx_snapshots_timestamp ON snapshots(timestamp);
200
201            CREATE TABLE IF NOT EXISTS alert_history (
202                id              INTEGER PRIMARY KEY AUTOINCREMENT,
203                timestamp       TEXT NOT NULL,
204                container_id    TEXT NOT NULL,
205                container_name  TEXT NOT NULL,
206                rule_condition  TEXT NOT NULL,
207                action_type     TEXT NOT NULL,
208                action_detail   TEXT NOT NULL,
209                success         INTEGER NOT NULL DEFAULT 1
210            );
211
212            CREATE INDEX IF NOT EXISTS idx_alert_history_time ON alert_history(timestamp);
213            CREATE INDEX IF NOT EXISTS idx_alert_history_container ON alert_history(container_id);
214            ",
215            )
216            .map_err(|e| TelemetryError::Storage(e.to_string()))?;
217        Ok(())
218    }
219}
220
221impl TelemetryStore for SqliteTelemetryStore {
222    fn write_metric(&mut self, sample: MetricSample) -> Result<(), TelemetryError> {
223        self.conn
224            .execute(
225                "INSERT INTO metrics (container_id, container_name, timestamp, cpu_percent, memory_usage, memory_limit, network_rx, network_tx, disk_read, disk_write)
226                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
227                params![
228                    sample.container_id,
229                    sample.container_name,
230                    sample.timestamp,
231                    sample.cpu_percent,
232                    sample.memory_usage_bytes.map(|v| v as i64),
233                    sample.memory_limit_bytes.map(|v| v as i64),
234                    sample.network_rx_bytes.map(|v| v as i64),
235                    sample.network_tx_bytes.map(|v| v as i64),
236                    sample.disk_read_bytes.map(|v| v as i64),
237                    sample.disk_write_bytes.map(|v| v as i64),
238                ],
239            )
240            .map_err(|e| TelemetryError::Storage(e.to_string()))?;
241        Ok(())
242    }
243
244    fn latest_metrics(&self) -> Result<Vec<MetricSample>, TelemetryError> {
245        let mut stmt = self
246            .conn
247            .prepare(
248                "SELECT m.container_id, m.container_name, m.timestamp,
249                        m.cpu_percent, m.memory_usage, m.memory_limit,
250                        m.network_rx, m.network_tx, m.disk_read, m.disk_write
251                 FROM metrics m
252                 INNER JOIN (
253                     SELECT container_id, MAX(timestamp) as max_ts
254                     FROM metrics
255                     GROUP BY container_id
256                 ) latest ON m.container_id = latest.container_id AND m.timestamp = latest.max_ts",
257            )
258            .map_err(|e| TelemetryError::Storage(e.to_string()))?;
259
260        let rows = stmt
261            .query_map([], |row| {
262                Ok(MetricSample {
263                    container_id: row.get(0)?,
264                    container_name: row.get(1)?,
265                    timestamp: row.get(2)?,
266                    cpu_percent: row.get(3)?,
267                    memory_usage_bytes: row.get::<_, Option<i64>>(4)?.map(|v| v as u64),
268                    memory_limit_bytes: row.get::<_, Option<i64>>(5)?.map(|v| v as u64),
269                    network_rx_bytes: row.get::<_, Option<i64>>(6)?.map(|v| v as u64),
270                    network_tx_bytes: row.get::<_, Option<i64>>(7)?.map(|v| v as u64),
271                    disk_read_bytes: row.get::<_, Option<i64>>(8)?.map(|v| v as u64),
272                    disk_write_bytes: row.get::<_, Option<i64>>(9)?.map(|v| v as u64),
273                })
274            })
275            .map_err(|e| TelemetryError::Storage(e.to_string()))?;
276
277        rows.collect::<Result<Vec<_>, _>>()
278            .map_err(|e| TelemetryError::Storage(e.to_string()))
279    }
280
281    fn metrics_between(&self, from: &str, to: &str) -> Result<Vec<MetricSample>, TelemetryError> {
282        let mut stmt = self
283            .conn
284            .prepare(
285                "SELECT container_id, container_name, timestamp,
286                        cpu_percent, memory_usage, memory_limit,
287                        network_rx, network_tx, disk_read, disk_write
288                 FROM metrics
289                 WHERE timestamp >= ?1 AND timestamp <= ?2
290                 ORDER BY timestamp ASC",
291            )
292            .map_err(|e| TelemetryError::Storage(e.to_string()))?;
293
294        let rows = stmt
295            .query_map(params![from, to], |row| {
296                Ok(MetricSample {
297                    container_id: row.get(0)?,
298                    container_name: row.get(1)?,
299                    timestamp: row.get(2)?,
300                    cpu_percent: row.get(3)?,
301                    memory_usage_bytes: row.get::<_, Option<i64>>(4)?.map(|v| v as u64),
302                    memory_limit_bytes: row.get::<_, Option<i64>>(5)?.map(|v| v as u64),
303                    network_rx_bytes: row.get::<_, Option<i64>>(6)?.map(|v| v as u64),
304                    network_tx_bytes: row.get::<_, Option<i64>>(7)?.map(|v| v as u64),
305                    disk_read_bytes: row.get::<_, Option<i64>>(8)?.map(|v| v as u64),
306                    disk_write_bytes: row.get::<_, Option<i64>>(9)?.map(|v| v as u64),
307                })
308            })
309            .map_err(|e| TelemetryError::Storage(e.to_string()))?;
310
311        rows.collect::<Result<Vec<_>, _>>()
312            .map_err(|e| TelemetryError::Storage(e.to_string()))
313    }
314
315    fn write_event(&mut self, event: DockerEvent) -> Result<(), TelemetryError> {
316        let attributes_json =
317            serde_json::to_string(&event.attributes).unwrap_or_else(|_| "[]".to_owned());
318
319        self.conn
320            .execute(
321                "INSERT INTO events (time, event_type, action, actor_id, container, image, attributes)
322                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
323                params![
324                    event.time,
325                    event.event_type,
326                    event.action,
327                    event.actor_id,
328                    event.container,
329                    event.image,
330                    attributes_json,
331                ],
332            )
333            .map_err(|e| TelemetryError::Storage(e.to_string()))?;
334        Ok(())
335    }
336
337    fn events_between(&self, from: &str, to: &str) -> Result<Vec<DockerEvent>, TelemetryError> {
338        let mut stmt = self
339            .conn
340            .prepare(
341                "SELECT time, event_type, action, actor_id, container, image, attributes
342                 FROM events
343                 WHERE time >= ?1 AND time <= ?2
344                 ORDER BY time ASC",
345            )
346            .map_err(|e| TelemetryError::Storage(e.to_string()))?;
347
348        let rows = stmt
349            .query_map(params![from, to], |row| {
350                let attributes_json: String = row.get(6)?;
351                let attributes: Vec<(String, String)> =
352                    serde_json::from_str(&attributes_json).unwrap_or_default();
353                Ok(DockerEvent {
354                    time: row.get(0)?,
355                    event_type: row.get(1)?,
356                    action: row.get(2)?,
357                    actor_id: row.get(3)?,
358                    container: row.get(4)?,
359                    image: row.get(5)?,
360                    attributes,
361                })
362            })
363            .map_err(|e| TelemetryError::Storage(e.to_string()))?;
364
365        rows.collect::<Result<Vec<_>, _>>()
366            .map_err(|e| TelemetryError::Storage(e.to_string()))
367    }
368
369    fn write_snapshot(&mut self, snapshot: TelemetrySnapshot) -> Result<(), TelemetryError> {
370        let data =
371            serde_json::to_string(&snapshot).map_err(|e| TelemetryError::Storage(e.to_string()))?;
372
373        self.conn
374            .execute(
375                "INSERT INTO snapshots (timestamp, data) VALUES (?1, ?2)",
376                params![snapshot.timestamp, data],
377            )
378            .map_err(|e| TelemetryError::Storage(e.to_string()))?;
379        Ok(())
380    }
381
382    fn snapshot_at_or_before(
383        &self,
384        timestamp: &str,
385    ) -> Result<Option<TelemetrySnapshot>, TelemetryError> {
386        let result: Option<String> = self
387            .conn
388            .query_row(
389                "SELECT data FROM snapshots WHERE timestamp <= ?1 ORDER BY timestamp DESC LIMIT 1",
390                params![timestamp],
391                |row| row.get(0),
392            )
393            .optional()
394            .map_err(|e| TelemetryError::Storage(e.to_string()))?;
395
396        match result {
397            Some(data) => {
398                let snapshot: TelemetrySnapshot = serde_json::from_str(&data)
399                    .map_err(|e| TelemetryError::Storage(e.to_string()))?;
400                Ok(Some(snapshot))
401            }
402            None => Ok(None),
403        }
404    }
405
406    fn all_snapshots(&self) -> Result<Vec<TelemetrySnapshot>, TelemetryError> {
407        let mut stmt = self
408            .conn
409            .prepare("SELECT data FROM snapshots ORDER BY timestamp ASC")
410            .map_err(|e| TelemetryError::Storage(e.to_string()))?;
411
412        let rows = stmt
413            .query_map([], |row| {
414                let data: String = row.get(0)?;
415                let snapshot: TelemetrySnapshot = serde_json::from_str(&data)
416                    .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
417                Ok(snapshot)
418            })
419            .map_err(|e| TelemetryError::Storage(e.to_string()))?;
420
421        rows.collect::<Result<Vec<_>, _>>()
422            .map_err(|e| TelemetryError::Storage(e.to_string()))
423    }
424
425    fn write_alert_event(
426        &mut self,
427        event: crate::storage::AlertHistoryEvent,
428    ) -> Result<(), TelemetryError> {
429        self.conn
430            .execute(
431                "INSERT INTO alert_history (timestamp, container_id, container_name, rule_condition, action_type, action_detail, success)
432                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
433                params![
434                    event.timestamp,
435                    event.container_id,
436                    event.container_name,
437                    event.rule_condition,
438                    event.action_type,
439                    event.action_detail,
440                    i32::from(event.success),
441                ],
442            )
443            .map_err(|e| TelemetryError::Storage(e.to_string()))?;
444        Ok(())
445    }
446
447    fn alert_history(
448        &self,
449        from: &str,
450        to: &str,
451    ) -> Result<Vec<crate::storage::AlertHistoryEvent>, TelemetryError> {
452        let mut stmt = self
453            .conn
454            .prepare(
455                "SELECT timestamp, container_id, container_name, rule_condition, action_type, action_detail, success
456                 FROM alert_history
457                 WHERE timestamp >= ?1 AND timestamp <= ?2
458                 ORDER BY timestamp DESC",
459            )
460            .map_err(|e| TelemetryError::Storage(e.to_string()))?;
461
462        let rows = stmt
463            .query_map(params![from, to], |row| {
464                let success_int: i32 = row.get(6)?;
465                Ok(crate::storage::AlertHistoryEvent {
466                    timestamp: row.get(0)?,
467                    container_id: row.get(1)?,
468                    container_name: row.get(2)?,
469                    rule_condition: row.get(3)?,
470                    action_type: row.get(4)?,
471                    action_detail: row.get(5)?,
472                    success: success_int != 0,
473                })
474            })
475            .map_err(|e| TelemetryError::Storage(e.to_string()))?;
476
477        rows.collect::<Result<Vec<_>, _>>()
478            .map_err(|e| TelemetryError::Storage(e.to_string()))
479    }
480}
481
482/// Statistics about data purged by retention policy.
483#[derive(Debug, Clone)]
484pub struct RetentionStats {
485    pub metrics_deleted: usize,
486    pub events_deleted: usize,
487    pub snapshots_deleted: usize,
488}
489
490/// Statistics about the store's contents.
491#[derive(Debug, Clone)]
492pub struct StoreStats {
493    pub metric_count: u64,
494    pub event_count: u64,
495    pub snapshot_count: u64,
496}
497
498/// Trait extension for optional query results from rusqlite.
499trait OptionalExt<T> {
500    fn optional(self) -> Result<Option<T>, rusqlite::Error>;
501}
502
503impl<T> OptionalExt<T> for Result<T, rusqlite::Error> {
504    fn optional(self) -> Result<Option<T>, rusqlite::Error> {
505        match self {
506            Ok(value) => Ok(Some(value)),
507            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
508            Err(e) => Err(e),
509        }
510    }
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516    use crate::docker::Container;
517
518    #[test]
519    fn sqlite_stores_and_reads_metrics() {
520        let mut store = SqliteTelemetryStore::in_memory().unwrap();
521
522        store
523            .write_metric(sample("abc", "api", "2026-01-01T12:00:00Z", 85.0))
524            .unwrap();
525        store
526            .write_metric(sample("abc", "api", "2026-01-01T12:01:00Z", 90.0))
527            .unwrap();
528        store
529            .write_metric(sample("def", "worker", "2026-01-01T12:00:00Z", 20.0))
530            .unwrap();
531
532        let latest = store.latest_metrics().unwrap();
533        assert_eq!(latest.len(), 2);
534
535        let between = store
536            .metrics_between("2026-01-01T12:00:00Z", "2026-01-01T12:00:30Z")
537            .unwrap();
538        assert_eq!(between.len(), 2); // api@12:00 and worker@12:00
539    }
540
541    #[test]
542    fn sqlite_stores_and_reads_events() {
543        let mut store = SqliteTelemetryStore::in_memory().unwrap();
544
545        store
546            .write_event(event("2026-01-01T12:00:00Z", "start"))
547            .unwrap();
548        store
549            .write_event(event("2026-01-01T12:05:00Z", "die"))
550            .unwrap();
551        store
552            .write_event(event("2026-01-01T13:00:00Z", "restart"))
553            .unwrap();
554
555        let events = store
556            .events_between("2026-01-01T12:00:00Z", "2026-01-01T12:59:59Z")
557            .unwrap();
558        assert_eq!(events.len(), 2);
559        assert_eq!(events[0].action, "start");
560        assert_eq!(events[1].action, "die");
561    }
562
563    #[test]
564    fn sqlite_stores_and_reads_snapshots() {
565        let mut store = SqliteTelemetryStore::in_memory().unwrap();
566
567        store
568            .write_snapshot(snapshot("2026-01-01 11:59:00", "old-image"))
569            .unwrap();
570        store
571            .write_snapshot(snapshot("2026-01-01 12:00:00", "new-image"))
572            .unwrap();
573
574        // Query at exact match
575        let snap = store
576            .snapshot_at_or_before("2026-01-01 12:00:00")
577            .unwrap()
578            .unwrap();
579        assert_eq!(snap.containers[0].image, "new-image");
580
581        // Query after both → gets latest
582        let snap = store
583            .snapshot_at_or_before("2026-01-01 12:00:30")
584            .unwrap()
585            .unwrap();
586        assert_eq!(snap.containers[0].image, "new-image");
587
588        // Query between → gets earlier
589        let snap = store
590            .snapshot_at_or_before("2026-01-01 11:59:30")
591            .unwrap()
592            .unwrap();
593        assert_eq!(snap.containers[0].image, "old-image");
594
595        // Query before all → none
596        let snap = store.snapshot_at_or_before("2026-01-01 11:00:00").unwrap();
597        assert!(snap.is_none());
598    }
599
600    #[test]
601    fn sqlite_inspect_at_works() {
602        let mut store = SqliteTelemetryStore::in_memory().unwrap();
603
604        store
605            .write_snapshot(snapshot("2026-01-01 12:00:00", "api:v2"))
606            .unwrap();
607
608        let query = crate::ast::InspectQuery {
609            target: crate::ast::SingularTarget {
610                kind: crate::ast::SingularTargetKind::Container,
611                value: "api".to_owned(),
612            },
613            at: Some("2026-01-01 12:00:30".to_owned()),
614        };
615
616        let result = crate::storage::inspect_at(&query, &store).unwrap();
617        assert_eq!(result.rows.len(), 1);
618        assert_eq!(
619            result.rows[0].fields["image"],
620            serde_json::Value::String("api:v2".to_owned())
621        );
622    }
623
624    #[test]
625    fn sqlite_historical_events_works() {
626        let mut store = SqliteTelemetryStore::in_memory().unwrap();
627
628        store
629            .write_event(event("2026-01-01T12:00:00Z", "start"))
630            .unwrap();
631        store
632            .write_event(event("2026-01-01T12:05:00Z", "die"))
633            .unwrap();
634
635        let query = crate::ast::EventsQuery {
636            target: crate::ast::CollectionTarget::Containers,
637            time: Some(crate::ast::TimeSelector::Range {
638                from: "2026-01-01T12:00:00Z".to_owned(),
639                to: "2026-01-01T12:10:00Z".to_owned(),
640            }),
641            filter: Some(crate::ast::Expression::Comparison {
642                left: Box::new(crate::ast::Expression::Field("action".to_owned())),
643                operator: crate::ast::Operator::Eq,
644                right: Box::new(crate::ast::Expression::Literal(crate::ast::Value::String(
645                    "die".to_owned(),
646                ))),
647            }),
648            pipeline: vec![],
649        };
650
651        let rt = tokio::runtime::Runtime::new().unwrap();
652        let result = rt
653            .block_on(crate::storage::historical_events(&query, &store))
654            .unwrap();
655        assert_eq!(result.rows.len(), 1);
656        assert_eq!(
657            result.rows[0].fields["action"],
658            serde_json::Value::String("die".to_owned())
659        );
660    }
661
662    #[test]
663    fn sqlite_retention_deletes_old_data() {
664        let mut store = SqliteTelemetryStore::in_memory().unwrap();
665        store.set_retention(RetentionPolicy {
666            metric_max_age_secs: 0, // delete everything
667            event_max_age_secs: 0,
668            snapshot_max_age_secs: 0,
669        });
670
671        store
672            .write_metric(sample("abc", "api", "2020-01-01T00:00:00Z", 50.0))
673            .unwrap();
674        store
675            .write_event(event("2020-01-01T00:00:00Z", "start"))
676            .unwrap();
677        store
678            .write_snapshot(snapshot("2020-01-01 00:00:00", "old"))
679            .unwrap();
680
681        let stats_before = store.stats().unwrap();
682        assert_eq!(stats_before.metric_count, 1);
683        assert_eq!(stats_before.event_count, 1);
684        assert_eq!(stats_before.snapshot_count, 1);
685
686        let retention_stats = store.apply_retention().unwrap();
687        assert_eq!(retention_stats.metrics_deleted, 1);
688        assert_eq!(retention_stats.events_deleted, 1);
689        assert_eq!(retention_stats.snapshots_deleted, 1);
690
691        let stats_after = store.stats().unwrap();
692        assert_eq!(stats_after.metric_count, 0);
693        assert_eq!(stats_after.event_count, 0);
694        assert_eq!(stats_after.snapshot_count, 0);
695    }
696
697    #[test]
698    fn sqlite_store_stats() {
699        let mut store = SqliteTelemetryStore::in_memory().unwrap();
700
701        store
702            .write_metric(sample("abc", "api", "2026-01-01T12:00:00Z", 50.0))
703            .unwrap();
704        store
705            .write_metric(sample("def", "worker", "2026-01-01T12:00:00Z", 30.0))
706            .unwrap();
707        store
708            .write_event(event("2026-01-01T12:00:00Z", "start"))
709            .unwrap();
710
711        let stats = store.stats().unwrap();
712        assert_eq!(stats.metric_count, 2);
713        assert_eq!(stats.event_count, 1);
714        assert_eq!(stats.snapshot_count, 0);
715    }
716
717    #[test]
718    fn sqlite_preserves_event_attributes() {
719        let mut store = SqliteTelemetryStore::in_memory().unwrap();
720
721        let ev = DockerEvent {
722            time: "2026-01-01T12:00:00Z".to_owned(),
723            event_type: "container".to_owned(),
724            action: "start".to_owned(),
725            actor_id: "abc".to_owned(),
726            container: Some("api".to_owned()),
727            image: Some("api:latest".to_owned()),
728            attributes: vec![
729                ("name".to_owned(), "api".to_owned()),
730                ("signal".to_owned(), "15".to_owned()),
731            ],
732        };
733        store.write_event(ev).unwrap();
734
735        let events = store
736            .events_between("2026-01-01T00:00:00Z", "2026-01-01T23:59:59Z")
737            .unwrap();
738        assert_eq!(events.len(), 1);
739        assert_eq!(events[0].attributes.len(), 2);
740        assert_eq!(
741            events[0].attributes[0],
742            ("name".to_owned(), "api".to_owned())
743        );
744    }
745
746    fn sample(id: &str, name: &str, ts: &str, cpu: f64) -> MetricSample {
747        MetricSample {
748            container_id: id.to_owned(),
749            container_name: name.to_owned(),
750            timestamp: ts.to_owned(),
751            cpu_percent: Some(cpu),
752            memory_usage_bytes: Some(128),
753            memory_limit_bytes: Some(1024),
754            network_rx_bytes: Some(10),
755            network_tx_bytes: Some(20),
756            disk_read_bytes: Some(30),
757            disk_write_bytes: Some(40),
758        }
759    }
760
761    fn event(time: &str, action: &str) -> DockerEvent {
762        DockerEvent {
763            time: time.to_owned(),
764            event_type: "container".to_owned(),
765            action: action.to_owned(),
766            actor_id: "abc".to_owned(),
767            container: Some("api".to_owned()),
768            image: Some("api:latest".to_owned()),
769            attributes: vec![("name".to_owned(), "api".to_owned())],
770        }
771    }
772
773    fn snapshot(timestamp: &str, image: &str) -> TelemetrySnapshot {
774        TelemetrySnapshot {
775            timestamp: timestamp.to_owned(),
776            containers: vec![Container {
777                id: "abc".to_owned(),
778                name: "api".to_owned(),
779                image: image.to_owned(),
780                status: "Up".to_owned(),
781                state: "running".to_owned(),
782                ports: Vec::new(),
783                labels: Vec::new(),
784                created_at: None,
785                started_at: None,
786                finished_at: None,
787                restart_count: Some(1),
788                health: None,
789            }],
790            images: Vec::new(),
791            networks: Vec::new(),
792            volumes: Vec::new(),
793        }
794    }
795}