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 camel_component_api::test_support::timeout_wait;
553    use tempfile::tempdir;
554
555    async fn new_journal(dir: &tempfile::TempDir) -> RedbRuntimeEventJournal {
556        RedbRuntimeEventJournal::new(dir.path().join("test.db"), RedbJournalOptions::default())
557            .await
558            .unwrap()
559    }
560
561    #[tokio::test]
562    async fn redb_journal_roundtrip() {
563        let dir = tempdir().unwrap();
564        let journal = new_journal(&dir).await;
565
566        let events = vec![
567            RuntimeEvent::RouteRegistered {
568                route_id: "r1".to_string(),
569            },
570            RuntimeEvent::RouteStarted {
571                route_id: "r1".to_string(),
572            },
573        ];
574        journal.append_batch(&events).await.unwrap();
575
576        let loaded = journal.load_all().await.unwrap();
577        assert_eq!(loaded, events);
578    }
579
580    #[tokio::test]
581    async fn redb_journal_command_id_lifecycle() {
582        let dir = tempdir().unwrap();
583        let journal = new_journal(&dir).await;
584
585        journal.append_command_id("c1").await.unwrap();
586        journal.append_command_id("c2").await.unwrap();
587        journal.remove_command_id("c1").await.unwrap();
588
589        let ids = journal.load_command_ids().await.unwrap();
590        assert_eq!(ids, vec!["c2".to_string()]);
591    }
592
593    #[tokio::test]
594    async fn redb_journal_compaction_removes_completed_routes() {
595        let dir = tempdir().unwrap();
596        // Threshold of 1 triggers compaction on every append.
597        let journal = RedbRuntimeEventJournal::new(
598            dir.path().join("compact.db"),
599            RedbJournalOptions {
600                durability: JournalDurability::Eventual,
601                compaction_threshold_events: 1,
602            },
603        )
604        .await
605        .unwrap();
606
607        // Removed route — full lifecycle.
608        journal
609            .append_batch(&[RuntimeEvent::RouteRegistered {
610                route_id: "old".to_string(),
611            }])
612            .await
613            .unwrap();
614        journal
615            .append_batch(&[RuntimeEvent::RouteRemoved {
616                route_id: "old".to_string(),
617            }])
618            .await
619            .unwrap();
620
621        // Active route — no RouteRemoved.
622        journal
623            .append_batch(&[RuntimeEvent::RouteRegistered {
624                route_id: "live".to_string(),
625            }])
626            .await
627            .unwrap();
628
629        let loaded = journal.load_all().await.unwrap();
630        assert!(
631            !loaded.iter().any(
632                |e| matches!(e, RuntimeEvent::RouteRegistered { route_id } if route_id == "old")
633            ),
634            "old route events must be compacted"
635        );
636        assert!(
637            loaded.iter().any(
638                |e| matches!(e, RuntimeEvent::RouteRegistered { route_id } if route_id == "live")
639            ),
640            "live route events must survive compaction"
641        );
642    }
643
644    #[tokio::test]
645    async fn redb_journal_compaction_preserves_reregistered_route() {
646        let dir = tempdir().unwrap();
647        let journal = RedbRuntimeEventJournal::new(
648            dir.path().join("rereg.db"),
649            RedbJournalOptions {
650                durability: JournalDurability::Eventual,
651                compaction_threshold_events: 1,
652            },
653        )
654        .await
655        .unwrap();
656
657        journal
658            .append_batch(&[RuntimeEvent::RouteRegistered {
659                route_id: "rereg".to_string(),
660            }])
661            .await
662            .unwrap();
663        journal
664            .append_batch(&[RuntimeEvent::RouteRemoved {
665                route_id: "rereg".to_string(),
666            }])
667            .await
668            .unwrap();
669        journal
670            .append_batch(&[RuntimeEvent::RouteRegistered {
671                route_id: "rereg".to_string(),
672            }])
673            .await
674            .unwrap();
675
676        let loaded = journal.load_all().await.unwrap();
677        let rereg_count = loaded
678            .iter()
679            .filter(
680                |e| matches!(e, RuntimeEvent::RouteRegistered { route_id } if route_id == "rereg"),
681            )
682            .count();
683        assert_eq!(
684            rereg_count, 1,
685            "re-registered route must have exactly one event after compaction"
686        );
687    }
688
689    #[tokio::test]
690    async fn redb_journal_durability_eventual() {
691        let dir = tempdir().unwrap();
692        let journal = RedbRuntimeEventJournal::new(
693            dir.path().join("eventual.db"),
694            RedbJournalOptions {
695                durability: JournalDurability::Eventual,
696                compaction_threshold_events: 10_000,
697            },
698        )
699        .await
700        .unwrap();
701
702        journal
703            .append_batch(&[RuntimeEvent::RouteRegistered {
704                route_id: "ev".to_string(),
705            }])
706            .await
707            .unwrap();
708        let loaded = journal.load_all().await.unwrap();
709        assert_eq!(loaded.len(), 1);
710    }
711
712    #[tokio::test]
713    async fn redb_journal_clone_shares_db() {
714        let dir = tempdir().unwrap();
715        let j1 = new_journal(&dir).await;
716        let j2 = j1.clone();
717
718        j1.append_batch(&[RuntimeEvent::RouteRegistered {
719            route_id: "shared".to_string(),
720        }])
721        .await
722        .unwrap();
723
724        // j2 must see j1's write since they share the same Arc<Database>.
725        let loaded = j2.load_all().await.unwrap();
726        assert_eq!(loaded.len(), 1);
727    }
728
729    #[tokio::test]
730    async fn redb_journal_append_empty_batch_is_noop() {
731        let dir = tempdir().unwrap();
732        let journal = new_journal(&dir).await;
733
734        journal.append_batch(&[]).await.unwrap();
735        let loaded = journal.load_all().await.unwrap();
736        assert!(loaded.is_empty());
737    }
738
739    #[tokio::test]
740    async fn redb_journal_sequence_numbers_across_batches() {
741        let dir = tempdir().unwrap();
742        let journal = new_journal(&dir).await;
743
744        journal
745            .append_batch(&[
746                RuntimeEvent::RouteRegistered {
747                    route_id: "r1".to_string(),
748                },
749                RuntimeEvent::RouteStarted {
750                    route_id: "r1".to_string(),
751                },
752            ])
753            .await
754            .unwrap();
755
756        journal
757            .append_batch(&[RuntimeEvent::RouteStopped {
758                route_id: "r1".to_string(),
759            }])
760            .await
761            .unwrap();
762
763        let loaded = journal.load_all().await.unwrap();
764        assert_eq!(loaded.len(), 3);
765
766        // Drop journal to release redb lock before inspect.
767        drop(journal);
768
769        // Verify sequence numbers are monotonic.
770        let entries = RedbRuntimeEventJournal::inspect(
771            dir.path().join("test.db"),
772            JournalInspectFilter {
773                route_id: None,
774                limit: 10,
775            },
776        )
777        .await
778        .unwrap();
779        let seqs: Vec<u64> = entries.iter().map(|e| e.seq).collect();
780        // inspect returns newest-first.
781        assert_eq!(seqs, vec![2, 1, 0]);
782    }
783
784    #[tokio::test]
785    async fn redb_journal_load_all_empty() {
786        let dir = tempdir().unwrap();
787        let journal = new_journal(&dir).await;
788        let loaded = journal.load_all().await.unwrap();
789        assert!(loaded.is_empty());
790    }
791
792    #[tokio::test]
793    async fn redb_journal_inspect_file_not_found() {
794        let dir = tempdir().unwrap();
795        let result = RedbRuntimeEventJournal::inspect(
796            dir.path().join("nonexistent.db"),
797            JournalInspectFilter {
798                route_id: None,
799                limit: 10,
800            },
801        )
802        .await;
803        assert!(result.is_err());
804        let err = result.unwrap_err().to_string();
805        assert!(err.contains("journal file not found"));
806    }
807
808    #[tokio::test]
809    async fn redb_journal_inspect_with_route_id_filter() {
810        let dir = tempdir().unwrap();
811        let journal = new_journal(&dir).await;
812
813        journal
814            .append_batch(&[
815                RuntimeEvent::RouteRegistered {
816                    route_id: "alpha".to_string(),
817                },
818                RuntimeEvent::RouteRegistered {
819                    route_id: "beta".to_string(),
820                },
821                RuntimeEvent::RouteStarted {
822                    route_id: "alpha".to_string(),
823                },
824            ])
825            .await
826            .unwrap();
827
828        drop(journal);
829
830        let entries = RedbRuntimeEventJournal::inspect(
831            dir.path().join("test.db"),
832            JournalInspectFilter {
833                route_id: Some("alpha".to_string()),
834                limit: 10,
835            },
836        )
837        .await
838        .unwrap();
839
840        assert_eq!(entries.len(), 2);
841        assert!(entries.iter().all(|e| {
842            matches!(&e.event, RuntimeEvent::RouteRegistered { route_id } | RuntimeEvent::RouteStarted { route_id } if route_id == "alpha")
843        }));
844    }
845
846    #[tokio::test]
847    async fn redb_journal_inspect_limit_enforcement() {
848        let dir = tempdir().unwrap();
849        let journal = new_journal(&dir).await;
850
851        for i in 0..5 {
852            journal
853                .append_batch(&[RuntimeEvent::RouteRegistered {
854                    route_id: format!("r{i}"),
855                }])
856                .await
857                .unwrap();
858        }
859
860        drop(journal);
861
862        let entries = RedbRuntimeEventJournal::inspect(
863            dir.path().join("test.db"),
864            JournalInspectFilter {
865                route_id: None,
866                limit: 2,
867            },
868        )
869        .await
870        .unwrap();
871
872        assert_eq!(entries.len(), 2);
873        // Newest first: r4, r3
874        assert!(
875            matches!(&entries[0].event, RuntimeEvent::RouteRegistered { route_id } if route_id == "r4")
876        );
877        assert!(
878            matches!(&entries[1].event, RuntimeEvent::RouteRegistered { route_id } if route_id == "r3")
879        );
880    }
881
882    #[tokio::test]
883    async fn redb_journal_inspect_limit_with_filter_returns_matching_count() {
884        let dir = tempdir().unwrap();
885        let journal = new_journal(&dir).await;
886
887        // Interleave alpha and beta events.
888        for i in 0..4 {
889            let rid = if i % 2 == 0 { "alpha" } else { "beta" };
890            journal
891                .append_batch(&[RuntimeEvent::RouteRegistered {
892                    route_id: rid.to_string(),
893                }])
894                .await
895                .unwrap();
896        }
897
898        drop(journal);
899
900        // limit=1 but 2 alpha events exist — should return exactly 1 alpha.
901        let entries = RedbRuntimeEventJournal::inspect(
902            dir.path().join("test.db"),
903            JournalInspectFilter {
904                route_id: Some("alpha".to_string()),
905                limit: 1,
906            },
907        )
908        .await
909        .unwrap();
910
911        assert_eq!(entries.len(), 1);
912        assert!(
913            matches!(&entries[0].event, RuntimeEvent::RouteRegistered { route_id } if route_id == "alpha")
914        );
915    }
916
917    #[test]
918    fn redb_journal_durability_default_is_immediate() {
919        assert_eq!(JournalDurability::default(), JournalDurability::Immediate);
920    }
921
922    #[test]
923    fn redb_journal_options_default() {
924        let opts = RedbJournalOptions::default();
925        assert_eq!(opts.durability, JournalDurability::Immediate);
926        assert_eq!(opts.compaction_threshold_events, 10_000);
927    }
928
929    #[test]
930    fn redb_journal_entry_serialization_roundtrip() {
931        let entry = JournalEntry {
932            seq: 42,
933            timestamp_ms: 1_700_000_000_000,
934            event: RuntimeEvent::RouteFailed {
935                route_id: "fail-route".to_string(),
936                error: "boom".to_string(),
937            },
938        };
939
940        let bytes = serde_json::to_vec(&entry).unwrap();
941        let decoded: JournalEntry = serde_json::from_slice(&bytes).unwrap();
942        assert_eq!(decoded.seq, 42);
943        assert_eq!(decoded.timestamp_ms, 1_700_000_000_000);
944        assert_eq!(decoded.event, entry.event);
945    }
946
947    #[test]
948    fn redb_journal_runtime_event_ext_all_variants() {
949        let events = [
950            RuntimeEvent::RouteRegistered {
951                route_id: "a".into(),
952            },
953            RuntimeEvent::RouteStartRequested {
954                route_id: "b".into(),
955            },
956            RuntimeEvent::RouteStarted {
957                route_id: "c".into(),
958            },
959            RuntimeEvent::RouteFailed {
960                route_id: "d".into(),
961                error: "err".into(),
962            },
963            RuntimeEvent::RouteStopped {
964                route_id: "e".into(),
965            },
966            RuntimeEvent::RouteSuspended {
967                route_id: "f".into(),
968            },
969            RuntimeEvent::RouteResumed {
970                route_id: "g".into(),
971            },
972            RuntimeEvent::RouteReloaded {
973                route_id: "h".into(),
974            },
975            RuntimeEvent::RouteRemoved {
976                route_id: "i".into(),
977            },
978        ];
979        let expected = ["a", "b", "c", "d", "e", "f", "g", "h", "i"];
980        for (event, expected_id) in events.iter().zip(expected.iter()) {
981            assert_eq!(event.route_id(), *expected_id);
982        }
983    }
984
985    #[tokio::test]
986    async fn redb_journal_compaction_no_removed_routes_early_return() {
987        let dir = tempdir().unwrap();
988        let journal = RedbRuntimeEventJournal::new(
989            dir.path().join("no_remove.db"),
990            RedbJournalOptions {
991                durability: JournalDurability::Eventual,
992                compaction_threshold_events: 1,
993            },
994        )
995        .await
996        .unwrap();
997
998        // Only registered and started — no RouteRemoved.
999        journal
1000            .append_batch(&[RuntimeEvent::RouteRegistered {
1001                route_id: "active".to_string(),
1002            }])
1003            .await
1004            .unwrap();
1005        journal
1006            .append_batch(&[RuntimeEvent::RouteStarted {
1007                route_id: "active".to_string(),
1008            }])
1009            .await
1010            .unwrap();
1011
1012        let loaded = journal.load_all().await.unwrap();
1013        assert_eq!(loaded.len(), 2);
1014    }
1015
1016    #[tokio::test]
1017    async fn redb_journal_command_ids_multiple_and_remove_nonexistent() {
1018        let dir = tempdir().unwrap();
1019        let journal = new_journal(&dir).await;
1020
1021        journal.append_command_id("cmd1").await.unwrap();
1022        journal.append_command_id("cmd2").await.unwrap();
1023        journal.append_command_id("cmd3").await.unwrap();
1024
1025        // Remove a non-existent command — should not error.
1026        journal.remove_command_id("nonexistent").await.unwrap();
1027
1028        let ids = journal.load_command_ids().await.unwrap();
1029        assert_eq!(ids.len(), 3);
1030        assert!(ids.contains(&"cmd1".to_string()));
1031        assert!(ids.contains(&"cmd2".to_string()));
1032        assert!(ids.contains(&"cmd3".to_string()));
1033    }
1034
1035    #[tokio::test]
1036    async fn redb_journal_multiple_routes_compaction() {
1037        let dir = tempdir().unwrap();
1038        let journal = RedbRuntimeEventJournal::new(
1039            dir.path().join("multi_compact.db"),
1040            RedbJournalOptions {
1041                durability: JournalDurability::Eventual,
1042                compaction_threshold_events: 1,
1043            },
1044        )
1045        .await
1046        .unwrap();
1047
1048        // Two removed routes, one active.
1049        journal
1050            .append_batch(&[RuntimeEvent::RouteRegistered {
1051                route_id: "removed1".to_string(),
1052            }])
1053            .await
1054            .unwrap();
1055        journal
1056            .append_batch(&[RuntimeEvent::RouteRemoved {
1057                route_id: "removed1".to_string(),
1058            }])
1059            .await
1060            .unwrap();
1061        journal
1062            .append_batch(&[RuntimeEvent::RouteRegistered {
1063                route_id: "removed2".to_string(),
1064            }])
1065            .await
1066            .unwrap();
1067        journal
1068            .append_batch(&[RuntimeEvent::RouteRemoved {
1069                route_id: "removed2".to_string(),
1070            }])
1071            .await
1072            .unwrap();
1073        journal
1074            .append_batch(&[RuntimeEvent::RouteRegistered {
1075                route_id: "kept".to_string(),
1076            }])
1077            .await
1078            .unwrap();
1079
1080        let loaded = journal.load_all().await.unwrap();
1081        assert!(
1082            !loaded.iter().any(|e| matches!(e, RuntimeEvent::RouteRegistered { route_id } if route_id == "removed1" || route_id == "removed2")),
1083            "removed routes must be compacted"
1084        );
1085        assert!(
1086            loaded.iter().any(
1087                |e| matches!(e, RuntimeEvent::RouteRegistered { route_id } if route_id == "kept")
1088            ),
1089            "kept route must survive"
1090        );
1091    }
1092
1093    #[tokio::test]
1094    async fn compaction_collapses_redundant_generations_at_last_registered() {
1095        // adopt-on-recovery re-appends [Registered, StartRequested, Started]
1096        // for the same declarative route on every boot. Because replay of a
1097        // RouteRegistered resets the aggregate to v0, every event before the
1098        // route's LAST RouteRegistered is redundant and must be compacted so
1099        // the durable journal does not grow without bound across restarts.
1100        let dir = tempdir().unwrap();
1101        let journal = RedbRuntimeEventJournal::new(
1102            dir.path().join("generations.db"),
1103            RedbJournalOptions {
1104                durability: JournalDurability::Eventual,
1105                // Keep compaction from firing until the whole history is written,
1106                // so the test asserts the compaction rule, not append timing.
1107                compaction_threshold_events: 1_000,
1108            },
1109        )
1110        .await
1111        .unwrap();
1112
1113        // Three boot generations for the same route.
1114        for _ in 0..3 {
1115            journal
1116                .append_batch(&[
1117                    RuntimeEvent::RouteRegistered {
1118                        route_id: "declarative".to_string(),
1119                    },
1120                    RuntimeEvent::RouteStartRequested {
1121                        route_id: "declarative".to_string(),
1122                    },
1123                    RuntimeEvent::RouteStarted {
1124                        route_id: "declarative".to_string(),
1125                    },
1126                ])
1127                .await
1128                .unwrap();
1129        }
1130        assert_eq!(journal.load_all().await.unwrap().len(), 9);
1131
1132        journal.compact().unwrap();
1133
1134        // Only the last generation survives, and it still replays to Started.
1135        let loaded = journal.load_all().await.unwrap();
1136        assert_eq!(
1137            loaded.len(),
1138            3,
1139            "history before the last RouteRegistered must be compacted, got {loaded:?}"
1140        );
1141        assert!(matches!(
1142            loaded.first(),
1143            Some(RuntimeEvent::RouteRegistered { route_id }) if route_id == "declarative"
1144        ));
1145        assert!(matches!(
1146            loaded.last(),
1147            Some(RuntimeEvent::RouteStarted { route_id }) if route_id == "declarative"
1148        ));
1149    }
1150
1151    #[tokio::test]
1152    async fn compaction_keeps_single_generation_intact() {
1153        // Regression guard: a route with a single generation (one
1154        // RouteRegistered) must keep its full post-register history — the
1155        // checkpoint rule only drops events strictly before the LAST register.
1156        let dir = tempdir().unwrap();
1157        let journal = RedbRuntimeEventJournal::new(
1158            dir.path().join("single_gen.db"),
1159            RedbJournalOptions {
1160                durability: JournalDurability::Eventual,
1161                compaction_threshold_events: 1_000,
1162            },
1163        )
1164        .await
1165        .unwrap();
1166
1167        journal
1168            .append_batch(&[
1169                RuntimeEvent::RouteRegistered {
1170                    route_id: "r".to_string(),
1171                },
1172                RuntimeEvent::RouteStartRequested {
1173                    route_id: "r".to_string(),
1174                },
1175                RuntimeEvent::RouteStarted {
1176                    route_id: "r".to_string(),
1177                },
1178                RuntimeEvent::RouteStopped {
1179                    route_id: "r".to_string(),
1180                },
1181            ])
1182            .await
1183            .unwrap();
1184
1185        journal.compact().unwrap();
1186
1187        assert_eq!(journal.load_all().await.unwrap().len(), 4);
1188    }
1189
1190    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1191    async fn open_retries_until_lock_released() {
1192        // Simulates the k8s RWO PVC handover: a second open of the same journal
1193        // file is blocked by the first handle's lock, then succeeds once that
1194        // handle is dropped — instead of crashing with "Database already open".
1195        let dir = tempdir().unwrap();
1196        let path = dir.path().join("locked.db");
1197
1198        let first = RedbRuntimeEventJournal::new(path.clone(), RedbJournalOptions::default())
1199            .await
1200            .unwrap();
1201
1202        let path2 = path.clone();
1203        let opener = tokio::spawn(async move {
1204            RedbRuntimeEventJournal::new(path2, RedbJournalOptions::default()).await
1205        });
1206
1207        // Hold the lock across at least one retry backoff, then release it.
1208        tokio::time::sleep(std::time::Duration::from_millis(400)).await;
1209        drop(first);
1210
1211        let second = timeout_wait(
1212            "redb open retry task join",
1213            std::time::Duration::from_secs(30),
1214            opener,
1215        )
1216        .await
1217        .unwrap();
1218        assert!(
1219            second.is_ok(),
1220            "second open must succeed after the lock is released, got err: {:?}",
1221            second.err().map(|e| e.to_string())
1222        );
1223    }
1224
1225    #[tokio::test]
1226    async fn open_fails_fast_on_non_lock_error() {
1227        // A genuine bad path (parent is a file, not a directory) is not lock
1228        // contention and must surface immediately, not spin the retry loop.
1229        let dir = tempdir().unwrap();
1230        let file_as_parent = dir.path().join("iamafile");
1231        std::fs::write(&file_as_parent, b"x").unwrap();
1232        let bogus = file_as_parent.join("journal.db");
1233
1234        let start = std::time::Instant::now();
1235        let result = RedbRuntimeEventJournal::new(bogus, RedbJournalOptions::default()).await;
1236        assert!(result.is_err());
1237        assert!(
1238            start.elapsed() < OPEN_LOCK_RETRY_BACKOFF,
1239            "non-lock error must fail fast without retrying"
1240        );
1241    }
1242}