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 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 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 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 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 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);
767
768 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 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 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 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 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 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 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 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 let dir = tempdir().unwrap();
1100 let journal = RedbRuntimeEventJournal::new(
1101 dir.path().join("generations.db"),
1102 RedbJournalOptions {
1103 durability: JournalDurability::Eventual,
1104 compaction_threshold_events: 1_000,
1107 },
1108 )
1109 .await
1110 .unwrap();
1111
1112 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 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 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 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 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 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}