1use 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
28const EVENTS_TABLE: TableDefinition<u64, &[u8]> = TableDefinition::new("events");
31const COMMAND_IDS_TABLE: TableDefinition<&str, ()> = TableDefinition::new("command_ids");
32
33const OPEN_LOCK_MAX_ATTEMPTS: u32 = 40;
37const OPEN_LOCK_RETRY_BACKOFF: std::time::Duration = std::time::Duration::from_millis(250);
39
40fn 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#[derive(Debug, Clone, PartialEq, Default)]
54pub enum JournalDurability {
55 #[default]
57 Immediate,
58 Eventual,
60}
61
62#[derive(Debug, Clone)]
64pub struct RedbJournalOptions {
65 pub durability: JournalDurability,
66 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#[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
88pub struct JournalInspectFilter {
90 pub route_id: Option<String>,
91 pub limit: usize,
92}
93
94#[derive(Clone)]
101pub struct RedbRuntimeEventJournal {
102 db: Arc<Database>,
103 options: RedbJournalOptions,
104}
105
106impl RedbRuntimeEventJournal {
107 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 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 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 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 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 fn redb_durability(&self) -> Durability {
230 match self.options.durability {
231 JournalDurability::Immediate => Durability::Immediate,
232 JournalDurability::Eventual => Durability::None,
233 }
234 }
235
236 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 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 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 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 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
350trait 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#[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 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 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(); 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 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 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#[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 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 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 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 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);
768
769 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 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 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 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 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 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 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 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 let dir = tempdir().unwrap();
1101 let journal = RedbRuntimeEventJournal::new(
1102 dir.path().join("generations.db"),
1103 RedbJournalOptions {
1104 durability: JournalDurability::Eventual,
1105 compaction_threshold_events: 1_000,
1108 },
1109 )
1110 .await
1111 .unwrap();
1112
1113 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 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 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 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 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 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}