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 offset: u64,
177 pub key: String,
178 pub payload: Arc<Bytes>,
179 pub appended_at: i64,
181}
182
183impl JournalRecord {
184 fn charged_bytes(&self) -> u64 {
185 self.payload.len() as u64 + self.key.len() as u64 + RECORD_OVERHEAD_BYTES
186 }
187}
188
189#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
195#[serde(rename_all = "camelCase")]
196pub struct ReplayWindow {
197 pub epoch: JournalEpoch,
198 pub earliest: u64,
199 pub next: u64,
200 #[serde(skip_serializing_if = "Option::is_none")]
205 pub gap_after: Option<u64>,
206}
207
208impl ReplayWindow {
209 pub fn is_empty(&self) -> bool {
210 self.earliest >= self.next
211 }
212
213 pub fn latest_cursor(&self) -> Option<Cursor> {
215 (self.next > self.earliest).then(|| Cursor {
216 epoch: self.epoch.clone(),
217 offset: self.next - 1,
218 })
219 }
220}
221
222#[derive(Clone, Debug, PartialEq, Eq)]
224pub enum ReplayError {
225 EpochMismatch(ReplayWindow),
229 CursorExpired(ReplayWindow),
231 CursorBeyondWindow(ReplayWindow),
235 GapCrossed(ReplayWindow),
237}
238
239impl ReplayError {
240 pub fn window(&self) -> &ReplayWindow {
241 match self {
242 Self::EpochMismatch(window)
243 | Self::CursorExpired(window)
244 | Self::CursorBeyondWindow(window)
245 | Self::GapCrossed(window) => window,
246 }
247 }
248}
249
250#[derive(Clone, Debug, Serialize, Deserialize)]
259pub struct PersistedRecord {
260 pub offset: u64,
261 pub key: String,
262 #[serde(with = "frame_text")]
263 pub payload: Arc<Bytes>,
264 pub appended_at: i64,
265}
266
267mod frame_text {
272 use super::*;
273 use serde::{Deserializer, Serializer};
274
275 pub fn serialize<S: Serializer>(
276 payload: &Arc<Bytes>,
277 serializer: S,
278 ) -> Result<S::Ok, S::Error> {
279 let text = std::str::from_utf8(payload)
280 .map_err(|_| serde::ser::Error::custom("retained frame is not UTF-8"))?;
281 serializer.serialize_str(text)
282 }
283
284 pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Arc<Bytes>, D::Error> {
285 let text = String::deserialize(deserializer)?;
286 Ok(Arc::new(Bytes::from(text.into_bytes())))
287 }
288}
289
290#[derive(Clone, Debug, Serialize, Deserialize)]
291pub struct PersistedViewJournal {
292 pub next_offset: u64,
293 pub records: Vec<PersistedRecord>,
294 #[serde(default, skip_serializing_if = "Option::is_none")]
295 pub gap_after: Option<u64>,
296}
297
298#[derive(Clone, Debug, Default, Serialize, Deserialize)]
309pub struct JournalSnapshot {
310 #[serde(default, skip_serializing_if = "Option::is_none")]
313 pub epoch: Option<JournalEpoch>,
314 pub views: HashMap<String, PersistedViewJournal>,
315}
316
317#[derive(Debug, Default)]
318struct ViewJournal {
319 next_offset: u64,
321 records: VecDeque<JournalRecord>,
323 retained_bytes: u64,
325 gap_after: Option<u64>,
327}
328
329impl ViewJournal {
330 fn window(&self, epoch: &JournalEpoch) -> ReplayWindow {
331 ReplayWindow {
332 epoch: epoch.clone(),
333 earliest: self
334 .records
335 .front()
336 .map(|record| record.offset)
337 .unwrap_or(self.next_offset),
338 next: self.next_offset,
339 gap_after: self.gap_after,
340 }
341 }
342
343 fn pop_oldest(&mut self) {
344 if let Some(record) = self.records.pop_front() {
345 self.retained_bytes = self.retained_bytes.saturating_sub(record.charged_bytes());
346 }
347 }
348
349 fn prune(&mut self, config: &JournalConfig, now: i64) {
350 let max_age = config.max_age.as_secs() as i64;
354 while self
355 .records
356 .front()
357 .is_some_and(|record| record.appended_at.saturating_add(max_age) <= now)
358 {
359 self.pop_oldest();
360 }
361 while self.records.len() > config.max_records_per_view {
362 self.pop_oldest();
363 }
364 while self.retained_bytes > config.max_bytes_per_view && self.records.len() > 1 {
368 self.pop_oldest();
369 }
370 if let (Some(gap), Some(oldest)) = (self.gap_after, self.records.front()) {
377 if oldest.offset > gap.saturating_add(1) {
378 self.gap_after = None;
379 }
380 }
381 }
382}
383
384#[derive(Debug)]
386pub struct EventJournal {
387 epoch: RwLock<JournalEpoch>,
388 views: RwLock<HashMap<String, ViewJournal>>,
389 config: JournalConfig,
390 sealed: std::sync::atomic::AtomicBool,
393 pending_gap: std::sync::atomic::AtomicBool,
400}
401
402impl EventJournal {
403 pub fn new(config: JournalConfig) -> Self {
404 Self {
405 epoch: RwLock::new(JournalEpoch::new()),
406 views: RwLock::new(HashMap::new()),
407 sealed: std::sync::atomic::AtomicBool::new(false),
408 pending_gap: std::sync::atomic::AtomicBool::new(false),
409 config,
410 }
411 }
412
413 pub fn config(&self) -> &JournalConfig {
414 &self.config
415 }
416
417 pub fn is_enabled(&self) -> bool {
418 self.config.enabled
419 }
420
421 pub async fn epoch(&self) -> JournalEpoch {
422 self.epoch.read().await.clone()
423 }
424
425 pub async fn append_with<E>(
433 &self,
434 view_id: &str,
435 key: &str,
436 build_frame: impl FnOnce(u64) -> Result<Arc<Bytes>, E>,
437 ) -> Result<Option<(u64, Arc<Bytes>)>, E> {
438 self.append_with_at(view_id, key, unix_now(), build_frame)
439 .await
440 }
441
442 async fn append_with_at<E>(
443 &self,
444 view_id: &str,
445 key: &str,
446 now: i64,
447 build_frame: impl FnOnce(u64) -> Result<Arc<Bytes>, E>,
448 ) -> Result<Option<(u64, Arc<Bytes>)>, E> {
449 let mut views = self.views.write().await;
450 if self.sealed.load(std::sync::atomic::Ordering::Relaxed) {
453 return Ok(None);
454 }
455 let fresh = !views.contains_key(view_id);
456 let journal = views.entry(view_id.to_string()).or_default();
457 if fresh && self.pending_gap.load(std::sync::atomic::Ordering::Relaxed) {
458 journal.next_offset = 1;
466 journal.gap_after = Some(0);
467 }
468 let offset = journal.next_offset;
469 let payload = build_frame(offset)?;
470
471 journal.next_offset += 1;
472 let record = JournalRecord {
473 offset,
474 key: key.to_string(),
475 payload: payload.clone(),
476 appended_at: now,
477 };
478 journal.retained_bytes = journal
479 .retained_bytes
480 .saturating_add(record.charged_bytes());
481 journal.records.push_back(record);
482 journal.prune(&self.config, now);
483 Ok(Some((offset, payload)))
484 }
485
486 pub fn seal(&self) {
507 self.sealed
508 .store(true, std::sync::atomic::Ordering::Relaxed);
509 }
510
511 pub async fn mark_gap(&self) {
512 let mut views = self.views.write().await;
518 self.pending_gap
521 .store(true, std::sync::atomic::Ordering::Relaxed);
522 for journal in views.values_mut() {
523 if journal.next_offset > 0 {
524 journal.gap_after = Some(journal.next_offset - 1);
525 }
526 }
527 }
528
529 pub async fn window(&self, view_id: &str) -> ReplayWindow {
534 let epoch = self.epoch.read().await.clone();
535 let mut views = self.views.write().await;
536 let now = unix_now();
537 match views.get_mut(view_id) {
538 Some(journal) => {
539 journal.prune(&self.config, now);
540 journal.window(&epoch)
541 }
542 None => ReplayWindow {
543 epoch,
544 earliest: 0,
545 next: 0,
546 gap_after: None,
547 },
548 }
549 }
550
551 pub async fn replay_after(
556 &self,
557 view_id: &str,
558 cursor: Option<&Cursor>,
559 ) -> Result<Vec<JournalRecord>, ReplayError> {
560 let epoch = self.epoch.read().await.clone();
561 let mut views = self.views.write().await;
562 let now = unix_now();
563 let Some(journal) = views.get_mut(view_id) else {
564 return Ok(Vec::new());
565 };
566 journal.prune(&self.config, now);
567 let window = journal.window(&epoch);
568
569 if let Some(cursor) = cursor {
570 if cursor.epoch != epoch {
572 return Err(ReplayError::EpochMismatch(window));
573 }
574 let offset = cursor.offset;
575 if offset.saturating_add(1) < window.earliest {
577 return Err(ReplayError::CursorExpired(window));
578 }
579 if window.next == 0 || offset >= window.next {
583 return Err(ReplayError::CursorBeyondWindow(window));
584 }
585 if window.gap_after.is_some_and(|gap| offset <= gap) {
588 return Err(ReplayError::GapCrossed(window));
589 }
590 }
591
592 let first_wanted = cursor
593 .map(|cursor| cursor.offset.saturating_add(1))
594 .unwrap_or(window.earliest);
595 Ok(journal
596 .records
597 .iter()
598 .filter(|record| record.offset >= first_wanted)
599 .cloned()
600 .collect())
601 }
602
603 pub async fn dump(&self) -> JournalSnapshot {
608 let epoch = self.epoch.read().await.clone();
609 let views = self.views.read().await;
610 JournalSnapshot {
611 epoch: Some(epoch),
612 views: views
613 .iter()
614 .map(|(view_id, journal)| {
615 (
616 view_id.clone(),
617 PersistedViewJournal {
618 next_offset: journal.next_offset,
619 gap_after: journal.gap_after,
620 records: journal
621 .records
622 .iter()
623 .map(|record| PersistedRecord {
624 offset: record.offset,
625 key: record.key.clone(),
626 payload: record.payload.clone(),
627 appended_at: record.appended_at,
628 })
629 .collect(),
630 },
631 )
632 })
633 .collect(),
634 }
635 }
636
637 pub async fn hydrate(&self, snapshot: JournalSnapshot, exact: bool) {
659 if !self.config.enabled {
660 return;
661 }
662 let Some(epoch) = snapshot.epoch else {
663 return;
664 };
665 let now = unix_now();
666 if exact {
667 *self.epoch.write().await = epoch;
668 }
669 let mut views = self.views.write().await;
670 for (view_id, persisted) in snapshot.views {
671 let records: VecDeque<JournalRecord> = persisted
672 .records
673 .into_iter()
674 .map(|record| JournalRecord {
675 offset: record.offset,
676 key: record.key,
677 payload: record.payload,
678 appended_at: record.appended_at,
679 })
680 .collect();
681 let retained_bytes = records.iter().map(JournalRecord::charged_bytes).sum();
682 let mut journal = ViewJournal {
683 next_offset: persisted.next_offset,
684 records,
685 retained_bytes,
686 gap_after: persisted.gap_after,
687 };
688 journal.prune(&self.config, now);
689 views.insert(view_id, journal);
690 }
691 }
692
693 pub async fn entry_counts(&self) -> Vec<(String, u64)> {
695 let views = self.views.read().await;
696 views
697 .iter()
698 .map(|(view_id, journal)| (view_id.clone(), journal.records.len() as u64))
699 .collect()
700 }
701
702 pub async fn retained_bytes(&self) -> Vec<(String, u64)> {
704 let views = self.views.read().await;
705 views
706 .iter()
707 .map(|(view_id, journal)| (view_id.clone(), journal.retained_bytes))
708 .collect()
709 }
710}
711
712tokio::task_local! {
713 static ACTIVE_JOURNAL: Arc<EventJournal>;
714}
715
716impl EventJournal {
717 pub async fn scope<F>(self: &Arc<Self>, future: F) -> F::Output
725 where
726 F: Future,
727 {
728 ACTIVE_JOURNAL.scope(self.clone(), future).await
729 }
730}
731
732pub async fn mark_stream_gap() {
736 let journal = match ACTIVE_JOURNAL.try_with(Arc::clone) {
737 Ok(journal) => journal,
738 Err(_) => return,
739 };
740 journal.mark_gap().await;
741}
742
743pub(crate) fn unix_now() -> i64 {
744 std::time::SystemTime::now()
745 .duration_since(std::time::UNIX_EPOCH)
746 .map(|elapsed| elapsed.as_secs() as i64)
747 .unwrap_or(0)
748}
749
750#[cfg(test)]
751mod tests {
752 use super::*;
753
754 fn config(max_records: usize, max_age_secs: u64) -> JournalConfig {
755 JournalConfig {
756 enabled: true,
757 max_bytes_per_view: u64::MAX,
758 max_records_per_view: max_records,
759 max_age: Duration::from_secs(max_age_secs),
760 }
761 }
762
763 fn frame(body: &str) -> Arc<Bytes> {
764 Arc::new(Bytes::from(format!(r#"{{"data":"{body}"}}"#)))
765 }
766
767 async fn append(journal: &EventJournal, view: &str, key: &str, body: &str) -> u64 {
768 journal
769 .append_with(view, key, |_offset| {
770 Ok::<_, std::convert::Infallible>(frame(body))
771 })
772 .await
773 .unwrap()
774 .expect("an open tape issues an offset")
775 .0
776 }
777
778 async fn cursor_at(journal: &EventJournal, offset: u64) -> Cursor {
779 Cursor {
780 epoch: journal.epoch().await,
781 offset,
782 }
783 }
784
785 #[tokio::test]
786 async fn offsets_are_dense_and_replay_is_ordered_and_exclusive() {
787 let journal = EventJournal::new(config(100, 600));
788 for index in 0..5 {
789 let offset = append(&journal, "Trade/append", &format!("key{index}"), "x").await;
790 assert_eq!(offset, index, "offsets are dense and monotonic");
791 }
792
793 let from_one = cursor_at(&journal, 1).await;
794 let replayed = journal
795 .replay_after("Trade/append", Some(&from_one))
796 .await
797 .unwrap();
798 assert_eq!(
799 replayed.iter().map(|r| r.offset).collect::<Vec<_>>(),
800 [2, 3, 4]
801 );
802
803 let all = journal.replay_after("Trade/append", None).await.unwrap();
804 assert_eq!(all.len(), 5);
805
806 let caught_up = cursor_at(&journal, 4).await;
807 assert!(journal
808 .replay_after("Trade/append", Some(&caught_up))
809 .await
810 .unwrap()
811 .is_empty());
812 }
813
814 #[tokio::test]
815 async fn the_published_frame_carries_the_offset_the_record_takes() {
816 let journal = EventJournal::new(config(100, 600));
817 for expected in 0..3u64 {
820 let (offset, payload) = journal
821 .append_with("Trade/append", "pool", |offset| {
822 Ok::<_, std::convert::Infallible>(Arc::new(Bytes::from(format!(
823 r#"{{"offset":{offset}}}"#
824 ))))
825 })
826 .await
827 .unwrap()
828 .expect("an open tape issues an offset");
829 assert_eq!(offset, expected);
830 assert_eq!(
831 String::from_utf8(payload.to_vec()).unwrap(),
832 format!(r#"{{"offset":{expected}}}"#)
833 );
834 }
835 }
836
837 #[tokio::test]
838 async fn a_cursor_from_a_previous_tape_lifetime_fails_closed() {
839 let first = EventJournal::new(config(100, 600));
840 for index in 0..10 {
841 append(&first, "Trade/append", &format!("key{index}"), "x").await;
842 }
843 let stale = cursor_at(&first, 5).await;
844
845 let second = EventJournal::new(config(100, 600));
847 for index in 0..10 {
848 append(&second, "Trade/append", &format!("key{index}"), "x").await;
849 }
850
851 let window = second.window("Trade/append").await;
854 assert!(stale.offset < window.next && stale.offset >= window.earliest);
855
856 let error = second
857 .replay_after("Trade/append", Some(&stale))
858 .await
859 .expect_err("a cursor from another lifetime is not a valid offset");
860 assert!(matches!(error, ReplayError::EpochMismatch(_)));
861 }
862
863 #[tokio::test]
864 async fn a_restored_tape_keeps_its_epoch_so_cursors_survive_restart() {
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 held = cursor_at(&first, 4).await;
870 let dumped = first.dump().await;
871
872 let restored = EventJournal::new(config(100, 600));
873 restored.hydrate(dumped, true).await;
874
875 assert_eq!(restored.epoch().await, held.epoch);
876 let replayed = restored
877 .replay_after("Trade/append", Some(&held))
878 .await
879 .expect("a cursor from the restored lifetime is still valid");
880 assert_eq!(replayed.len(), 5);
881 }
882
883 #[tokio::test]
884 async fn a_pre_epoch_snapshot_is_discarded_rather_than_adopted() {
885 let journal = EventJournal::new(config(100, 600));
886 let legacy = JournalSnapshot {
887 epoch: None,
888 views: HashMap::from([(
889 "Trade/append".to_string(),
890 PersistedViewJournal {
891 next_offset: 500,
892 records: Vec::new(),
893 gap_after: None,
894 },
895 )]),
896 };
897 journal.hydrate(legacy, true).await;
898 assert!(journal.window("Trade/append").await.is_empty());
899 assert_eq!(journal.window("Trade/append").await.next, 0);
900 }
901
902 #[tokio::test]
903 async fn replay_across_a_known_gap_is_refused() {
904 let journal = EventJournal::new(config(100, 600));
905 for index in 0..5 {
906 append(&journal, "Trade/append", &format!("key{index}"), "x").await;
907 }
908
909 journal.mark_gap().await;
911 for index in 5..8 {
912 append(&journal, "Trade/append", &format!("key{index}"), "x").await;
913 }
914
915 let window = journal.window("Trade/append").await;
916 assert_eq!(window.gap_after, Some(4));
917
918 let before_gap = cursor_at(&journal, 2).await;
919 let error = journal
920 .replay_after("Trade/append", Some(&before_gap))
921 .await
922 .expect_err("crossing the hole would look continuous");
923 assert!(matches!(error, ReplayError::GapCrossed(_)));
924
925 let after_gap = cursor_at(&journal, 5).await;
927 assert_eq!(
928 journal
929 .replay_after("Trade/append", Some(&after_gap))
930 .await
931 .unwrap()
932 .len(),
933 2
934 );
935 }
936
937 #[tokio::test]
938 async fn the_byte_bound_trims_before_the_record_bound() {
939 let journal = EventJournal::new(JournalConfig {
940 enabled: true,
941 max_bytes_per_view: 2 * (RECORD_OVERHEAD_BYTES + 40),
943 max_records_per_view: 10_000,
944 max_age: Duration::from_secs(600),
945 });
946 for index in 0..20 {
947 append(&journal, "Trade/append", "k", "payload-body").await;
948 let _ = index;
949 }
950
951 let window = journal.window("Trade/append").await;
952 assert_eq!(window.next, 20);
953 assert!(
954 window.next - window.earliest <= 3,
955 "the byte bound trims well before 10,000 records, got {window:?}"
956 );
957 assert!(!window.is_empty(), "a window is always left to serve");
958 }
959
960 #[tokio::test]
961 async fn a_cursor_below_the_window_is_expired_and_reports_the_window() {
962 let journal = EventJournal::new(config(3, 600));
963 for index in 0..10 {
964 append(&journal, "Trade/append", &format!("key{index}"), "x").await;
965 }
966
967 let window = journal.window("Trade/append").await;
968 assert_eq!(window.earliest, 7);
969 assert_eq!(window.next, 10);
970
971 let stale = cursor_at(&journal, 2).await;
972 let error = journal
973 .replay_after("Trade/append", Some(&stale))
974 .await
975 .expect_err("a cursor before the window cannot be honoured");
976 assert_eq!(error, ReplayError::CursorExpired(window));
977 }
978
979 #[tokio::test]
980 async fn age_retention_drops_records_the_count_bound_would_keep() {
981 let journal = EventJournal::new(config(1_000, 60));
982 let now = unix_now();
983
984 journal
985 .append_with_at("Trade/append", "old", now - 600, |_| {
986 Ok::<_, std::convert::Infallible>(frame("x"))
987 })
988 .await
989 .unwrap();
990 journal
991 .append_with_at("Trade/append", "fresh", now, |_| {
992 Ok::<_, std::convert::Infallible>(frame("x"))
993 })
994 .await
995 .unwrap();
996
997 let window = journal.window("Trade/append").await;
998 assert_eq!(window.earliest, 1);
999 assert_eq!(window.next, 2);
1000 }
1001
1002 #[tokio::test]
1003 async fn an_unknown_view_replays_nothing_rather_than_failing() {
1004 let journal = EventJournal::new(config(100, 600));
1005 let cursor = cursor_at(&journal, 7).await;
1006 assert!(journal
1007 .replay_after("Missing/append", Some(&cursor))
1008 .await
1009 .unwrap()
1010 .is_empty());
1011 assert!(journal.window("Missing/append").await.is_empty());
1012 }
1013
1014 #[test]
1015 fn cursors_round_trip_through_the_wire_form() {
1016 let cursor = Cursor {
1017 epoch: JournalEpoch("8a1f-epoch".to_string()),
1018 offset: 4211,
1019 };
1020 let rendered = cursor.to_string();
1021 assert_eq!(rendered, "8a1f-epoch:4211");
1022 assert_eq!(Cursor::parse(&rendered), Some(cursor));
1023
1024 assert_eq!(Cursor::parse("4211"), None);
1026 assert_eq!(Cursor::parse(":4211"), None);
1027 assert_eq!(Cursor::parse("epoch:not-a-number"), None);
1028 }
1029
1030 #[test]
1031 fn persisted_frames_round_trip_as_text_not_byte_arrays() {
1032 let record = PersistedRecord {
1033 offset: 1,
1034 key: "pool".to_string(),
1035 payload: Arc::new(Bytes::from_static(br#"{"data":{"amount":5}}"#)),
1036 appended_at: 100,
1037 };
1038 let json = serde_json::to_string(&record).unwrap();
1039 assert!(
1040 json.contains(r#""payload":"{\"data\":{\"amount\":5}}""#),
1041 "frames persist as text, not a decimal byte array: {json}"
1042 );
1043
1044 let restored: PersistedRecord = serde_json::from_str(&json).unwrap();
1045 assert_eq!(restored.payload, record.payload);
1046 }
1047
1048 #[tokio::test]
1051 async fn an_ingestion_gap_is_marked_on_the_tape_in_scope() {
1052 let journal = Arc::new(EventJournal::new(config(100, 3_600)));
1053 for index in 0..3 {
1054 append(&journal, "Trade/append", "pool1", &index.to_string()).await;
1055 }
1056 assert_eq!(journal.window("Trade/append").await.gap_after, None);
1057
1058 journal.scope(mark_stream_gap()).await;
1059
1060 assert_eq!(
1061 journal.window("Trade/append").await.gap_after,
1062 Some(2),
1063 "a replay across the abandoned checkpoint must be refused"
1064 );
1065 }
1066
1067 #[tokio::test]
1070 async fn marking_a_gap_without_a_tape_in_scope_is_a_no_op() {
1071 mark_stream_gap().await;
1072 }
1073
1074 #[tokio::test]
1079 async fn a_view_that_first_appends_after_a_gap_does_not_look_complete() {
1080 let journal = EventJournal::new(config(100, 3_600));
1081
1082 journal.mark_gap().await;
1083 let offset = append(&journal, "Quiet/append", "pool1", "first").await;
1084
1085 let window = journal.window("Quiet/append").await;
1086 assert_eq!(offset, 1, "offset 0 is the reserved gap marker");
1087 assert_eq!(window.earliest, 1);
1088 assert_eq!(
1089 window.gap_after,
1090 Some(0),
1091 "the tape has to say it does not start where the view does"
1092 );
1093
1094 let before = cursor_at(&journal, 0).await;
1099 assert!(matches!(
1100 journal
1101 .replay_after("Quiet/append", Some(&before))
1102 .await
1103 .expect_err("a position before the hole cannot be served"),
1104 ReplayError::GapCrossed(_)
1105 ));
1106 }
1107
1108 #[tokio::test]
1113 async fn a_gap_still_refuses_once_only_the_record_after_it_remains() {
1114 let journal = EventJournal::new(config(1, 3_600));
1115 append(&journal, "Trade/append", "pool1", "before").await;
1116 journal.mark_gap().await;
1117 append(&journal, "Trade/append", "pool1", "after").await;
1118
1119 let window = journal.window("Trade/append").await;
1120 assert_eq!(
1121 (window.earliest, window.gap_after),
1122 (1, Some(0)),
1123 "retention dropped the record before the hole, not the hole"
1124 );
1125
1126 let across = cursor_at(&journal, 0).await;
1127 assert!(matches!(
1128 journal
1129 .replay_after("Trade/append", Some(&across))
1130 .await
1131 .expect_err("the hole is still between this cursor and the window"),
1132 ReplayError::GapCrossed(_)
1133 ));
1134 }
1135
1136 #[tokio::test]
1141 async fn a_sealed_tape_stops_issuing_positions_but_not_events() {
1142 let journal = EventJournal::new(config(100, 3_600));
1143 append(&journal, "Trade/append", "pool1", "before").await;
1144
1145 journal.seal();
1146
1147 let after = journal
1148 .append_with("Trade/append", "pool1", |_offset| {
1149 Ok::<_, std::convert::Infallible>(frame("after"))
1150 })
1151 .await
1152 .unwrap();
1153 assert!(
1154 after.is_none(),
1155 "a sealed tape must not hand out a position the snapshot cannot know"
1156 );
1157 assert_eq!(
1158 journal.window("Trade/append").await.next,
1159 1,
1160 "and must not advance past what was captured"
1161 );
1162 }
1163
1164 #[tokio::test]
1168 async fn a_gap_and_a_first_append_cannot_interleave() {
1169 let journal = Arc::new(EventJournal::new(config(100, 3_600)));
1170
1171 let marker = {
1172 let journal = journal.clone();
1173 tokio::spawn(async move { journal.mark_gap().await })
1174 };
1175 let appender = {
1176 let journal = journal.clone();
1177 tokio::spawn(async move { append(&journal, "Trade/append", "pool1", "first").await })
1178 };
1179 let offset = appender.await.unwrap();
1180 marker.await.unwrap();
1181
1182 let window = journal.window("Trade/append").await;
1183 assert!(
1184 window.gap_after.is_none_or(|gap| gap < offset),
1185 "a delivered record must land after the hole, not inside it: \
1186 offset {offset}, gap_after {:?}",
1187 window.gap_after
1188 );
1189 }
1190
1191 #[tokio::test]
1193 async fn a_first_append_with_no_gap_pending_starts_at_zero() {
1194 let journal = EventJournal::new(config(100, 3_600));
1195 assert_eq!(append(&journal, "Quiet/append", "pool1", "first").await, 0);
1196 assert_eq!(journal.window("Quiet/append").await.earliest, 0);
1197 }
1198}