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