Skip to main content

camel_core/lifecycle/adapters/
redb_journal.rs

1//! Redb-backed runtime event journal.
2//!
3//! # Schema
4//!
5//! - Table `events`: `u64 → &[u8]`  (seq → serde_json bytes of `JournalEntry`)
6//! - Table `command_ids`: `&str → ()`  (presence = alive)
7//!
8//! # Sequence numbers
9//!
10//! No autoincrement in redb. Each `append_batch` derives `next_seq` by reading
11//! the last key via `iter().next_back()` and adding 1; defaults to 0 if empty.
12
13use std::path::PathBuf;
14use std::sync::Arc;
15
16use async_trait::async_trait;
17use redb::{
18    Database, Durability, ReadableDatabase, ReadableTable, ReadableTableMetadata, TableDefinition,
19};
20use serde::{Deserialize, Serialize};
21
22use camel_api::CamelError;
23
24use crate::lifecycle::adapters::runtime_event_record::runtime_event_serde;
25use crate::lifecycle::application::ports::RuntimeEventJournalPort;
26use crate::lifecycle::domain::{DomainError, RuntimeEvent};
27
28// ── Table definitions ─────────────────────────────────────────────────────────
29
30const EVENTS_TABLE: TableDefinition<u64, &[u8]> = TableDefinition::new("events");
31const COMMAND_IDS_TABLE: TableDefinition<&str, ()> = TableDefinition::new("command_ids");
32
33// ── Public types ──────────────────────────────────────────────────────────────
34
35/// Durability mode for journal writes.
36#[derive(Debug, Clone, PartialEq, Default)]
37pub enum JournalDurability {
38    /// fsync on every commit — protects against kernel crash and power loss (default).
39    #[default]
40    Immediate,
41    /// No fsync — OS decides flush timing. Suitable for dev/test.
42    Eventual,
43}
44
45/// Options for `RedbRuntimeEventJournal`.
46#[derive(Debug, Clone)]
47pub struct RedbJournalOptions {
48    pub durability: JournalDurability,
49    /// Trigger compaction after this many events in the table. Default: 10_000.
50    pub compaction_threshold_events: u64,
51}
52
53impl Default for RedbJournalOptions {
54    fn default() -> Self {
55        Self {
56            durability: JournalDurability::Immediate,
57            compaction_threshold_events: 10_000,
58        }
59    }
60}
61
62/// Internal wire format stored as redb value bytes (serde_json).
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct JournalEntry {
65    pub seq: u64,
66    pub timestamp_ms: i64,
67    #[serde(with = "runtime_event_serde")]
68    pub event: RuntimeEvent,
69}
70
71/// Filter for `RedbRuntimeEventJournal::inspect`.
72pub struct JournalInspectFilter {
73    pub route_id: Option<String>,
74    pub limit: usize,
75}
76
77// ── Adapter ───────────────────────────────────────────────────────────────────
78
79/// Redb-backed implementation of `RuntimeEventJournalPort`.
80///
81/// `Arc<Database>` allows cheap cloning — all clones share the same underlying
82/// redb file handle. `redb::Database` is `Send + Sync`.
83#[derive(Clone)]
84pub struct RedbRuntimeEventJournal {
85    db: Arc<Database>,
86    options: RedbJournalOptions,
87}
88
89impl RedbRuntimeEventJournal {
90    /// Open (or create) the redb database at `path`.
91    ///
92    /// Parent directories are created if they do not exist.
93    /// Both tables are initialised on first open.
94    /// Uses `tokio::task::spawn_blocking` because `Database::open` is blocking.
95    pub async fn new(
96        path: impl Into<PathBuf>,
97        options: RedbJournalOptions,
98    ) -> Result<Self, CamelError> {
99        let path = path.into();
100        let db = tokio::task::spawn_blocking(move || {
101            if let Some(parent) = path.parent() {
102                std::fs::create_dir_all(parent).map_err(|e| {
103                    CamelError::Io(format!(
104                        "failed to create journal directory '{}': {e}",
105                        parent.display()
106                    ))
107                })?;
108            }
109            let db = Database::create(&path).map_err(|e| {
110                CamelError::Io(format!(
111                    "failed to open journal at '{}': {e}",
112                    path.display()
113                ))
114            })?;
115            // Initialise tables so they exist before any reads.
116            let tx = db
117                .begin_write()
118                .map_err(|e| CamelError::Io(format!("redb begin_write: {e}")))?;
119            tx.open_table(EVENTS_TABLE)
120                .map_err(|e| CamelError::Io(format!("redb open events table: {e}")))?;
121            tx.open_table(COMMAND_IDS_TABLE)
122                .map_err(|e| CamelError::Io(format!("redb open command_ids table: {e}")))?;
123            tx.commit()
124                .map_err(|e| CamelError::Io(format!("redb commit init: {e}")))?;
125            Ok::<_, CamelError>(db)
126        })
127        .await
128        .map_err(|e| CamelError::Io(format!("spawn_blocking join: {e}")))??;
129
130        Ok(Self {
131            db: Arc::new(db),
132            options,
133        })
134    }
135
136    /// Open an existing database at `path` and return entries (newest-first, up to `filter.limit`).
137    ///
138    /// Uses `Database::open` + `begin_read` — concurrent with a live writer on the same file.
139    /// `inspect` is an offline utility: it does NOT require a live `RedbRuntimeEventJournal` instance.
140    pub async fn inspect(
141        path: impl Into<PathBuf>,
142        filter: JournalInspectFilter,
143    ) -> Result<Vec<JournalEntry>, CamelError> {
144        let path = path.into();
145        let limit = filter.limit;
146        let route_id = filter.route_id;
147        tokio::task::spawn_blocking(move || {
148            if !path.exists() {
149                return Err(CamelError::Io(format!(
150                    "journal file not found: {}",
151                    path.display()
152                )));
153            }
154            let db = Database::open(&path)
155                .map_err(|e| CamelError::Io(format!("invalid journal file: {e}")))?;
156            let tx = db
157                .begin_read()
158                .map_err(|e| CamelError::Io(format!("redb begin_read: {e}")))?;
159            let table = tx
160                .open_table(EVENTS_TABLE)
161                .map_err(|e| CamelError::Io(format!("redb open events: {e}")))?;
162
163            // Collect in descending order (newest first).
164            // Filter by route_id FIRST, then apply limit — ensures we return
165            // `limit` matching entries, not `limit` total entries where most may
166            // not match the filter.
167            let mut entries: Vec<JournalEntry> = Vec::new();
168            for result in table
169                .iter()
170                .map_err(|e| CamelError::Io(format!("redb iter: {e}")))?
171                .rev()
172            {
173                let (_k, v) = result.map_err(|e| CamelError::Io(format!("redb read: {e}")))?;
174                let entry: JournalEntry = serde_json::from_slice(v.value())
175                    .map_err(|e| CamelError::Io(format!("journal deserialize: {e}")))?;
176                if let Some(ref rid) = route_id
177                    && entry.event.route_id() != rid.as_str()
178                {
179                    continue;
180                }
181                if entries.len() >= limit {
182                    break;
183                }
184                entries.push(entry);
185            }
186            Ok(entries)
187        })
188        .await
189        .map_err(|e| CamelError::Io(format!("spawn_blocking join: {e}")))?
190    }
191
192    // ── Internal helpers ──────────────────────────────────────────────────────
193
194    fn redb_durability(&self) -> Durability {
195        match self.options.durability {
196            JournalDurability::Immediate => Durability::Immediate,
197            JournalDurability::Eventual => Durability::None,
198        }
199    }
200
201    /// Derive next sequence number from the last key in the events table.
202    /// Must be called inside a write transaction with the table already open.
203    fn next_seq(table: &redb::Table<u64, &[u8]>) -> Result<u64, CamelError> {
204        match table
205            .iter()
206            .map_err(|e| CamelError::Io(format!("redb iter for seq: {e}")))?
207            .next_back()
208        {
209            Some(Ok((k, _))) => Ok(k.value() + 1),
210            Some(Err(e)) => Err(CamelError::Io(format!("redb seq read: {e}"))),
211            None => Ok(0),
212        }
213    }
214
215    /// Count rows in the events table (read transaction).
216    fn event_count(&self) -> Result<u64, CamelError> {
217        let tx = self
218            .db
219            .begin_read()
220            .map_err(|e| CamelError::Io(format!("redb begin_read: {e}")))?;
221        let table = tx
222            .open_table(EVENTS_TABLE)
223            .map_err(|e| CamelError::Io(format!("redb open events: {e}")))?;
224        table
225            .len()
226            .map_err(|e| CamelError::Io(format!("redb len: {e}")))
227    }
228
229    /// Compact the events table: remove events for routes that have been fully removed.
230    fn compact(&self) -> Result<(), CamelError> {
231        let tx = self
232            .db
233            .begin_write()
234            .map_err(|e| CamelError::Io(format!("redb begin_write: {e}")))?;
235        {
236            let mut table = tx
237                .open_table(EVENTS_TABLE)
238                .map_err(|e| CamelError::Io(format!("redb open events: {e}")))?;
239
240            // Pass 1: read all events in key order, find last RouteRemoved seq per route.
241            let mut last_removed_seq: std::collections::HashMap<String, u64> =
242                std::collections::HashMap::new();
243            for result in table
244                .iter()
245                .map_err(|e| CamelError::Io(format!("redb iter: {e}")))?
246            {
247                let (k, v) = result.map_err(|e| CamelError::Io(format!("redb read: {e}")))?;
248                let seq = k.value();
249                let entry: JournalEntry = serde_json::from_slice(v.value())
250                    .map_err(|e| CamelError::Io(format!("journal deserialize: {e}")))?;
251                if matches!(entry.event, RuntimeEvent::RouteRemoved { .. }) {
252                    last_removed_seq.insert(entry.event.route_id().to_string(), seq);
253                }
254            }
255
256            if last_removed_seq.is_empty() {
257                drop(table);
258                tx.commit()
259                    .map_err(|e| CamelError::Io(format!("redb commit compact: {e}")))?;
260                return Ok(());
261            }
262
263            // Pass 2: collect seqs to delete.
264            let mut to_delete: Vec<u64> = Vec::new();
265            for result in table
266                .iter()
267                .map_err(|e| CamelError::Io(format!("redb iter pass2: {e}")))?
268            {
269                let (k, v) = result.map_err(|e| CamelError::Io(format!("redb read: {e}")))?;
270                let seq = k.value();
271                let entry: JournalEntry = serde_json::from_slice(v.value())
272                    .map_err(|e| CamelError::Io(format!("journal deserialize: {e}")))?;
273                let route_id = entry.event.route_id().to_string();
274                if let Some(&cutoff) = last_removed_seq.get(&route_id)
275                    && seq <= cutoff
276                {
277                    to_delete.push(seq);
278                }
279            }
280
281            for seq in to_delete {
282                table
283                    .remove(&seq)
284                    .map_err(|e| CamelError::Io(format!("redb remove seq {seq}: {e}")))?;
285            }
286        }
287        tx.commit()
288            .map_err(|e| CamelError::Io(format!("redb commit compact: {e}")))?;
289        Ok(())
290    }
291}
292
293// ── RuntimeEvent helper ───────────────────────────────────────────────────────
294
295/// Extension to extract the `route_id` field from any `RuntimeEvent` variant.
296trait RuntimeEventExt {
297    fn route_id(&self) -> &str;
298}
299
300impl RuntimeEventExt for RuntimeEvent {
301    fn route_id(&self) -> &str {
302        match self {
303            RuntimeEvent::RouteRegistered { route_id }
304            | RuntimeEvent::RouteStartRequested { route_id }
305            | RuntimeEvent::RouteStarted { route_id }
306            | RuntimeEvent::RouteFailed { route_id, .. }
307            | RuntimeEvent::RouteStopped { route_id }
308            | RuntimeEvent::RouteSuspended { route_id }
309            | RuntimeEvent::RouteResumed { route_id }
310            | RuntimeEvent::RouteReloaded { route_id }
311            | RuntimeEvent::RouteRemoved { route_id } => route_id,
312        }
313    }
314}
315
316// ── RuntimeEventJournalPort impl ──────────────────────────────────────────────
317
318#[async_trait]
319impl RuntimeEventJournalPort for RedbRuntimeEventJournal {
320    async fn append_batch(&self, events: &[RuntimeEvent]) -> Result<(), DomainError> {
321        if events.is_empty() {
322            return Ok(());
323        }
324        let db = Arc::clone(&self.db);
325        let durability = self.redb_durability();
326        let events = events.to_vec();
327        let now_ms = chrono::Utc::now().timestamp_millis();
328
329        tokio::task::spawn_blocking(move || {
330            // NOTE: `mut` is required — `set_durability` takes `&mut self` in redb v2.
331            let mut tx = db
332                .begin_write()
333                .map_err(|e| CamelError::Io(format!("redb begin_write: {e}")))?;
334            tx.set_durability(durability)
335                .map_err(|e| CamelError::Io(format!("redb set_durability: {e}")))?;
336            {
337                let mut table = tx
338                    .open_table(EVENTS_TABLE)
339                    .map_err(|e| CamelError::Io(format!("redb open events: {e}")))?;
340                let start_seq = Self::next_seq(&table)?;
341                for (next_seq, event) in (start_seq..).zip(events) {
342                    let entry = JournalEntry {
343                        seq: next_seq,
344                        timestamp_ms: now_ms,
345                        event,
346                    };
347                    let bytes = serde_json::to_vec(&entry)
348                        .map_err(|e| CamelError::Io(format!("journal serialize: {e}")))?;
349                    table
350                        .insert(&next_seq, bytes.as_slice())
351                        .map_err(|e| CamelError::Io(format!("redb insert: {e}")))?;
352                }
353            }
354            tx.commit()
355                .map_err(|e| CamelError::Io(format!("redb commit: {e}")))?;
356            Ok::<_, CamelError>(())
357        })
358        .await
359        .map_err(|e| DomainError::InvalidState(format!("spawn_blocking join: {e}")))?
360        .map_err(|e| DomainError::InvalidState(e.to_string()))?;
361
362        // Trigger compaction if threshold exceeded. Non-fatal if it fails.
363        // Both event_count() and compact() do blocking redb I/O — run in spawn_blocking.
364        let journal_clone = self.clone();
365        let threshold = self.options.compaction_threshold_events;
366        tokio::task::spawn_blocking(move || match journal_clone.event_count() {
367            Ok(count) if count >= threshold => {
368                if let Err(e) = journal_clone.compact() {
369                    tracing::warn!("journal compaction failed (non-fatal): {e}");
370                }
371            }
372            Ok(_) => {}
373            Err(e) => {
374                tracing::warn!("journal event count check failed (non-fatal): {e}");
375            }
376        })
377        .await
378        .ok(); // Non-fatal: if spawn_blocking panics, we ignore it
379
380        Ok(())
381    }
382
383    async fn load_all(&self) -> Result<Vec<RuntimeEvent>, DomainError> {
384        let db = Arc::clone(&self.db);
385        tokio::task::spawn_blocking(move || {
386            let tx = db
387                .begin_read()
388                .map_err(|e| CamelError::Io(format!("redb begin_read: {e}")))?;
389            let table = tx
390                .open_table(EVENTS_TABLE)
391                .map_err(|e| CamelError::Io(format!("redb open events: {e}")))?;
392            let mut events = Vec::new();
393            for result in table
394                .iter()
395                .map_err(|e| CamelError::Io(format!("redb iter: {e}")))?
396            {
397                let (_k, v) = result.map_err(|e| CamelError::Io(format!("redb read: {e}")))?;
398                let entry: JournalEntry = serde_json::from_slice(v.value())
399                    .map_err(|e| CamelError::Io(format!("journal deserialize: {e}")))?;
400                events.push(entry.event);
401            }
402            Ok(events)
403        })
404        .await
405        .map_err(|e| DomainError::InvalidState(format!("spawn_blocking join: {e}")))?
406        .map_err(|e: CamelError| DomainError::InvalidState(e.to_string()))
407    }
408
409    async fn append_command_id(&self, command_id: &str) -> Result<(), DomainError> {
410        let db = Arc::clone(&self.db);
411        let durability = self.redb_durability();
412        let id = command_id.to_string();
413        tokio::task::spawn_blocking(move || {
414            // NOTE: `mut` required — `set_durability` takes `&mut self` in redb v2.
415            let mut tx = db
416                .begin_write()
417                .map_err(|e| CamelError::Io(format!("redb begin_write: {e}")))?;
418            tx.set_durability(durability)
419                .map_err(|e| CamelError::Io(format!("redb set_durability: {e}")))?;
420            {
421                let mut table = tx
422                    .open_table(COMMAND_IDS_TABLE)
423                    .map_err(|e| CamelError::Io(format!("redb open command_ids: {e}")))?;
424                table
425                    .insert(id.as_str(), ())
426                    .map_err(|e| CamelError::Io(format!("redb insert command_id: {e}")))?;
427            }
428            tx.commit()
429                .map_err(|e| CamelError::Io(format!("redb commit: {e}")))?;
430            Ok::<_, CamelError>(())
431        })
432        .await
433        .map_err(|e| DomainError::InvalidState(format!("spawn_blocking join: {e}")))?
434        .map_err(|e| DomainError::InvalidState(e.to_string()))
435    }
436
437    async fn remove_command_id(&self, command_id: &str) -> Result<(), DomainError> {
438        let db = Arc::clone(&self.db);
439        let durability = self.redb_durability();
440        let id = command_id.to_string();
441        tokio::task::spawn_blocking(move || {
442            // NOTE: `mut` required — `set_durability` takes `&mut self` in redb v2.
443            let mut tx = db
444                .begin_write()
445                .map_err(|e| CamelError::Io(format!("redb begin_write: {e}")))?;
446            tx.set_durability(durability)
447                .map_err(|e| CamelError::Io(format!("redb set_durability: {e}")))?;
448            {
449                let mut table = tx
450                    .open_table(COMMAND_IDS_TABLE)
451                    .map_err(|e| CamelError::Io(format!("redb open command_ids: {e}")))?;
452                table
453                    .remove(id.as_str())
454                    .map_err(|e| CamelError::Io(format!("redb remove command_id: {e}")))?;
455            }
456            tx.commit()
457                .map_err(|e| CamelError::Io(format!("redb commit: {e}")))?;
458            Ok::<_, CamelError>(())
459        })
460        .await
461        .map_err(|e| DomainError::InvalidState(format!("spawn_blocking join: {e}")))?
462        .map_err(|e| DomainError::InvalidState(e.to_string()))
463    }
464
465    async fn load_command_ids(&self) -> Result<Vec<String>, DomainError> {
466        let db = Arc::clone(&self.db);
467        tokio::task::spawn_blocking(move || {
468            let tx = db
469                .begin_read()
470                .map_err(|e| CamelError::Io(format!("redb begin_read: {e}")))?;
471            let table = tx
472                .open_table(COMMAND_IDS_TABLE)
473                .map_err(|e| CamelError::Io(format!("redb open command_ids: {e}")))?;
474            let mut ids = Vec::new();
475            for result in table
476                .iter()
477                .map_err(|e| CamelError::Io(format!("redb iter: {e}")))?
478            {
479                let (k, _) = result.map_err(|e| CamelError::Io(format!("redb read: {e}")))?;
480                ids.push(k.value().to_string());
481            }
482            Ok(ids)
483        })
484        .await
485        .map_err(|e| DomainError::InvalidState(format!("spawn_blocking join: {e}")))?
486        .map_err(|e: CamelError| DomainError::InvalidState(e.to_string()))
487    }
488}
489
490// ── Unit tests ────────────────────────────────────────────────────────────────
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495    use tempfile::tempdir;
496
497    async fn new_journal(dir: &tempfile::TempDir) -> RedbRuntimeEventJournal {
498        RedbRuntimeEventJournal::new(dir.path().join("test.db"), RedbJournalOptions::default())
499            .await
500            .unwrap()
501    }
502
503    #[tokio::test]
504    async fn redb_journal_roundtrip() {
505        let dir = tempdir().unwrap();
506        let journal = new_journal(&dir).await;
507
508        let events = vec![
509            RuntimeEvent::RouteRegistered {
510                route_id: "r1".to_string(),
511            },
512            RuntimeEvent::RouteStarted {
513                route_id: "r1".to_string(),
514            },
515        ];
516        journal.append_batch(&events).await.unwrap();
517
518        let loaded = journal.load_all().await.unwrap();
519        assert_eq!(loaded, events);
520    }
521
522    #[tokio::test]
523    async fn redb_journal_command_id_lifecycle() {
524        let dir = tempdir().unwrap();
525        let journal = new_journal(&dir).await;
526
527        journal.append_command_id("c1").await.unwrap();
528        journal.append_command_id("c2").await.unwrap();
529        journal.remove_command_id("c1").await.unwrap();
530
531        let ids = journal.load_command_ids().await.unwrap();
532        assert_eq!(ids, vec!["c2".to_string()]);
533    }
534
535    #[tokio::test]
536    async fn redb_journal_compaction_removes_completed_routes() {
537        let dir = tempdir().unwrap();
538        // Threshold of 1 triggers compaction on every append.
539        let journal = RedbRuntimeEventJournal::new(
540            dir.path().join("compact.db"),
541            RedbJournalOptions {
542                durability: JournalDurability::Eventual,
543                compaction_threshold_events: 1,
544            },
545        )
546        .await
547        .unwrap();
548
549        // Removed route — full lifecycle.
550        journal
551            .append_batch(&[RuntimeEvent::RouteRegistered {
552                route_id: "old".to_string(),
553            }])
554            .await
555            .unwrap();
556        journal
557            .append_batch(&[RuntimeEvent::RouteRemoved {
558                route_id: "old".to_string(),
559            }])
560            .await
561            .unwrap();
562
563        // Active route — no RouteRemoved.
564        journal
565            .append_batch(&[RuntimeEvent::RouteRegistered {
566                route_id: "live".to_string(),
567            }])
568            .await
569            .unwrap();
570
571        let loaded = journal.load_all().await.unwrap();
572        assert!(
573            !loaded.iter().any(
574                |e| matches!(e, RuntimeEvent::RouteRegistered { route_id } if route_id == "old")
575            ),
576            "old route events must be compacted"
577        );
578        assert!(
579            loaded.iter().any(
580                |e| matches!(e, RuntimeEvent::RouteRegistered { route_id } if route_id == "live")
581            ),
582            "live route events must survive compaction"
583        );
584    }
585
586    #[tokio::test]
587    async fn redb_journal_compaction_preserves_reregistered_route() {
588        let dir = tempdir().unwrap();
589        let journal = RedbRuntimeEventJournal::new(
590            dir.path().join("rereg.db"),
591            RedbJournalOptions {
592                durability: JournalDurability::Eventual,
593                compaction_threshold_events: 1,
594            },
595        )
596        .await
597        .unwrap();
598
599        journal
600            .append_batch(&[RuntimeEvent::RouteRegistered {
601                route_id: "rereg".to_string(),
602            }])
603            .await
604            .unwrap();
605        journal
606            .append_batch(&[RuntimeEvent::RouteRemoved {
607                route_id: "rereg".to_string(),
608            }])
609            .await
610            .unwrap();
611        journal
612            .append_batch(&[RuntimeEvent::RouteRegistered {
613                route_id: "rereg".to_string(),
614            }])
615            .await
616            .unwrap();
617
618        let loaded = journal.load_all().await.unwrap();
619        let rereg_count = loaded
620            .iter()
621            .filter(
622                |e| matches!(e, RuntimeEvent::RouteRegistered { route_id } if route_id == "rereg"),
623            )
624            .count();
625        assert_eq!(
626            rereg_count, 1,
627            "re-registered route must have exactly one event after compaction"
628        );
629    }
630
631    #[tokio::test]
632    async fn redb_journal_durability_eventual() {
633        let dir = tempdir().unwrap();
634        let journal = RedbRuntimeEventJournal::new(
635            dir.path().join("eventual.db"),
636            RedbJournalOptions {
637                durability: JournalDurability::Eventual,
638                compaction_threshold_events: 10_000,
639            },
640        )
641        .await
642        .unwrap();
643
644        journal
645            .append_batch(&[RuntimeEvent::RouteRegistered {
646                route_id: "ev".to_string(),
647            }])
648            .await
649            .unwrap();
650        let loaded = journal.load_all().await.unwrap();
651        assert_eq!(loaded.len(), 1);
652    }
653
654    #[tokio::test]
655    async fn redb_journal_clone_shares_db() {
656        let dir = tempdir().unwrap();
657        let j1 = new_journal(&dir).await;
658        let j2 = j1.clone();
659
660        j1.append_batch(&[RuntimeEvent::RouteRegistered {
661            route_id: "shared".to_string(),
662        }])
663        .await
664        .unwrap();
665
666        // j2 must see j1's write since they share the same Arc<Database>.
667        let loaded = j2.load_all().await.unwrap();
668        assert_eq!(loaded.len(), 1);
669    }
670
671    #[tokio::test]
672    async fn redb_journal_append_empty_batch_is_noop() {
673        let dir = tempdir().unwrap();
674        let journal = new_journal(&dir).await;
675
676        journal.append_batch(&[]).await.unwrap();
677        let loaded = journal.load_all().await.unwrap();
678        assert!(loaded.is_empty());
679    }
680
681    #[tokio::test]
682    async fn redb_journal_sequence_numbers_across_batches() {
683        let dir = tempdir().unwrap();
684        let journal = new_journal(&dir).await;
685
686        journal
687            .append_batch(&[
688                RuntimeEvent::RouteRegistered {
689                    route_id: "r1".to_string(),
690                },
691                RuntimeEvent::RouteStarted {
692                    route_id: "r1".to_string(),
693                },
694            ])
695            .await
696            .unwrap();
697
698        journal
699            .append_batch(&[RuntimeEvent::RouteStopped {
700                route_id: "r1".to_string(),
701            }])
702            .await
703            .unwrap();
704
705        let loaded = journal.load_all().await.unwrap();
706        assert_eq!(loaded.len(), 3);
707
708        // Drop journal to release redb lock before inspect.
709        drop(journal);
710
711        // Verify sequence numbers are monotonic.
712        let entries = RedbRuntimeEventJournal::inspect(
713            dir.path().join("test.db"),
714            JournalInspectFilter {
715                route_id: None,
716                limit: 10,
717            },
718        )
719        .await
720        .unwrap();
721        let seqs: Vec<u64> = entries.iter().map(|e| e.seq).collect();
722        // inspect returns newest-first.
723        assert_eq!(seqs, vec![2, 1, 0]);
724    }
725
726    #[tokio::test]
727    async fn redb_journal_load_all_empty() {
728        let dir = tempdir().unwrap();
729        let journal = new_journal(&dir).await;
730        let loaded = journal.load_all().await.unwrap();
731        assert!(loaded.is_empty());
732    }
733
734    #[tokio::test]
735    async fn redb_journal_inspect_file_not_found() {
736        let dir = tempdir().unwrap();
737        let result = RedbRuntimeEventJournal::inspect(
738            dir.path().join("nonexistent.db"),
739            JournalInspectFilter {
740                route_id: None,
741                limit: 10,
742            },
743        )
744        .await;
745        assert!(result.is_err());
746        let err = result.unwrap_err().to_string();
747        assert!(err.contains("journal file not found"));
748    }
749
750    #[tokio::test]
751    async fn redb_journal_inspect_with_route_id_filter() {
752        let dir = tempdir().unwrap();
753        let journal = new_journal(&dir).await;
754
755        journal
756            .append_batch(&[
757                RuntimeEvent::RouteRegistered {
758                    route_id: "alpha".to_string(),
759                },
760                RuntimeEvent::RouteRegistered {
761                    route_id: "beta".to_string(),
762                },
763                RuntimeEvent::RouteStarted {
764                    route_id: "alpha".to_string(),
765                },
766            ])
767            .await
768            .unwrap();
769
770        drop(journal);
771
772        let entries = RedbRuntimeEventJournal::inspect(
773            dir.path().join("test.db"),
774            JournalInspectFilter {
775                route_id: Some("alpha".to_string()),
776                limit: 10,
777            },
778        )
779        .await
780        .unwrap();
781
782        assert_eq!(entries.len(), 2);
783        assert!(entries.iter().all(|e| {
784            matches!(&e.event, RuntimeEvent::RouteRegistered { route_id } | RuntimeEvent::RouteStarted { route_id } if route_id == "alpha")
785        }));
786    }
787
788    #[tokio::test]
789    async fn redb_journal_inspect_limit_enforcement() {
790        let dir = tempdir().unwrap();
791        let journal = new_journal(&dir).await;
792
793        for i in 0..5 {
794            journal
795                .append_batch(&[RuntimeEvent::RouteRegistered {
796                    route_id: format!("r{i}"),
797                }])
798                .await
799                .unwrap();
800        }
801
802        drop(journal);
803
804        let entries = RedbRuntimeEventJournal::inspect(
805            dir.path().join("test.db"),
806            JournalInspectFilter {
807                route_id: None,
808                limit: 2,
809            },
810        )
811        .await
812        .unwrap();
813
814        assert_eq!(entries.len(), 2);
815        // Newest first: r4, r3
816        assert!(
817            matches!(&entries[0].event, RuntimeEvent::RouteRegistered { route_id } if route_id == "r4")
818        );
819        assert!(
820            matches!(&entries[1].event, RuntimeEvent::RouteRegistered { route_id } if route_id == "r3")
821        );
822    }
823
824    #[tokio::test]
825    async fn redb_journal_inspect_limit_with_filter_returns_matching_count() {
826        let dir = tempdir().unwrap();
827        let journal = new_journal(&dir).await;
828
829        // Interleave alpha and beta events.
830        for i in 0..4 {
831            let rid = if i % 2 == 0 { "alpha" } else { "beta" };
832            journal
833                .append_batch(&[RuntimeEvent::RouteRegistered {
834                    route_id: rid.to_string(),
835                }])
836                .await
837                .unwrap();
838        }
839
840        drop(journal);
841
842        // limit=1 but 2 alpha events exist — should return exactly 1 alpha.
843        let entries = RedbRuntimeEventJournal::inspect(
844            dir.path().join("test.db"),
845            JournalInspectFilter {
846                route_id: Some("alpha".to_string()),
847                limit: 1,
848            },
849        )
850        .await
851        .unwrap();
852
853        assert_eq!(entries.len(), 1);
854        assert!(
855            matches!(&entries[0].event, RuntimeEvent::RouteRegistered { route_id } if route_id == "alpha")
856        );
857    }
858
859    #[test]
860    fn redb_journal_durability_default_is_immediate() {
861        assert_eq!(JournalDurability::default(), JournalDurability::Immediate);
862    }
863
864    #[test]
865    fn redb_journal_options_default() {
866        let opts = RedbJournalOptions::default();
867        assert_eq!(opts.durability, JournalDurability::Immediate);
868        assert_eq!(opts.compaction_threshold_events, 10_000);
869    }
870
871    #[test]
872    fn redb_journal_entry_serialization_roundtrip() {
873        let entry = JournalEntry {
874            seq: 42,
875            timestamp_ms: 1_700_000_000_000,
876            event: RuntimeEvent::RouteFailed {
877                route_id: "fail-route".to_string(),
878                error: "boom".to_string(),
879            },
880        };
881
882        let bytes = serde_json::to_vec(&entry).unwrap();
883        let decoded: JournalEntry = serde_json::from_slice(&bytes).unwrap();
884        assert_eq!(decoded.seq, 42);
885        assert_eq!(decoded.timestamp_ms, 1_700_000_000_000);
886        assert_eq!(decoded.event, entry.event);
887    }
888
889    #[test]
890    fn redb_journal_runtime_event_ext_all_variants() {
891        let events = [
892            RuntimeEvent::RouteRegistered {
893                route_id: "a".into(),
894            },
895            RuntimeEvent::RouteStartRequested {
896                route_id: "b".into(),
897            },
898            RuntimeEvent::RouteStarted {
899                route_id: "c".into(),
900            },
901            RuntimeEvent::RouteFailed {
902                route_id: "d".into(),
903                error: "err".into(),
904            },
905            RuntimeEvent::RouteStopped {
906                route_id: "e".into(),
907            },
908            RuntimeEvent::RouteSuspended {
909                route_id: "f".into(),
910            },
911            RuntimeEvent::RouteResumed {
912                route_id: "g".into(),
913            },
914            RuntimeEvent::RouteReloaded {
915                route_id: "h".into(),
916            },
917            RuntimeEvent::RouteRemoved {
918                route_id: "i".into(),
919            },
920        ];
921        let expected = ["a", "b", "c", "d", "e", "f", "g", "h", "i"];
922        for (event, expected_id) in events.iter().zip(expected.iter()) {
923            assert_eq!(event.route_id(), *expected_id);
924        }
925    }
926
927    #[tokio::test]
928    async fn redb_journal_compaction_no_removed_routes_early_return() {
929        let dir = tempdir().unwrap();
930        let journal = RedbRuntimeEventJournal::new(
931            dir.path().join("no_remove.db"),
932            RedbJournalOptions {
933                durability: JournalDurability::Eventual,
934                compaction_threshold_events: 1,
935            },
936        )
937        .await
938        .unwrap();
939
940        // Only registered and started — no RouteRemoved.
941        journal
942            .append_batch(&[RuntimeEvent::RouteRegistered {
943                route_id: "active".to_string(),
944            }])
945            .await
946            .unwrap();
947        journal
948            .append_batch(&[RuntimeEvent::RouteStarted {
949                route_id: "active".to_string(),
950            }])
951            .await
952            .unwrap();
953
954        let loaded = journal.load_all().await.unwrap();
955        assert_eq!(loaded.len(), 2);
956    }
957
958    #[tokio::test]
959    async fn redb_journal_command_ids_multiple_and_remove_nonexistent() {
960        let dir = tempdir().unwrap();
961        let journal = new_journal(&dir).await;
962
963        journal.append_command_id("cmd1").await.unwrap();
964        journal.append_command_id("cmd2").await.unwrap();
965        journal.append_command_id("cmd3").await.unwrap();
966
967        // Remove a non-existent command — should not error.
968        journal.remove_command_id("nonexistent").await.unwrap();
969
970        let ids = journal.load_command_ids().await.unwrap();
971        assert_eq!(ids.len(), 3);
972        assert!(ids.contains(&"cmd1".to_string()));
973        assert!(ids.contains(&"cmd2".to_string()));
974        assert!(ids.contains(&"cmd3".to_string()));
975    }
976
977    #[tokio::test]
978    async fn redb_journal_multiple_routes_compaction() {
979        let dir = tempdir().unwrap();
980        let journal = RedbRuntimeEventJournal::new(
981            dir.path().join("multi_compact.db"),
982            RedbJournalOptions {
983                durability: JournalDurability::Eventual,
984                compaction_threshold_events: 1,
985            },
986        )
987        .await
988        .unwrap();
989
990        // Two removed routes, one active.
991        journal
992            .append_batch(&[RuntimeEvent::RouteRegistered {
993                route_id: "removed1".to_string(),
994            }])
995            .await
996            .unwrap();
997        journal
998            .append_batch(&[RuntimeEvent::RouteRemoved {
999                route_id: "removed1".to_string(),
1000            }])
1001            .await
1002            .unwrap();
1003        journal
1004            .append_batch(&[RuntimeEvent::RouteRegistered {
1005                route_id: "removed2".to_string(),
1006            }])
1007            .await
1008            .unwrap();
1009        journal
1010            .append_batch(&[RuntimeEvent::RouteRemoved {
1011                route_id: "removed2".to_string(),
1012            }])
1013            .await
1014            .unwrap();
1015        journal
1016            .append_batch(&[RuntimeEvent::RouteRegistered {
1017                route_id: "kept".to_string(),
1018            }])
1019            .await
1020            .unwrap();
1021
1022        let loaded = journal.load_all().await.unwrap();
1023        assert!(
1024            !loaded.iter().any(|e| matches!(e, RuntimeEvent::RouteRegistered { route_id } if route_id == "removed1" || route_id == "removed2")),
1025            "removed routes must be compacted"
1026        );
1027        assert!(
1028            loaded.iter().any(
1029                |e| matches!(e, RuntimeEvent::RouteRegistered { route_id } if route_id == "kept")
1030            ),
1031            "kept route must survive"
1032        );
1033    }
1034}