1use std::collections::{HashMap, VecDeque};
29use std::future::Future;
30use std::sync::Arc;
31use std::time::Duration;
32
33use bytes::Bytes;
34use serde::{Deserialize, Serialize};
35use tokio::sync::RwLock;
36
37const DEFAULT_MAX_RECORDS_PER_VIEW: usize = 10_000;
38const DEFAULT_MAX_BYTES_PER_VIEW: u64 = 32 * 1024 * 1024;
39const DEFAULT_MAX_AGE: Duration = Duration::from_secs(15 * 60);
40
41const RECORD_OVERHEAD_BYTES: u64 = 120;
45
46#[derive(Clone, Debug)]
48pub struct JournalConfig {
49 pub enabled: bool,
52 pub max_bytes_per_view: u64,
57 pub max_records_per_view: usize,
60 pub max_age: Duration,
63}
64
65impl Default for JournalConfig {
66 fn default() -> Self {
67 Self {
68 enabled: false,
69 max_bytes_per_view: DEFAULT_MAX_BYTES_PER_VIEW,
70 max_records_per_view: DEFAULT_MAX_RECORDS_PER_VIEW,
71 max_age: DEFAULT_MAX_AGE,
72 }
73 }
74}
75
76impl JournalConfig {
77 pub fn from_env() -> anyhow::Result<Self> {
84 let mut config = Self::default();
85 config.enabled = crate::config::env_bool("ARETE_JOURNAL_ENABLED")?.unwrap_or(false);
86 config.max_bytes_per_view = crate::config::env_parse("ARETE_JOURNAL_MAX_BYTES")?
87 .unwrap_or(config.max_bytes_per_view);
88 config.max_records_per_view = crate::config::env_parse("ARETE_JOURNAL_MAX_RECORDS")?
89 .unwrap_or(config.max_records_per_view);
90 config.max_age = Duration::from_secs(
91 crate::config::env_parse("ARETE_JOURNAL_MAX_AGE_SECS")?
92 .unwrap_or(config.max_age.as_secs()),
93 );
94 config.validate()?;
95 Ok(config)
96 }
97
98 pub fn validate(&self) -> anyhow::Result<()> {
99 if !self.enabled {
100 return Ok(());
101 }
102 if self.max_bytes_per_view == 0 {
103 anyhow::bail!("ARETE_JOURNAL_MAX_BYTES must be greater than zero");
104 }
105 if self.max_records_per_view == 0 {
106 anyhow::bail!("ARETE_JOURNAL_MAX_RECORDS must be greater than zero");
107 }
108 if self.max_age.is_zero() {
109 anyhow::bail!("ARETE_JOURNAL_MAX_AGE_SECS must be greater than zero");
110 }
111 Ok(())
112 }
113}
114
115#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
118#[serde(transparent)]
119pub struct JournalEpoch(String);
120
121impl JournalEpoch {
122 pub fn new() -> Self {
123 Self(uuid::Uuid::new_v4().to_string())
124 }
125
126 pub fn as_str(&self) -> &str {
127 &self.0
128 }
129}
130
131impl Default for JournalEpoch {
132 fn default() -> Self {
133 Self::new()
134 }
135}
136
137impl std::fmt::Display for JournalEpoch {
138 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139 formatter.write_str(&self.0)
140 }
141}
142
143#[derive(Clone, Debug, PartialEq, Eq)]
145pub struct Cursor {
146 pub epoch: JournalEpoch,
147 pub offset: u64,
148}
149
150impl Cursor {
151 pub fn parse(raw: &str) -> Option<Self> {
152 let (epoch, offset) = raw.rsplit_once(':')?;
153 if epoch.is_empty() {
154 return None;
155 }
156 Some(Self {
157 epoch: JournalEpoch(epoch.to_string()),
158 offset: offset.parse().ok()?,
159 })
160 }
161}
162
163impl std::fmt::Display for Cursor {
164 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165 write!(formatter, "{}:{}", self.epoch, self.offset)
166 }
167}
168
169#[derive(Clone, Debug)]
175pub struct JournalRecord {
176 pub row_scope: Option<crate::row_scope::RowScope>,
178 pub offset: u64,
179 pub key: String,
180 pub payload: Arc<Bytes>,
181 pub appended_at: i64,
183}
184
185impl JournalRecord {
186 fn charged_bytes(&self) -> u64 {
187 self.payload.len() as u64
188 + self.key.len() as u64
189 + RECORD_OVERHEAD_BYTES
190 + match &self.row_scope {
191 Some(crate::row_scope::RowScope::Scoped(scope)) => scope.len() as u64,
192 _ => 0,
193 }
194 }
195}
196
197#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
203#[serde(rename_all = "camelCase")]
204pub struct ReplayWindow {
205 pub epoch: JournalEpoch,
206 pub earliest: u64,
207 pub next: u64,
208 #[serde(skip_serializing_if = "Option::is_none")]
213 pub gap_after: Option<u64>,
214}
215
216impl ReplayWindow {
217 pub fn is_empty(&self) -> bool {
218 self.earliest >= self.next
219 }
220
221 pub fn latest_cursor(&self) -> Option<Cursor> {
223 (self.next > self.earliest).then(|| Cursor {
224 epoch: self.epoch.clone(),
225 offset: self.next - 1,
226 })
227 }
228}
229
230#[derive(Clone, Debug, PartialEq, Eq)]
232pub enum ReplayError {
233 EpochMismatch(ReplayWindow),
237 CursorExpired(ReplayWindow),
239 CursorBeyondWindow(ReplayWindow),
243 GapCrossed(ReplayWindow),
245}
246
247impl ReplayError {
248 pub fn window(&self) -> &ReplayWindow {
249 match self {
250 Self::EpochMismatch(window)
251 | Self::CursorExpired(window)
252 | Self::CursorBeyondWindow(window)
253 | Self::GapCrossed(window) => window,
254 }
255 }
256}
257
258#[derive(Clone, Debug, Serialize, Deserialize)]
267pub struct PersistedRecord {
268 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub row_scope: Option<crate::row_scope::RowScope>,
270 pub offset: u64,
271 pub key: String,
272 #[serde(with = "frame_text")]
273 pub payload: Arc<Bytes>,
274 pub appended_at: i64,
275}
276
277mod frame_text {
282 use super::*;
283 use serde::{Deserializer, Serializer};
284
285 pub fn serialize<S: Serializer>(
286 payload: &Arc<Bytes>,
287 serializer: S,
288 ) -> Result<S::Ok, S::Error> {
289 let text = std::str::from_utf8(payload)
290 .map_err(|_| serde::ser::Error::custom("retained frame is not UTF-8"))?;
291 serializer.serialize_str(text)
292 }
293
294 pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Arc<Bytes>, D::Error> {
295 let text = String::deserialize(deserializer)?;
296 Ok(Arc::new(Bytes::from(text.into_bytes())))
297 }
298}
299
300#[derive(Clone, Debug, Serialize, Deserialize)]
301pub struct PersistedViewJournal {
302 pub next_offset: u64,
303 pub records: Vec<PersistedRecord>,
304 #[serde(default, skip_serializing_if = "Option::is_none")]
305 pub gap_after: Option<u64>,
306}
307
308#[derive(Clone, Debug, Default, Serialize, Deserialize)]
319pub struct JournalSnapshot {
320 #[serde(default, skip_serializing_if = "Option::is_none")]
323 pub epoch: Option<JournalEpoch>,
324 pub views: HashMap<String, PersistedViewJournal>,
325}
326
327#[derive(Debug, Default)]
328struct ViewJournal {
329 next_offset: u64,
331 records: VecDeque<JournalRecord>,
333 retained_bytes: u64,
335 gap_after: Option<u64>,
337}
338
339impl ViewJournal {
340 fn window(&self, epoch: &JournalEpoch) -> ReplayWindow {
341 ReplayWindow {
342 epoch: epoch.clone(),
343 earliest: self
344 .records
345 .front()
346 .map(|record| record.offset)
347 .unwrap_or(self.next_offset),
348 next: self.next_offset,
349 gap_after: self.gap_after,
350 }
351 }
352
353 fn pop_oldest(&mut self) {
354 if let Some(record) = self.records.pop_front() {
355 self.retained_bytes = self.retained_bytes.saturating_sub(record.charged_bytes());
356 }
357 }
358
359 fn prune(&mut self, config: &JournalConfig, now: i64) {
360 let max_age = config.max_age.as_secs() as i64;
364 while self
365 .records
366 .front()
367 .is_some_and(|record| record.appended_at.saturating_add(max_age) <= now)
368 {
369 self.pop_oldest();
370 }
371 while self.records.len() > config.max_records_per_view {
372 self.pop_oldest();
373 }
374 while self.retained_bytes > config.max_bytes_per_view && self.records.len() > 1 {
378 self.pop_oldest();
379 }
380 if let (Some(gap), Some(oldest)) = (self.gap_after, self.records.front()) {
387 if oldest.offset > gap.saturating_add(1) {
388 self.gap_after = None;
389 }
390 }
391 }
392}
393
394#[derive(Debug)]
396pub struct EventJournal {
397 epoch: RwLock<JournalEpoch>,
398 views: RwLock<HashMap<String, ViewJournal>>,
399 config: JournalConfig,
400 sealed: std::sync::atomic::AtomicBool,
403 pending_gap: std::sync::atomic::AtomicBool,
410}
411
412impl EventJournal {
413 pub fn new(config: JournalConfig) -> Self {
414 Self {
415 epoch: RwLock::new(JournalEpoch::new()),
416 views: RwLock::new(HashMap::new()),
417 sealed: std::sync::atomic::AtomicBool::new(false),
418 pending_gap: std::sync::atomic::AtomicBool::new(false),
419 config,
420 }
421 }
422
423 pub fn config(&self) -> &JournalConfig {
424 &self.config
425 }
426
427 pub fn is_enabled(&self) -> bool {
428 self.config.enabled
429 }
430
431 pub async fn epoch(&self) -> JournalEpoch {
432 self.epoch.read().await.clone()
433 }
434
435 pub async fn append_with<E>(
443 &self,
444 view_id: &str,
445 key: &str,
446 build_frame: impl FnOnce(u64) -> Result<Arc<Bytes>, E>,
447 ) -> Result<Option<(u64, Arc<Bytes>)>, E> {
448 self.append_scoped_with(view_id, key, None, build_frame)
449 .await
450 }
451
452 pub async fn append_scoped_with<E>(
454 &self,
455 view_id: &str,
456 key: &str,
457 row_scope: Option<crate::row_scope::RowScope>,
458 build_frame: impl FnOnce(u64) -> Result<Arc<Bytes>, E>,
459 ) -> Result<Option<(u64, Arc<Bytes>)>, E> {
460 self.append_with_at(view_id, key, row_scope, unix_now(), build_frame)
461 .await
462 }
463
464 async fn append_with_at<E>(
465 &self,
466 view_id: &str,
467 key: &str,
468 row_scope: Option<crate::row_scope::RowScope>,
469 now: i64,
470 build_frame: impl FnOnce(u64) -> Result<Arc<Bytes>, E>,
471 ) -> Result<Option<(u64, Arc<Bytes>)>, E> {
472 let mut views = self.views.write().await;
473 if self.sealed.load(std::sync::atomic::Ordering::Relaxed) {
476 return Ok(None);
477 }
478 let fresh = !views.contains_key(view_id);
479 let journal = views.entry(view_id.to_string()).or_default();
480 if fresh && self.pending_gap.load(std::sync::atomic::Ordering::Relaxed) {
481 journal.next_offset = 1;
489 journal.gap_after = Some(0);
490 }
491 let offset = journal.next_offset;
492 let payload = build_frame(offset)?;
493
494 journal.next_offset += 1;
495 let record = JournalRecord {
496 row_scope,
497 offset,
498 key: key.to_string(),
499 payload: payload.clone(),
500 appended_at: now,
501 };
502 journal.retained_bytes = journal
503 .retained_bytes
504 .saturating_add(record.charged_bytes());
505 journal.records.push_back(record);
506 journal.prune(&self.config, now);
507 Ok(Some((offset, payload)))
508 }
509
510 pub fn seal(&self) {
531 self.sealed
532 .store(true, std::sync::atomic::Ordering::Relaxed);
533 }
534
535 pub async fn mark_gap(&self) {
536 let mut views = self.views.write().await;
542 self.pending_gap
545 .store(true, std::sync::atomic::Ordering::Relaxed);
546 for journal in views.values_mut() {
547 if journal.next_offset > 0 {
548 journal.gap_after = Some(journal.next_offset - 1);
549 }
550 }
551 }
552
553 pub async fn window(&self, view_id: &str) -> ReplayWindow {
558 let epoch = self.epoch.read().await.clone();
559 let mut views = self.views.write().await;
560 let now = unix_now();
561 match views.get_mut(view_id) {
562 Some(journal) => {
563 journal.prune(&self.config, now);
564 journal.window(&epoch)
565 }
566 None => ReplayWindow {
567 epoch,
568 earliest: 0,
569 next: 0,
570 gap_after: None,
571 },
572 }
573 }
574
575 pub async fn replay_after(
580 &self,
581 view_id: &str,
582 cursor: Option<&Cursor>,
583 ) -> Result<Vec<JournalRecord>, ReplayError> {
584 let epoch = self.epoch.read().await.clone();
585 let mut views = self.views.write().await;
586 let now = unix_now();
587 let Some(journal) = views.get_mut(view_id) else {
588 return Ok(Vec::new());
589 };
590 journal.prune(&self.config, now);
591 let window = journal.window(&epoch);
592
593 if let Some(cursor) = cursor {
594 if cursor.epoch != epoch {
596 return Err(ReplayError::EpochMismatch(window));
597 }
598 let offset = cursor.offset;
599 if offset.saturating_add(1) < window.earliest {
601 return Err(ReplayError::CursorExpired(window));
602 }
603 if window.next == 0 || offset >= window.next {
607 return Err(ReplayError::CursorBeyondWindow(window));
608 }
609 if window.gap_after.is_some_and(|gap| offset <= gap) {
612 return Err(ReplayError::GapCrossed(window));
613 }
614 }
615
616 let first_wanted = cursor
617 .map(|cursor| cursor.offset.saturating_add(1))
618 .unwrap_or(window.earliest);
619 Ok(journal
620 .records
621 .iter()
622 .filter(|record| record.offset >= first_wanted)
623 .cloned()
624 .collect())
625 }
626
627 pub async fn dump(&self) -> JournalSnapshot {
632 let epoch = self.epoch.read().await.clone();
633 let views = self.views.read().await;
634 JournalSnapshot {
635 epoch: Some(epoch),
636 views: views
637 .iter()
638 .map(|(view_id, journal)| {
639 (
640 view_id.clone(),
641 PersistedViewJournal {
642 next_offset: journal.next_offset,
643 gap_after: journal.gap_after,
644 records: journal
645 .records
646 .iter()
647 .map(|record| PersistedRecord {
648 row_scope: record.row_scope.clone(),
649 offset: record.offset,
650 key: record.key.clone(),
651 payload: record.payload.clone(),
652 appended_at: record.appended_at,
653 })
654 .collect(),
655 },
656 )
657 })
658 .collect(),
659 }
660 }
661
662 pub async fn hydrate(&self, snapshot: JournalSnapshot, exact: bool) {
684 if !self.config.enabled {
685 return;
686 }
687 let Some(epoch) = snapshot.epoch else {
688 return;
689 };
690 let now = unix_now();
691 if exact {
692 *self.epoch.write().await = epoch;
693 }
694 let mut views = self.views.write().await;
695 for (view_id, persisted) in snapshot.views {
696 let records: VecDeque<JournalRecord> = persisted
697 .records
698 .into_iter()
699 .map(|record| JournalRecord {
700 row_scope: record.row_scope,
701 offset: record.offset,
702 key: record.key,
703 payload: record.payload,
704 appended_at: record.appended_at,
705 })
706 .collect();
707 let retained_bytes = records.iter().map(JournalRecord::charged_bytes).sum();
708 let mut journal = ViewJournal {
709 next_offset: persisted.next_offset,
710 records,
711 retained_bytes,
712 gap_after: persisted.gap_after,
713 };
714 journal.prune(&self.config, now);
715 views.insert(view_id, journal);
716 }
717 }
718
719 pub async fn entry_counts(&self) -> Vec<(String, u64)> {
721 let views = self.views.read().await;
722 views
723 .iter()
724 .map(|(view_id, journal)| (view_id.clone(), journal.records.len() as u64))
725 .collect()
726 }
727
728 pub async fn retained_bytes(&self) -> Vec<(String, u64)> {
730 let views = self.views.read().await;
731 views
732 .iter()
733 .map(|(view_id, journal)| (view_id.clone(), journal.retained_bytes))
734 .collect()
735 }
736}
737
738tokio::task_local! {
739 static ACTIVE_JOURNAL: Arc<EventJournal>;
740}
741
742impl EventJournal {
743 pub async fn scope<F>(self: &Arc<Self>, future: F) -> F::Output
751 where
752 F: Future,
753 {
754 ACTIVE_JOURNAL.scope(self.clone(), future).await
755 }
756}
757
758pub async fn mark_stream_gap() {
762 let journal = match ACTIVE_JOURNAL.try_with(Arc::clone) {
763 Ok(journal) => journal,
764 Err(_) => return,
765 };
766 journal.mark_gap().await;
767}
768
769pub(crate) fn unix_now() -> i64 {
770 std::time::SystemTime::now()
771 .duration_since(std::time::UNIX_EPOCH)
772 .map(|elapsed| elapsed.as_secs() as i64)
773 .unwrap_or(0)
774}
775
776#[cfg(test)]
777mod tests {
778 use super::*;
779
780 fn config(max_records: usize, max_age_secs: u64) -> JournalConfig {
781 JournalConfig {
782 enabled: true,
783 max_bytes_per_view: u64::MAX,
784 max_records_per_view: max_records,
785 max_age: Duration::from_secs(max_age_secs),
786 }
787 }
788
789 fn frame(body: &str) -> Arc<Bytes> {
790 Arc::new(Bytes::from(format!(r#"{{"data":"{body}"}}"#)))
791 }
792
793 async fn append(journal: &EventJournal, view: &str, key: &str, body: &str) -> u64 {
794 journal
795 .append_with(view, key, |_offset| {
796 Ok::<_, std::convert::Infallible>(frame(body))
797 })
798 .await
799 .unwrap()
800 .expect("an open tape issues an offset")
801 .0
802 }
803
804 async fn cursor_at(journal: &EventJournal, offset: u64) -> Cursor {
805 Cursor {
806 epoch: journal.epoch().await,
807 offset,
808 }
809 }
810
811 #[tokio::test]
812 async fn offsets_are_dense_and_replay_is_ordered_and_exclusive() {
813 let journal = EventJournal::new(config(100, 600));
814 for index in 0..5 {
815 let offset = append(&journal, "Trade/append", &format!("key{index}"), "x").await;
816 assert_eq!(offset, index, "offsets are dense and monotonic");
817 }
818
819 let from_one = cursor_at(&journal, 1).await;
820 let replayed = journal
821 .replay_after("Trade/append", Some(&from_one))
822 .await
823 .unwrap();
824 assert_eq!(
825 replayed.iter().map(|r| r.offset).collect::<Vec<_>>(),
826 [2, 3, 4]
827 );
828
829 let all = journal.replay_after("Trade/append", None).await.unwrap();
830 assert_eq!(all.len(), 5);
831
832 let caught_up = cursor_at(&journal, 4).await;
833 assert!(journal
834 .replay_after("Trade/append", Some(&caught_up))
835 .await
836 .unwrap()
837 .is_empty());
838 }
839
840 #[tokio::test]
841 async fn the_published_frame_carries_the_offset_the_record_takes() {
842 let journal = EventJournal::new(config(100, 600));
843 for expected in 0..3u64 {
846 let (offset, payload) = journal
847 .append_with("Trade/append", "pool", |offset| {
848 Ok::<_, std::convert::Infallible>(Arc::new(Bytes::from(format!(
849 r#"{{"offset":{offset}}}"#
850 ))))
851 })
852 .await
853 .unwrap()
854 .expect("an open tape issues an offset");
855 assert_eq!(offset, expected);
856 assert_eq!(
857 String::from_utf8(payload.to_vec()).unwrap(),
858 format!(r#"{{"offset":{expected}}}"#)
859 );
860 }
861 }
862
863 #[tokio::test]
864 async fn a_cursor_from_a_previous_tape_lifetime_fails_closed() {
865 let first = EventJournal::new(config(100, 600));
866 for index in 0..10 {
867 append(&first, "Trade/append", &format!("key{index}"), "x").await;
868 }
869 let stale = cursor_at(&first, 5).await;
870
871 let second = EventJournal::new(config(100, 600));
873 for index in 0..10 {
874 append(&second, "Trade/append", &format!("key{index}"), "x").await;
875 }
876
877 let window = second.window("Trade/append").await;
880 assert!(stale.offset < window.next && stale.offset >= window.earliest);
881
882 let error = second
883 .replay_after("Trade/append", Some(&stale))
884 .await
885 .expect_err("a cursor from another lifetime is not a valid offset");
886 assert!(matches!(error, ReplayError::EpochMismatch(_)));
887 }
888
889 #[tokio::test]
890 async fn a_restored_tape_keeps_its_epoch_so_cursors_survive_restart() {
891 let first = EventJournal::new(config(100, 600));
892 for index in 0..10 {
893 append(&first, "Trade/append", &format!("key{index}"), "x").await;
894 }
895 let held = cursor_at(&first, 4).await;
896 let dumped = first.dump().await;
897
898 let restored = EventJournal::new(config(100, 600));
899 restored.hydrate(dumped, true).await;
900
901 assert_eq!(restored.epoch().await, held.epoch);
902 let replayed = restored
903 .replay_after("Trade/append", Some(&held))
904 .await
905 .expect("a cursor from the restored lifetime is still valid");
906 assert_eq!(replayed.len(), 5);
907 }
908
909 #[tokio::test]
910 async fn a_pre_epoch_snapshot_is_discarded_rather_than_adopted() {
911 let journal = EventJournal::new(config(100, 600));
912 let legacy = JournalSnapshot {
913 epoch: None,
914 views: HashMap::from([(
915 "Trade/append".to_string(),
916 PersistedViewJournal {
917 next_offset: 500,
918 records: Vec::new(),
919 gap_after: None,
920 },
921 )]),
922 };
923 journal.hydrate(legacy, true).await;
924 assert!(journal.window("Trade/append").await.is_empty());
925 assert_eq!(journal.window("Trade/append").await.next, 0);
926 }
927
928 #[tokio::test]
929 async fn replay_across_a_known_gap_is_refused() {
930 let journal = EventJournal::new(config(100, 600));
931 for index in 0..5 {
932 append(&journal, "Trade/append", &format!("key{index}"), "x").await;
933 }
934
935 journal.mark_gap().await;
937 for index in 5..8 {
938 append(&journal, "Trade/append", &format!("key{index}"), "x").await;
939 }
940
941 let window = journal.window("Trade/append").await;
942 assert_eq!(window.gap_after, Some(4));
943
944 let before_gap = cursor_at(&journal, 2).await;
945 let error = journal
946 .replay_after("Trade/append", Some(&before_gap))
947 .await
948 .expect_err("crossing the hole would look continuous");
949 assert!(matches!(error, ReplayError::GapCrossed(_)));
950
951 let after_gap = cursor_at(&journal, 5).await;
953 assert_eq!(
954 journal
955 .replay_after("Trade/append", Some(&after_gap))
956 .await
957 .unwrap()
958 .len(),
959 2
960 );
961 }
962
963 #[tokio::test]
964 async fn the_byte_bound_trims_before_the_record_bound() {
965 let journal = EventJournal::new(JournalConfig {
966 enabled: true,
967 max_bytes_per_view: 2 * (RECORD_OVERHEAD_BYTES + 40),
969 max_records_per_view: 10_000,
970 max_age: Duration::from_secs(600),
971 });
972 for index in 0..20 {
973 append(&journal, "Trade/append", "k", "payload-body").await;
974 let _ = index;
975 }
976
977 let window = journal.window("Trade/append").await;
978 assert_eq!(window.next, 20);
979 assert!(
980 window.next - window.earliest <= 3,
981 "the byte bound trims well before 10,000 records, got {window:?}"
982 );
983 assert!(!window.is_empty(), "a window is always left to serve");
984 }
985
986 #[tokio::test]
987 async fn a_cursor_below_the_window_is_expired_and_reports_the_window() {
988 let journal = EventJournal::new(config(3, 600));
989 for index in 0..10 {
990 append(&journal, "Trade/append", &format!("key{index}"), "x").await;
991 }
992
993 let window = journal.window("Trade/append").await;
994 assert_eq!(window.earliest, 7);
995 assert_eq!(window.next, 10);
996
997 let stale = cursor_at(&journal, 2).await;
998 let error = journal
999 .replay_after("Trade/append", Some(&stale))
1000 .await
1001 .expect_err("a cursor before the window cannot be honoured");
1002 assert_eq!(error, ReplayError::CursorExpired(window));
1003 }
1004
1005 #[tokio::test]
1006 async fn age_retention_drops_records_the_count_bound_would_keep() {
1007 let journal = EventJournal::new(config(1_000, 60));
1008 let now = unix_now();
1009
1010 journal
1011 .append_with_at("Trade/append", "old", None, now - 600, |_| {
1012 Ok::<_, std::convert::Infallible>(frame("x"))
1013 })
1014 .await
1015 .unwrap();
1016 journal
1017 .append_with_at("Trade/append", "fresh", None, now, |_| {
1018 Ok::<_, std::convert::Infallible>(frame("x"))
1019 })
1020 .await
1021 .unwrap();
1022
1023 let window = journal.window("Trade/append").await;
1024 assert_eq!(window.earliest, 1);
1025 assert_eq!(window.next, 2);
1026 }
1027
1028 #[tokio::test]
1029 async fn an_unknown_view_replays_nothing_rather_than_failing() {
1030 let journal = EventJournal::new(config(100, 600));
1031 let cursor = cursor_at(&journal, 7).await;
1032 assert!(journal
1033 .replay_after("Missing/append", Some(&cursor))
1034 .await
1035 .unwrap()
1036 .is_empty());
1037 assert!(journal.window("Missing/append").await.is_empty());
1038 }
1039
1040 #[test]
1041 fn cursors_round_trip_through_the_wire_form() {
1042 let cursor = Cursor {
1043 epoch: JournalEpoch("8a1f-epoch".to_string()),
1044 offset: 4211,
1045 };
1046 let rendered = cursor.to_string();
1047 assert_eq!(rendered, "8a1f-epoch:4211");
1048 assert_eq!(Cursor::parse(&rendered), Some(cursor));
1049
1050 assert_eq!(Cursor::parse("4211"), None);
1052 assert_eq!(Cursor::parse(":4211"), None);
1053 assert_eq!(Cursor::parse("epoch:not-a-number"), None);
1054 }
1055
1056 #[test]
1057 fn persisted_frames_round_trip_as_text_not_byte_arrays() {
1058 let record = PersistedRecord {
1059 row_scope: None,
1060 offset: 1,
1061 key: "pool".to_string(),
1062 payload: Arc::new(Bytes::from_static(br#"{"data":{"amount":5}}"#)),
1063 appended_at: 100,
1064 };
1065 let json = serde_json::to_string(&record).unwrap();
1066 assert!(
1067 json.contains(r#""payload":"{\"data\":{\"amount\":5}}""#),
1068 "frames persist as text, not a decimal byte array: {json}"
1069 );
1070
1071 let restored: PersistedRecord = serde_json::from_str(&json).unwrap();
1072 assert_eq!(restored.payload, record.payload);
1073 }
1074
1075 #[tokio::test]
1078 async fn an_ingestion_gap_is_marked_on_the_tape_in_scope() {
1079 let journal = Arc::new(EventJournal::new(config(100, 3_600)));
1080 for index in 0..3 {
1081 append(&journal, "Trade/append", "pool1", &index.to_string()).await;
1082 }
1083 assert_eq!(journal.window("Trade/append").await.gap_after, None);
1084
1085 journal.scope(mark_stream_gap()).await;
1086
1087 assert_eq!(
1088 journal.window("Trade/append").await.gap_after,
1089 Some(2),
1090 "a replay across the abandoned checkpoint must be refused"
1091 );
1092 }
1093
1094 #[tokio::test]
1097 async fn marking_a_gap_without_a_tape_in_scope_is_a_no_op() {
1098 mark_stream_gap().await;
1099 }
1100
1101 #[tokio::test]
1106 async fn a_view_that_first_appends_after_a_gap_does_not_look_complete() {
1107 let journal = EventJournal::new(config(100, 3_600));
1108
1109 journal.mark_gap().await;
1110 let offset = append(&journal, "Quiet/append", "pool1", "first").await;
1111
1112 let window = journal.window("Quiet/append").await;
1113 assert_eq!(offset, 1, "offset 0 is the reserved gap marker");
1114 assert_eq!(window.earliest, 1);
1115 assert_eq!(
1116 window.gap_after,
1117 Some(0),
1118 "the tape has to say it does not start where the view does"
1119 );
1120
1121 let before = cursor_at(&journal, 0).await;
1126 assert!(matches!(
1127 journal
1128 .replay_after("Quiet/append", Some(&before))
1129 .await
1130 .expect_err("a position before the hole cannot be served"),
1131 ReplayError::GapCrossed(_)
1132 ));
1133 }
1134
1135 #[tokio::test]
1140 async fn a_gap_still_refuses_once_only_the_record_after_it_remains() {
1141 let journal = EventJournal::new(config(1, 3_600));
1142 append(&journal, "Trade/append", "pool1", "before").await;
1143 journal.mark_gap().await;
1144 append(&journal, "Trade/append", "pool1", "after").await;
1145
1146 let window = journal.window("Trade/append").await;
1147 assert_eq!(
1148 (window.earliest, window.gap_after),
1149 (1, Some(0)),
1150 "retention dropped the record before the hole, not the hole"
1151 );
1152
1153 let across = cursor_at(&journal, 0).await;
1154 assert!(matches!(
1155 journal
1156 .replay_after("Trade/append", Some(&across))
1157 .await
1158 .expect_err("the hole is still between this cursor and the window"),
1159 ReplayError::GapCrossed(_)
1160 ));
1161 }
1162
1163 #[tokio::test]
1168 async fn a_sealed_tape_stops_issuing_positions_but_not_events() {
1169 let journal = EventJournal::new(config(100, 3_600));
1170 append(&journal, "Trade/append", "pool1", "before").await;
1171
1172 journal.seal();
1173
1174 let after = journal
1175 .append_with("Trade/append", "pool1", |_offset| {
1176 Ok::<_, std::convert::Infallible>(frame("after"))
1177 })
1178 .await
1179 .unwrap();
1180 assert!(
1181 after.is_none(),
1182 "a sealed tape must not hand out a position the snapshot cannot know"
1183 );
1184 assert_eq!(
1185 journal.window("Trade/append").await.next,
1186 1,
1187 "and must not advance past what was captured"
1188 );
1189 }
1190
1191 #[tokio::test]
1195 async fn a_gap_and_a_first_append_cannot_interleave() {
1196 let journal = Arc::new(EventJournal::new(config(100, 3_600)));
1197
1198 let marker = {
1199 let journal = journal.clone();
1200 tokio::spawn(async move { journal.mark_gap().await })
1201 };
1202 let appender = {
1203 let journal = journal.clone();
1204 tokio::spawn(async move { append(&journal, "Trade/append", "pool1", "first").await })
1205 };
1206 let offset = appender.await.unwrap();
1207 marker.await.unwrap();
1208
1209 let window = journal.window("Trade/append").await;
1210 assert!(
1211 window.gap_after.is_none_or(|gap| gap < offset),
1212 "a delivered record must land after the hole, not inside it: \
1213 offset {offset}, gap_after {:?}",
1214 window.gap_after
1215 );
1216 }
1217
1218 #[tokio::test]
1220 async fn a_first_append_with_no_gap_pending_starts_at_zero() {
1221 let journal = EventJournal::new(config(100, 3_600));
1222 assert_eq!(append(&journal, "Quiet/append", "pool1", "first").await, 0);
1223 assert_eq!(journal.window("Quiet/append").await.earliest, 0);
1224 }
1225}
1226
1227#[cfg(test)]
1228mod scope_retention_tests {
1229 use super::*;
1230 use crate::row_scope::RowScope;
1231
1232 #[tokio::test]
1233 async fn effective_scope_survives_retention_and_snapshot_restore() {
1234 let journal = EventJournal::new(JournalConfig {
1235 enabled: true,
1236 ..Default::default()
1237 });
1238 for scope in [RowScope::Scoped("B".into()), RowScope::Public] {
1239 journal
1240 .append_scoped_with("Row/append", "row", Some(scope), |_| {
1241 Ok::<_, std::convert::Infallible>(Arc::new(Bytes::from_static(
1242 br#"{"data":{"value":1}}"#,
1243 )))
1244 })
1245 .await
1246 .unwrap();
1247 }
1248 let dump = journal.dump().await;
1249 let serialized = serde_json::to_vec(&dump).unwrap();
1250 let restored = EventJournal::new(JournalConfig {
1251 enabled: true,
1252 ..Default::default()
1253 });
1254 restored
1255 .hydrate(serde_json::from_slice(&serialized).unwrap(), true)
1256 .await;
1257 let window = restored.window("Row/append").await;
1258 let records = restored
1259 .replay_after(
1260 "Row/append",
1261 Some(&Cursor {
1262 epoch: window.epoch,
1263 offset: 0,
1264 }),
1265 )
1266 .await
1267 .unwrap();
1268 assert_eq!(records.len(), 1);
1269 assert_eq!(records[0].row_scope, Some(RowScope::Public));
1270 let dump = restored.dump().await;
1271 assert_eq!(
1272 dump.views["Row/append"].records[0].row_scope,
1273 Some(RowScope::Scoped("B".into()))
1274 );
1275 }
1276}