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/// Max attempts to acquire the redb file lock on open. Covers the Kubernetes
34/// ReadWriteOnce PVC handover window where a terminating pod has not yet
35/// released the lock when the replacement pod starts.
36const OPEN_LOCK_MAX_ATTEMPTS: u32 = 40;
37/// Delay between open attempts (40 × 250ms ≈ 10s total handover budget).
38const OPEN_LOCK_RETRY_BACKOFF: std::time::Duration = std::time::Duration::from_millis(250);
39
40/// True when a redb open error is the "another handle holds the lock" case,
41/// which is transient during pod handover and worth retrying. Matched on the
42/// Display text to stay independent of the redb error-enum shape across versions.
43fn is_lock_contention(err: &redb::DatabaseError) -> bool {
44    matches!(err, redb::DatabaseError::DatabaseAlreadyOpen) || {
45        let msg = err.to_string().to_ascii_lowercase();
46        msg.contains("already open") || msg.contains("acquire lock")
47    }
48}
49
50// ── Public types ──────────────────────────────────────────────────────────────
51
52/// Durability mode for journal writes.
53#[derive(Debug, Clone, PartialEq, Default)]
54pub enum JournalDurability {
55    /// fsync on every commit — protects against kernel crash and power loss (default).
56    #[default]
57    Immediate,
58    /// No fsync — OS decides flush timing. Suitable for dev/test.
59    Eventual,
60}
61
62/// Options for `RedbRuntimeEventJournal`.
63#[derive(Debug, Clone)]
64pub struct RedbJournalOptions {
65    pub durability: JournalDurability,
66    /// Trigger compaction after this many events in the table. Default: 10_000.
67    pub compaction_threshold_events: u64,
68}
69
70impl Default for RedbJournalOptions {
71    fn default() -> Self {
72        Self {
73            durability: JournalDurability::Immediate,
74            compaction_threshold_events: 10_000,
75        }
76    }
77}
78
79/// Internal wire format stored as redb value bytes (serde_json).
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct JournalEntry {
82    pub seq: u64,
83    pub timestamp_ms: i64,
84    #[serde(with = "runtime_event_serde")]
85    pub event: RuntimeEvent,
86}
87
88/// Filter for `RedbRuntimeEventJournal::inspect`.
89pub struct JournalInspectFilter {
90    pub route_id: Option<String>,
91    pub limit: usize,
92}
93
94// ── Adapter ───────────────────────────────────────────────────────────────────
95
96/// Redb-backed implementation of `RuntimeEventJournalPort`.
97///
98/// `Arc<Database>` allows cheap cloning — all clones share the same underlying
99/// redb file handle. `redb::Database` is `Send + Sync`.
100#[derive(Clone)]
101pub struct RedbRuntimeEventJournal {
102    db: Arc<Database>,
103    options: RedbJournalOptions,
104}
105
106impl RedbRuntimeEventJournal {
107    /// Open (or create) the redb database at `path`.
108    ///
109    /// Parent directories are created if they do not exist.
110    /// Both tables are initialised on first open.
111    /// Uses `tokio::task::spawn_blocking` because `Database::open` is blocking.
112    pub async fn new(
113        path: impl Into<PathBuf>,
114        options: RedbJournalOptions,
115    ) -> Result<Self, CamelError> {
116        let path = path.into();
117        let db = tokio::task::spawn_blocking(move || {
118            if let Some(parent) = path.parent() {
119                std::fs::create_dir_all(parent).map_err(|e| {
120                    CamelError::Io(format!(
121                        "failed to create journal directory '{}': {e}",
122                        parent.display()
123                    ))
124                })?;
125            }
126            // Retry on transient lock contention (k8s RWO PVC pod handover):
127            // the old pod may still hold the file lock when the new one opens.
128            let mut attempt: u32 = 0;
129            let db = loop {
130                attempt += 1;
131                match Database::create(&path) {
132                    Ok(db) => break db,
133                    Err(e) if is_lock_contention(&e) && attempt < OPEN_LOCK_MAX_ATTEMPTS => {
134                        tracing::warn!(
135                            path = %path.display(),
136                            attempt,
137                            max_attempts = OPEN_LOCK_MAX_ATTEMPTS,
138                            "journal file locked by another handle (pod handover?), retrying"
139                        );
140                        std::thread::sleep(OPEN_LOCK_RETRY_BACKOFF);
141                    }
142                    Err(e) => {
143                        return Err(CamelError::Io(format!(
144                            "failed to open journal at '{}': {e}",
145                            path.display()
146                        )));
147                    }
148                }
149            };
150            // Initialise tables so they exist before any reads.
151            let tx = db
152                .begin_write()
153                .map_err(|e| CamelError::Io(format!("redb begin_write: {e}")))?;
154            tx.open_table(EVENTS_TABLE)
155                .map_err(|e| CamelError::Io(format!("redb open events table: {e}")))?;
156            tx.open_table(COMMAND_IDS_TABLE)
157                .map_err(|e| CamelError::Io(format!("redb open command_ids table: {e}")))?;
158            tx.commit()
159                .map_err(|e| CamelError::Io(format!("redb commit init: {e}")))?;
160            Ok::<_, CamelError>(db)
161        })
162        .await
163        .map_err(|e| CamelError::Io(format!("spawn_blocking join: {e}")))??;
164
165        Ok(Self {
166            db: Arc::new(db),
167            options,
168        })
169    }
170
171    /// Open an existing database at `path` and return entries (newest-first, up to `filter.limit`).
172    ///
173    /// Uses `Database::open` + `begin_read` — concurrent with a live writer on the same file.
174    /// `inspect` is an offline utility: it does NOT require a live `RedbRuntimeEventJournal` instance.
175    pub async fn inspect(
176        path: impl Into<PathBuf>,
177        filter: JournalInspectFilter,
178    ) -> Result<Vec<JournalEntry>, CamelError> {
179        let path = path.into();
180        let limit = filter.limit;
181        let route_id = filter.route_id;
182        tokio::task::spawn_blocking(move || {
183            if !path.exists() {
184                return Err(CamelError::Io(format!(
185                    "journal file not found: {}",
186                    path.display()
187                )));
188            }
189            let db = Database::open(&path)
190                .map_err(|e| CamelError::Io(format!("invalid journal file: {e}")))?;
191            let tx = db
192                .begin_read()
193                .map_err(|e| CamelError::Io(format!("redb begin_read: {e}")))?;
194            let table = tx
195                .open_table(EVENTS_TABLE)
196                .map_err(|e| CamelError::Io(format!("redb open events: {e}")))?;
197
198            // Collect in descending order (newest first).
199            // Filter by route_id FIRST, then apply limit — ensures we return
200            // `limit` matching entries, not `limit` total entries where most may
201            // not match the filter.
202            let mut entries: Vec<JournalEntry> = Vec::new();
203            for result in table
204                .iter()
205                .map_err(|e| CamelError::Io(format!("redb iter: {e}")))?
206                .rev()
207            {
208                let (_k, v) = result.map_err(|e| CamelError::Io(format!("redb read: {e}")))?;
209                let entry: JournalEntry = serde_json::from_slice(v.value())
210                    .map_err(|e| CamelError::Io(format!("journal deserialize: {e}")))?;
211                if let Some(ref rid) = route_id
212                    && entry.event.route_id() != rid.as_str()
213                {
214                    continue;
215                }
216                if entries.len() >= limit {
217                    break;
218                }
219                entries.push(entry);
220            }
221            Ok(entries)
222        })
223        .await
224        .map_err(|e| CamelError::Io(format!("spawn_blocking join: {e}")))?
225    }
226
227    // ── Internal helpers ──────────────────────────────────────────────────────
228
229    fn redb_durability(&self) -> Durability {
230        match self.options.durability {
231            JournalDurability::Immediate => Durability::Immediate,
232            JournalDurability::Eventual => Durability::None,
233        }
234    }
235
236    /// Derive next sequence number from the last key in the events table.
237    /// Must be called inside a write transaction with the table already open.
238    fn next_seq(table: &redb::Table<u64, &[u8]>) -> Result<u64, CamelError> {
239        match table
240            .iter()
241            .map_err(|e| CamelError::Io(format!("redb iter for seq: {e}")))?
242            .next_back()
243        {
244            Some(Ok((k, _))) => Ok(k.value() + 1),
245            Some(Err(e)) => Err(CamelError::Io(format!("redb seq read: {e}"))),
246            None => Ok(0),
247        }
248    }
249
250    /// Count rows in the events table (read transaction).
251    fn event_count(&self) -> Result<u64, CamelError> {
252        let tx = self
253            .db
254            .begin_read()
255            .map_err(|e| CamelError::Io(format!("redb begin_read: {e}")))?;
256        let table = tx
257            .open_table(EVENTS_TABLE)
258            .map_err(|e| CamelError::Io(format!("redb open events: {e}")))?;
259        table
260            .len()
261            .map_err(|e| CamelError::Io(format!("redb len: {e}")))
262    }
263
264    /// Compact the events table by dropping events that no longer affect replay.
265    ///
266    /// Two provably replay-equivalent rules run together, per route id:
267    /// - **Removal cutoff:** every event with `seq <= last RouteRemoved seq` is
268    ///   dropped — a removed route contributes nothing until it is re-registered.
269    /// - **Register checkpoint:** every event with `seq < last RouteRegistered
270    ///   seq` is dropped. Replaying a RouteRegistered resets the aggregate to a
271    ///   fresh `Registered` v0 (see `apply_replayed_event`), so all prior history
272    ///   for that route is redundant. This bounds journal growth under
273    ///   adopt-on-recovery, where declarative routes re-append their
274    ///   `[Registered, StartRequested, Started]` generation on every boot.
275    fn compact(&self) -> Result<(), CamelError> {
276        let tx = self
277            .db
278            .begin_write()
279            .map_err(|e| CamelError::Io(format!("redb begin_write: {e}")))?;
280        {
281            let mut table = tx
282                .open_table(EVENTS_TABLE)
283                .map_err(|e| CamelError::Io(format!("redb open events: {e}")))?;
284
285            // Pass 1: find the last RouteRemoved and last RouteRegistered seq per route.
286            let mut last_removed_seq: std::collections::HashMap<String, u64> =
287                std::collections::HashMap::new();
288            let mut last_registered_seq: std::collections::HashMap<String, u64> =
289                std::collections::HashMap::new();
290            for result in table
291                .iter()
292                .map_err(|e| CamelError::Io(format!("redb iter: {e}")))?
293            {
294                let (k, v) = result.map_err(|e| CamelError::Io(format!("redb read: {e}")))?;
295                let seq = k.value();
296                let entry: JournalEntry = serde_json::from_slice(v.value())
297                    .map_err(|e| CamelError::Io(format!("journal deserialize: {e}")))?;
298                match entry.event {
299                    RuntimeEvent::RouteRemoved { .. } => {
300                        last_removed_seq.insert(entry.event.route_id().to_string(), seq);
301                    }
302                    RuntimeEvent::RouteRegistered { .. } => {
303                        last_registered_seq.insert(entry.event.route_id().to_string(), seq);
304                    }
305                    _ => {}
306                }
307            }
308
309            if last_removed_seq.is_empty() && last_registered_seq.is_empty() {
310                drop(table);
311                tx.commit()
312                    .map_err(|e| CamelError::Io(format!("redb commit compact: {e}")))?;
313                return Ok(());
314            }
315
316            // Pass 2: collect seqs to delete under either rule.
317            let mut to_delete: Vec<u64> = Vec::new();
318            for result in table
319                .iter()
320                .map_err(|e| CamelError::Io(format!("redb iter pass2: {e}")))?
321            {
322                let (k, v) = result.map_err(|e| CamelError::Io(format!("redb read: {e}")))?;
323                let seq = k.value();
324                let entry: JournalEntry = serde_json::from_slice(v.value())
325                    .map_err(|e| CamelError::Io(format!("journal deserialize: {e}")))?;
326                let route_id = entry.event.route_id().to_string();
327                let removed = last_removed_seq
328                    .get(&route_id)
329                    .is_some_and(|&cutoff| seq <= cutoff);
330                let superseded = last_registered_seq
331                    .get(&route_id)
332                    .is_some_and(|&checkpoint| seq < checkpoint);
333                if removed || superseded {
334                    to_delete.push(seq);
335                }
336            }
337
338            for seq in to_delete {
339                table
340                    .remove(&seq)
341                    .map_err(|e| CamelError::Io(format!("redb remove seq {seq}: {e}")))?;
342            }
343        }
344        tx.commit()
345            .map_err(|e| CamelError::Io(format!("redb commit compact: {e}")))?;
346        Ok(())
347    }
348}
349
350// ── RuntimeEvent helper ───────────────────────────────────────────────────────
351
352/// Extension to extract the `route_id` field from any `RuntimeEvent` variant.
353trait RuntimeEventExt {
354    fn route_id(&self) -> &str;
355}
356
357impl RuntimeEventExt for RuntimeEvent {
358    fn route_id(&self) -> &str {
359        match self {
360            RuntimeEvent::RouteRegistered { route_id }
361            | RuntimeEvent::RouteStartRequested { route_id }
362            | RuntimeEvent::RouteStarted { route_id }
363            | RuntimeEvent::RouteFailed { route_id, .. }
364            | RuntimeEvent::RouteStopped { route_id }
365            | RuntimeEvent::RouteSuspended { route_id }
366            | RuntimeEvent::RouteResumed { route_id }
367            | RuntimeEvent::RouteReloaded { route_id }
368            | RuntimeEvent::RouteRemoved { route_id } => route_id,
369        }
370    }
371}
372
373// ── RuntimeEventJournalPort impl ──────────────────────────────────────────────
374
375#[async_trait]
376impl RuntimeEventJournalPort for RedbRuntimeEventJournal {
377    async fn append_batch(&self, events: &[RuntimeEvent]) -> Result<(), DomainError> {
378        if events.is_empty() {
379            return Ok(());
380        }
381        let db = Arc::clone(&self.db);
382        let durability = self.redb_durability();
383        let events = events.to_vec();
384        let now_ms = chrono::Utc::now().timestamp_millis();
385
386        tokio::task::spawn_blocking(move || {
387            // NOTE: `mut` is required — `set_durability` takes `&mut self` in redb v2.
388            let mut tx = db
389                .begin_write()
390                .map_err(|e| CamelError::Io(format!("redb begin_write: {e}")))?;
391            tx.set_durability(durability)
392                .map_err(|e| CamelError::Io(format!("redb set_durability: {e}")))?;
393            {
394                let mut table = tx
395                    .open_table(EVENTS_TABLE)
396                    .map_err(|e| CamelError::Io(format!("redb open events: {e}")))?;
397                let start_seq = Self::next_seq(&table)?;
398                for (next_seq, event) in (start_seq..).zip(events) {
399                    let entry = JournalEntry {
400                        seq: next_seq,
401                        timestamp_ms: now_ms,
402                        event,
403                    };
404                    let bytes = serde_json::to_vec(&entry)
405                        .map_err(|e| CamelError::Io(format!("journal serialize: {e}")))?;
406                    table
407                        .insert(&next_seq, bytes.as_slice())
408                        .map_err(|e| CamelError::Io(format!("redb insert: {e}")))?;
409                }
410            }
411            tx.commit()
412                .map_err(|e| CamelError::Io(format!("redb commit: {e}")))?;
413            Ok::<_, CamelError>(())
414        })
415        .await
416        .map_err(|e| DomainError::InvalidState(format!("spawn_blocking join: {e}")))?
417        .map_err(|e| DomainError::InvalidState(e.to_string()))?;
418
419        // Trigger compaction if threshold exceeded. Non-fatal if it fails.
420        // Both event_count() and compact() do blocking redb I/O — run in spawn_blocking.
421        let journal_clone = self.clone();
422        let threshold = self.options.compaction_threshold_events;
423        tokio::task::spawn_blocking(move || match journal_clone.event_count() {
424            Ok(count) if count >= threshold => {
425                if let Err(e) = journal_clone.compact() {
426                    tracing::warn!("journal compaction failed (non-fatal): {e}");
427                }
428            }
429            Ok(_) => {}
430            Err(e) => {
431                tracing::warn!("journal event count check failed (non-fatal): {e}");
432            }
433        })
434        .await
435        .ok(); // Non-fatal: if spawn_blocking panics, we ignore it
436
437        Ok(())
438    }
439
440    async fn load_all(&self) -> Result<Vec<RuntimeEvent>, DomainError> {
441        let db = Arc::clone(&self.db);
442        tokio::task::spawn_blocking(move || {
443            let tx = db
444                .begin_read()
445                .map_err(|e| CamelError::Io(format!("redb begin_read: {e}")))?;
446            let table = tx
447                .open_table(EVENTS_TABLE)
448                .map_err(|e| CamelError::Io(format!("redb open events: {e}")))?;
449            let mut events = Vec::new();
450            for result in table
451                .iter()
452                .map_err(|e| CamelError::Io(format!("redb iter: {e}")))?
453            {
454                let (_k, v) = result.map_err(|e| CamelError::Io(format!("redb read: {e}")))?;
455                let entry: JournalEntry = serde_json::from_slice(v.value())
456                    .map_err(|e| CamelError::Io(format!("journal deserialize: {e}")))?;
457                events.push(entry.event);
458            }
459            Ok(events)
460        })
461        .await
462        .map_err(|e| DomainError::InvalidState(format!("spawn_blocking join: {e}")))?
463        .map_err(|e: CamelError| DomainError::InvalidState(e.to_string()))
464    }
465
466    async fn append_command_id(&self, command_id: &str) -> Result<(), DomainError> {
467        let db = Arc::clone(&self.db);
468        let durability = self.redb_durability();
469        let id = command_id.to_string();
470        tokio::task::spawn_blocking(move || {
471            // NOTE: `mut` required — `set_durability` takes `&mut self` in redb v2.
472            let mut tx = db
473                .begin_write()
474                .map_err(|e| CamelError::Io(format!("redb begin_write: {e}")))?;
475            tx.set_durability(durability)
476                .map_err(|e| CamelError::Io(format!("redb set_durability: {e}")))?;
477            {
478                let mut table = tx
479                    .open_table(COMMAND_IDS_TABLE)
480                    .map_err(|e| CamelError::Io(format!("redb open command_ids: {e}")))?;
481                table
482                    .insert(id.as_str(), ())
483                    .map_err(|e| CamelError::Io(format!("redb insert command_id: {e}")))?;
484            }
485            tx.commit()
486                .map_err(|e| CamelError::Io(format!("redb commit: {e}")))?;
487            Ok::<_, CamelError>(())
488        })
489        .await
490        .map_err(|e| DomainError::InvalidState(format!("spawn_blocking join: {e}")))?
491        .map_err(|e| DomainError::InvalidState(e.to_string()))
492    }
493
494    async fn remove_command_id(&self, command_id: &str) -> Result<(), DomainError> {
495        let db = Arc::clone(&self.db);
496        let durability = self.redb_durability();
497        let id = command_id.to_string();
498        tokio::task::spawn_blocking(move || {
499            // NOTE: `mut` required — `set_durability` takes `&mut self` in redb v2.
500            let mut tx = db
501                .begin_write()
502                .map_err(|e| CamelError::Io(format!("redb begin_write: {e}")))?;
503            tx.set_durability(durability)
504                .map_err(|e| CamelError::Io(format!("redb set_durability: {e}")))?;
505            {
506                let mut table = tx
507                    .open_table(COMMAND_IDS_TABLE)
508                    .map_err(|e| CamelError::Io(format!("redb open command_ids: {e}")))?;
509                table
510                    .remove(id.as_str())
511                    .map_err(|e| CamelError::Io(format!("redb remove command_id: {e}")))?;
512            }
513            tx.commit()
514                .map_err(|e| CamelError::Io(format!("redb commit: {e}")))?;
515            Ok::<_, CamelError>(())
516        })
517        .await
518        .map_err(|e| DomainError::InvalidState(format!("spawn_blocking join: {e}")))?
519        .map_err(|e| DomainError::InvalidState(e.to_string()))
520    }
521
522    async fn load_command_ids(&self) -> Result<Vec<String>, DomainError> {
523        let db = Arc::clone(&self.db);
524        tokio::task::spawn_blocking(move || {
525            let tx = db
526                .begin_read()
527                .map_err(|e| CamelError::Io(format!("redb begin_read: {e}")))?;
528            let table = tx
529                .open_table(COMMAND_IDS_TABLE)
530                .map_err(|e| CamelError::Io(format!("redb open command_ids: {e}")))?;
531            let mut ids = Vec::new();
532            for result in table
533                .iter()
534                .map_err(|e| CamelError::Io(format!("redb iter: {e}")))?
535            {
536                let (k, _) = result.map_err(|e| CamelError::Io(format!("redb read: {e}")))?;
537                ids.push(k.value().to_string());
538            }
539            Ok(ids)
540        })
541        .await
542        .map_err(|e| DomainError::InvalidState(format!("spawn_blocking join: {e}")))?
543        .map_err(|e: CamelError| DomainError::InvalidState(e.to_string()))
544    }
545}
546
547// ── Unit tests ────────────────────────────────────────────────────────────────
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552    use tempfile::tempdir;
553
554    async fn new_journal(dir: &tempfile::TempDir) -> RedbRuntimeEventJournal {
555        RedbRuntimeEventJournal::new(dir.path().join("test.db"), RedbJournalOptions::default())
556            .await
557            .unwrap()
558    }
559
560    #[tokio::test]
561    async fn redb_journal_roundtrip() {
562        let dir = tempdir().unwrap();
563        let journal = new_journal(&dir).await;
564
565        let events = vec![
566            RuntimeEvent::RouteRegistered {
567                route_id: "r1".to_string(),
568            },
569            RuntimeEvent::RouteStarted {
570                route_id: "r1".to_string(),
571            },
572        ];
573        journal.append_batch(&events).await.unwrap();
574
575        let loaded = journal.load_all().await.unwrap();
576        assert_eq!(loaded, events);
577    }
578
579    #[tokio::test]
580    async fn redb_journal_command_id_lifecycle() {
581        let dir = tempdir().unwrap();
582        let journal = new_journal(&dir).await;
583
584        journal.append_command_id("c1").await.unwrap();
585        journal.append_command_id("c2").await.unwrap();
586        journal.remove_command_id("c1").await.unwrap();
587
588        let ids = journal.load_command_ids().await.unwrap();
589        assert_eq!(ids, vec!["c2".to_string()]);
590    }
591
592    #[tokio::test]
593    async fn redb_journal_compaction_removes_completed_routes() {
594        let dir = tempdir().unwrap();
595        // Threshold of 1 triggers compaction on every append.
596        let journal = RedbRuntimeEventJournal::new(
597            dir.path().join("compact.db"),
598            RedbJournalOptions {
599                durability: JournalDurability::Eventual,
600                compaction_threshold_events: 1,
601            },
602        )
603        .await
604        .unwrap();
605
606        // Removed route — full lifecycle.
607        journal
608            .append_batch(&[RuntimeEvent::RouteRegistered {
609                route_id: "old".to_string(),
610            }])
611            .await
612            .unwrap();
613        journal
614            .append_batch(&[RuntimeEvent::RouteRemoved {
615                route_id: "old".to_string(),
616            }])
617            .await
618            .unwrap();
619
620        // Active route — no RouteRemoved.
621        journal
622            .append_batch(&[RuntimeEvent::RouteRegistered {
623                route_id: "live".to_string(),
624            }])
625            .await
626            .unwrap();
627
628        let loaded = journal.load_all().await.unwrap();
629        assert!(
630            !loaded.iter().any(
631                |e| matches!(e, RuntimeEvent::RouteRegistered { route_id } if route_id == "old")
632            ),
633            "old route events must be compacted"
634        );
635        assert!(
636            loaded.iter().any(
637                |e| matches!(e, RuntimeEvent::RouteRegistered { route_id } if route_id == "live")
638            ),
639            "live route events must survive compaction"
640        );
641    }
642
643    #[tokio::test]
644    async fn redb_journal_compaction_preserves_reregistered_route() {
645        let dir = tempdir().unwrap();
646        let journal = RedbRuntimeEventJournal::new(
647            dir.path().join("rereg.db"),
648            RedbJournalOptions {
649                durability: JournalDurability::Eventual,
650                compaction_threshold_events: 1,
651            },
652        )
653        .await
654        .unwrap();
655
656        journal
657            .append_batch(&[RuntimeEvent::RouteRegistered {
658                route_id: "rereg".to_string(),
659            }])
660            .await
661            .unwrap();
662        journal
663            .append_batch(&[RuntimeEvent::RouteRemoved {
664                route_id: "rereg".to_string(),
665            }])
666            .await
667            .unwrap();
668        journal
669            .append_batch(&[RuntimeEvent::RouteRegistered {
670                route_id: "rereg".to_string(),
671            }])
672            .await
673            .unwrap();
674
675        let loaded = journal.load_all().await.unwrap();
676        let rereg_count = loaded
677            .iter()
678            .filter(
679                |e| matches!(e, RuntimeEvent::RouteRegistered { route_id } if route_id == "rereg"),
680            )
681            .count();
682        assert_eq!(
683            rereg_count, 1,
684            "re-registered route must have exactly one event after compaction"
685        );
686    }
687
688    #[tokio::test]
689    async fn redb_journal_durability_eventual() {
690        let dir = tempdir().unwrap();
691        let journal = RedbRuntimeEventJournal::new(
692            dir.path().join("eventual.db"),
693            RedbJournalOptions {
694                durability: JournalDurability::Eventual,
695                compaction_threshold_events: 10_000,
696            },
697        )
698        .await
699        .unwrap();
700
701        journal
702            .append_batch(&[RuntimeEvent::RouteRegistered {
703                route_id: "ev".to_string(),
704            }])
705            .await
706            .unwrap();
707        let loaded = journal.load_all().await.unwrap();
708        assert_eq!(loaded.len(), 1);
709    }
710
711    #[tokio::test]
712    async fn redb_journal_clone_shares_db() {
713        let dir = tempdir().unwrap();
714        let j1 = new_journal(&dir).await;
715        let j2 = j1.clone();
716
717        j1.append_batch(&[RuntimeEvent::RouteRegistered {
718            route_id: "shared".to_string(),
719        }])
720        .await
721        .unwrap();
722
723        // j2 must see j1's write since they share the same Arc<Database>.
724        let loaded = j2.load_all().await.unwrap();
725        assert_eq!(loaded.len(), 1);
726    }
727
728    #[tokio::test]
729    async fn redb_journal_append_empty_batch_is_noop() {
730        let dir = tempdir().unwrap();
731        let journal = new_journal(&dir).await;
732
733        journal.append_batch(&[]).await.unwrap();
734        let loaded = journal.load_all().await.unwrap();
735        assert!(loaded.is_empty());
736    }
737
738    #[tokio::test]
739    async fn redb_journal_sequence_numbers_across_batches() {
740        let dir = tempdir().unwrap();
741        let journal = new_journal(&dir).await;
742
743        journal
744            .append_batch(&[
745                RuntimeEvent::RouteRegistered {
746                    route_id: "r1".to_string(),
747                },
748                RuntimeEvent::RouteStarted {
749                    route_id: "r1".to_string(),
750                },
751            ])
752            .await
753            .unwrap();
754
755        journal
756            .append_batch(&[RuntimeEvent::RouteStopped {
757                route_id: "r1".to_string(),
758            }])
759            .await
760            .unwrap();
761
762        let loaded = journal.load_all().await.unwrap();
763        assert_eq!(loaded.len(), 3);
764
765        // Drop journal to release redb lock before inspect.
766        drop(journal);
767
768        // Verify sequence numbers are monotonic.
769        let entries = RedbRuntimeEventJournal::inspect(
770            dir.path().join("test.db"),
771            JournalInspectFilter {
772                route_id: None,
773                limit: 10,
774            },
775        )
776        .await
777        .unwrap();
778        let seqs: Vec<u64> = entries.iter().map(|e| e.seq).collect();
779        // inspect returns newest-first.
780        assert_eq!(seqs, vec![2, 1, 0]);
781    }
782
783    #[tokio::test]
784    async fn redb_journal_load_all_empty() {
785        let dir = tempdir().unwrap();
786        let journal = new_journal(&dir).await;
787        let loaded = journal.load_all().await.unwrap();
788        assert!(loaded.is_empty());
789    }
790
791    #[tokio::test]
792    async fn redb_journal_inspect_file_not_found() {
793        let dir = tempdir().unwrap();
794        let result = RedbRuntimeEventJournal::inspect(
795            dir.path().join("nonexistent.db"),
796            JournalInspectFilter {
797                route_id: None,
798                limit: 10,
799            },
800        )
801        .await;
802        assert!(result.is_err());
803        let err = result.unwrap_err().to_string();
804        assert!(err.contains("journal file not found"));
805    }
806
807    #[tokio::test]
808    async fn redb_journal_inspect_with_route_id_filter() {
809        let dir = tempdir().unwrap();
810        let journal = new_journal(&dir).await;
811
812        journal
813            .append_batch(&[
814                RuntimeEvent::RouteRegistered {
815                    route_id: "alpha".to_string(),
816                },
817                RuntimeEvent::RouteRegistered {
818                    route_id: "beta".to_string(),
819                },
820                RuntimeEvent::RouteStarted {
821                    route_id: "alpha".to_string(),
822                },
823            ])
824            .await
825            .unwrap();
826
827        drop(journal);
828
829        let entries = RedbRuntimeEventJournal::inspect(
830            dir.path().join("test.db"),
831            JournalInspectFilter {
832                route_id: Some("alpha".to_string()),
833                limit: 10,
834            },
835        )
836        .await
837        .unwrap();
838
839        assert_eq!(entries.len(), 2);
840        assert!(entries.iter().all(|e| {
841            matches!(&e.event, RuntimeEvent::RouteRegistered { route_id } | RuntimeEvent::RouteStarted { route_id } if route_id == "alpha")
842        }));
843    }
844
845    #[tokio::test]
846    async fn redb_journal_inspect_limit_enforcement() {
847        let dir = tempdir().unwrap();
848        let journal = new_journal(&dir).await;
849
850        for i in 0..5 {
851            journal
852                .append_batch(&[RuntimeEvent::RouteRegistered {
853                    route_id: format!("r{i}"),
854                }])
855                .await
856                .unwrap();
857        }
858
859        drop(journal);
860
861        let entries = RedbRuntimeEventJournal::inspect(
862            dir.path().join("test.db"),
863            JournalInspectFilter {
864                route_id: None,
865                limit: 2,
866            },
867        )
868        .await
869        .unwrap();
870
871        assert_eq!(entries.len(), 2);
872        // Newest first: r4, r3
873        assert!(
874            matches!(&entries[0].event, RuntimeEvent::RouteRegistered { route_id } if route_id == "r4")
875        );
876        assert!(
877            matches!(&entries[1].event, RuntimeEvent::RouteRegistered { route_id } if route_id == "r3")
878        );
879    }
880
881    #[tokio::test]
882    async fn redb_journal_inspect_limit_with_filter_returns_matching_count() {
883        let dir = tempdir().unwrap();
884        let journal = new_journal(&dir).await;
885
886        // Interleave alpha and beta events.
887        for i in 0..4 {
888            let rid = if i % 2 == 0 { "alpha" } else { "beta" };
889            journal
890                .append_batch(&[RuntimeEvent::RouteRegistered {
891                    route_id: rid.to_string(),
892                }])
893                .await
894                .unwrap();
895        }
896
897        drop(journal);
898
899        // limit=1 but 2 alpha events exist — should return exactly 1 alpha.
900        let entries = RedbRuntimeEventJournal::inspect(
901            dir.path().join("test.db"),
902            JournalInspectFilter {
903                route_id: Some("alpha".to_string()),
904                limit: 1,
905            },
906        )
907        .await
908        .unwrap();
909
910        assert_eq!(entries.len(), 1);
911        assert!(
912            matches!(&entries[0].event, RuntimeEvent::RouteRegistered { route_id } if route_id == "alpha")
913        );
914    }
915
916    #[test]
917    fn redb_journal_durability_default_is_immediate() {
918        assert_eq!(JournalDurability::default(), JournalDurability::Immediate);
919    }
920
921    #[test]
922    fn redb_journal_options_default() {
923        let opts = RedbJournalOptions::default();
924        assert_eq!(opts.durability, JournalDurability::Immediate);
925        assert_eq!(opts.compaction_threshold_events, 10_000);
926    }
927
928    #[test]
929    fn redb_journal_entry_serialization_roundtrip() {
930        let entry = JournalEntry {
931            seq: 42,
932            timestamp_ms: 1_700_000_000_000,
933            event: RuntimeEvent::RouteFailed {
934                route_id: "fail-route".to_string(),
935                error: "boom".to_string(),
936            },
937        };
938
939        let bytes = serde_json::to_vec(&entry).unwrap();
940        let decoded: JournalEntry = serde_json::from_slice(&bytes).unwrap();
941        assert_eq!(decoded.seq, 42);
942        assert_eq!(decoded.timestamp_ms, 1_700_000_000_000);
943        assert_eq!(decoded.event, entry.event);
944    }
945
946    #[test]
947    fn redb_journal_runtime_event_ext_all_variants() {
948        let events = [
949            RuntimeEvent::RouteRegistered {
950                route_id: "a".into(),
951            },
952            RuntimeEvent::RouteStartRequested {
953                route_id: "b".into(),
954            },
955            RuntimeEvent::RouteStarted {
956                route_id: "c".into(),
957            },
958            RuntimeEvent::RouteFailed {
959                route_id: "d".into(),
960                error: "err".into(),
961            },
962            RuntimeEvent::RouteStopped {
963                route_id: "e".into(),
964            },
965            RuntimeEvent::RouteSuspended {
966                route_id: "f".into(),
967            },
968            RuntimeEvent::RouteResumed {
969                route_id: "g".into(),
970            },
971            RuntimeEvent::RouteReloaded {
972                route_id: "h".into(),
973            },
974            RuntimeEvent::RouteRemoved {
975                route_id: "i".into(),
976            },
977        ];
978        let expected = ["a", "b", "c", "d", "e", "f", "g", "h", "i"];
979        for (event, expected_id) in events.iter().zip(expected.iter()) {
980            assert_eq!(event.route_id(), *expected_id);
981        }
982    }
983
984    #[tokio::test]
985    async fn redb_journal_compaction_no_removed_routes_early_return() {
986        let dir = tempdir().unwrap();
987        let journal = RedbRuntimeEventJournal::new(
988            dir.path().join("no_remove.db"),
989            RedbJournalOptions {
990                durability: JournalDurability::Eventual,
991                compaction_threshold_events: 1,
992            },
993        )
994        .await
995        .unwrap();
996
997        // Only registered and started — no RouteRemoved.
998        journal
999            .append_batch(&[RuntimeEvent::RouteRegistered {
1000                route_id: "active".to_string(),
1001            }])
1002            .await
1003            .unwrap();
1004        journal
1005            .append_batch(&[RuntimeEvent::RouteStarted {
1006                route_id: "active".to_string(),
1007            }])
1008            .await
1009            .unwrap();
1010
1011        let loaded = journal.load_all().await.unwrap();
1012        assert_eq!(loaded.len(), 2);
1013    }
1014
1015    #[tokio::test]
1016    async fn redb_journal_command_ids_multiple_and_remove_nonexistent() {
1017        let dir = tempdir().unwrap();
1018        let journal = new_journal(&dir).await;
1019
1020        journal.append_command_id("cmd1").await.unwrap();
1021        journal.append_command_id("cmd2").await.unwrap();
1022        journal.append_command_id("cmd3").await.unwrap();
1023
1024        // Remove a non-existent command — should not error.
1025        journal.remove_command_id("nonexistent").await.unwrap();
1026
1027        let ids = journal.load_command_ids().await.unwrap();
1028        assert_eq!(ids.len(), 3);
1029        assert!(ids.contains(&"cmd1".to_string()));
1030        assert!(ids.contains(&"cmd2".to_string()));
1031        assert!(ids.contains(&"cmd3".to_string()));
1032    }
1033
1034    #[tokio::test]
1035    async fn redb_journal_multiple_routes_compaction() {
1036        let dir = tempdir().unwrap();
1037        let journal = RedbRuntimeEventJournal::new(
1038            dir.path().join("multi_compact.db"),
1039            RedbJournalOptions {
1040                durability: JournalDurability::Eventual,
1041                compaction_threshold_events: 1,
1042            },
1043        )
1044        .await
1045        .unwrap();
1046
1047        // Two removed routes, one active.
1048        journal
1049            .append_batch(&[RuntimeEvent::RouteRegistered {
1050                route_id: "removed1".to_string(),
1051            }])
1052            .await
1053            .unwrap();
1054        journal
1055            .append_batch(&[RuntimeEvent::RouteRemoved {
1056                route_id: "removed1".to_string(),
1057            }])
1058            .await
1059            .unwrap();
1060        journal
1061            .append_batch(&[RuntimeEvent::RouteRegistered {
1062                route_id: "removed2".to_string(),
1063            }])
1064            .await
1065            .unwrap();
1066        journal
1067            .append_batch(&[RuntimeEvent::RouteRemoved {
1068                route_id: "removed2".to_string(),
1069            }])
1070            .await
1071            .unwrap();
1072        journal
1073            .append_batch(&[RuntimeEvent::RouteRegistered {
1074                route_id: "kept".to_string(),
1075            }])
1076            .await
1077            .unwrap();
1078
1079        let loaded = journal.load_all().await.unwrap();
1080        assert!(
1081            !loaded.iter().any(|e| matches!(e, RuntimeEvent::RouteRegistered { route_id } if route_id == "removed1" || route_id == "removed2")),
1082            "removed routes must be compacted"
1083        );
1084        assert!(
1085            loaded.iter().any(
1086                |e| matches!(e, RuntimeEvent::RouteRegistered { route_id } if route_id == "kept")
1087            ),
1088            "kept route must survive"
1089        );
1090    }
1091
1092    #[tokio::test]
1093    async fn compaction_collapses_redundant_generations_at_last_registered() {
1094        // adopt-on-recovery re-appends [Registered, StartRequested, Started]
1095        // for the same declarative route on every boot. Because replay of a
1096        // RouteRegistered resets the aggregate to v0, every event before the
1097        // route's LAST RouteRegistered is redundant and must be compacted so
1098        // the durable journal does not grow without bound across restarts.
1099        let dir = tempdir().unwrap();
1100        let journal = RedbRuntimeEventJournal::new(
1101            dir.path().join("generations.db"),
1102            RedbJournalOptions {
1103                durability: JournalDurability::Eventual,
1104                // Keep compaction from firing until the whole history is written,
1105                // so the test asserts the compaction rule, not append timing.
1106                compaction_threshold_events: 1_000,
1107            },
1108        )
1109        .await
1110        .unwrap();
1111
1112        // Three boot generations for the same route.
1113        for _ in 0..3 {
1114            journal
1115                .append_batch(&[
1116                    RuntimeEvent::RouteRegistered {
1117                        route_id: "declarative".to_string(),
1118                    },
1119                    RuntimeEvent::RouteStartRequested {
1120                        route_id: "declarative".to_string(),
1121                    },
1122                    RuntimeEvent::RouteStarted {
1123                        route_id: "declarative".to_string(),
1124                    },
1125                ])
1126                .await
1127                .unwrap();
1128        }
1129        assert_eq!(journal.load_all().await.unwrap().len(), 9);
1130
1131        journal.compact().unwrap();
1132
1133        // Only the last generation survives, and it still replays to Started.
1134        let loaded = journal.load_all().await.unwrap();
1135        assert_eq!(
1136            loaded.len(),
1137            3,
1138            "history before the last RouteRegistered must be compacted, got {loaded:?}"
1139        );
1140        assert!(matches!(
1141            loaded.first(),
1142            Some(RuntimeEvent::RouteRegistered { route_id }) if route_id == "declarative"
1143        ));
1144        assert!(matches!(
1145            loaded.last(),
1146            Some(RuntimeEvent::RouteStarted { route_id }) if route_id == "declarative"
1147        ));
1148    }
1149
1150    #[tokio::test]
1151    async fn compaction_keeps_single_generation_intact() {
1152        // Regression guard: a route with a single generation (one
1153        // RouteRegistered) must keep its full post-register history — the
1154        // checkpoint rule only drops events strictly before the LAST register.
1155        let dir = tempdir().unwrap();
1156        let journal = RedbRuntimeEventJournal::new(
1157            dir.path().join("single_gen.db"),
1158            RedbJournalOptions {
1159                durability: JournalDurability::Eventual,
1160                compaction_threshold_events: 1_000,
1161            },
1162        )
1163        .await
1164        .unwrap();
1165
1166        journal
1167            .append_batch(&[
1168                RuntimeEvent::RouteRegistered {
1169                    route_id: "r".to_string(),
1170                },
1171                RuntimeEvent::RouteStartRequested {
1172                    route_id: "r".to_string(),
1173                },
1174                RuntimeEvent::RouteStarted {
1175                    route_id: "r".to_string(),
1176                },
1177                RuntimeEvent::RouteStopped {
1178                    route_id: "r".to_string(),
1179                },
1180            ])
1181            .await
1182            .unwrap();
1183
1184        journal.compact().unwrap();
1185
1186        assert_eq!(journal.load_all().await.unwrap().len(), 4);
1187    }
1188
1189    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1190    async fn open_retries_until_lock_released() {
1191        // Simulates the k8s RWO PVC handover: a second open of the same journal
1192        // file is blocked by the first handle's lock, then succeeds once that
1193        // handle is dropped — instead of crashing with "Database already open".
1194        let dir = tempdir().unwrap();
1195        let path = dir.path().join("locked.db");
1196
1197        let first = RedbRuntimeEventJournal::new(path.clone(), RedbJournalOptions::default())
1198            .await
1199            .unwrap();
1200
1201        let path2 = path.clone();
1202        let opener = tokio::spawn(async move {
1203            RedbRuntimeEventJournal::new(path2, RedbJournalOptions::default()).await
1204        });
1205
1206        // Hold the lock across at least one retry backoff, then release it.
1207        tokio::time::sleep(std::time::Duration::from_millis(400)).await;
1208        drop(first);
1209
1210        let second = opener.await.unwrap();
1211        assert!(
1212            second.is_ok(),
1213            "second open must succeed after the lock is released, got err: {:?}",
1214            second.err().map(|e| e.to_string())
1215        );
1216    }
1217
1218    #[tokio::test]
1219    async fn open_fails_fast_on_non_lock_error() {
1220        // A genuine bad path (parent is a file, not a directory) is not lock
1221        // contention and must surface immediately, not spin the retry loop.
1222        let dir = tempdir().unwrap();
1223        let file_as_parent = dir.path().join("iamafile");
1224        std::fs::write(&file_as_parent, b"x").unwrap();
1225        let bogus = file_as_parent.join("journal.db");
1226
1227        let start = std::time::Instant::now();
1228        let result = RedbRuntimeEventJournal::new(bogus, RedbJournalOptions::default()).await;
1229        assert!(result.is_err());
1230        assert!(
1231            start.elapsed() < OPEN_LOCK_RETRY_BACKOFF,
1232            "non-lock error must fail fast without retrying"
1233        );
1234    }
1235}