Skip to main content

arete_server/
journal.rs

1//! Bounded append-only record of published events, per view.
2//!
3//! The entity cache is a latest-state projection: `deep_merge_with_append`
4//! folds each patch into the resident entity, so an intermediate occurrence is
5//! unrecoverable however large the cache is. Resuming a consumer therefore
6//! cannot be served from it — a replayable subscription needs the events
7//! themselves, kept in arrival order.
8//!
9//! # Cursors
10//!
11//! Each view has its own dense monotonic offset, assigned here at append time.
12//! The wire `_seq` (`{slot}:{slot_index:012}`) is deliberately *not* used as
13//! the replay cursor: `slot_index` is `txn_index` for instruction updates, so
14//! every event decoded from one transaction shares it and "replay each
15//! occurrence exactly once" is not expressible. An append-assigned offset is
16//! unique and gap-free by construction.
17//!
18//! A cursor is `{epoch}:{offset}`. The epoch identifies one tape lifetime.
19//! Offsets restart at zero whenever a tape is built without restoring one
20//! (snapshots disabled, a rejected or corrupt blob, any cold start), and dense
21//! offsets make a stale cursor indistinguishable from a live one — so without
22//! the epoch, a cursor from a previous lifetime would eventually land inside
23//! the new window and replay unrelated events as a continuation. The epoch
24//! makes that fail closed.
25//!
26//! Offsets are per view and are not comparable across views.
27
28use 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
41/// Per-record bookkeeping charged against the byte bound alongside the frame:
42/// the key `String`, the `Arc` control block, `Bytes` header and `VecDeque`
43/// slot. Approximate by design — the bound exists to be budgetable, not exact.
44const RECORD_OVERHEAD_BYTES: u64 = 120;
45
46/// Retention bounds for the event journal. Whichever bound bites first wins.
47#[derive(Clone, Debug)]
48pub struct JournalConfig {
49    /// Master opt-in. Disabled leaves append views on their previous
50    /// latest-state delivery.
51    pub enabled: bool,
52    /// Retained bytes per view, counting frames plus per-record overhead.
53    ///
54    /// This is the bound to budget against: frame size is stack-dependent, so
55    /// a record count cannot be reasoned about against a memory limit.
56    pub max_bytes_per_view: u64,
57    /// Retained records per view. A backstop against pathologically small
58    /// frames; the byte bound is the one that should normally bite.
59    pub max_records_per_view: usize,
60    /// Records older than this are dropped even when the size bounds are not
61    /// reached, so a quiet view does not advertise a stale replay window.
62    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    /// Load from `ARETE_JOURNAL_*`. Stays disabled unless
78    /// `ARETE_JOURNAL_ENABLED=true`.
79    ///
80    /// An embedder hosting several deployments in one process should use
81    /// [`crate::ServerBuilder::journal`] instead, which takes these as the
82    /// default and overrides them per runtime.
83    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/// Identifies one tape lifetime, so a cursor minted by a previous one cannot
116/// be mistaken for a live offset.
117#[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/// A cursor as it travels on the wire: `{epoch}:{offset}`.
144#[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/// One published event, retained verbatim.
170///
171/// `payload` is the exact bytes the projector published, so a replay is
172/// byte-identical to what a live subscriber received rather than a
173/// re-projection of current state.
174#[derive(Clone, Debug)]
175pub struct JournalRecord {
176    pub offset: u64,
177    pub key: String,
178    pub payload: Arc<Bytes>,
179    /// Unix seconds, for the age bound.
180    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/// The offsets a view can currently serve.
190///
191/// `earliest` is the oldest retained offset, so a cursor below it has fallen
192/// out of the window and cannot be honoured. `next` is the offset the next
193/// append will take, so a consumer at `next - 1` is fully caught up.
194#[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    /// When set, records after this offset were lost before the tape resumed:
201    /// the stream restarted live rather than from the snapshot's watermark.
202    /// Replay across this boundary is refused rather than presented as
203    /// continuous.
204    #[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    /// The cursor a caught-up consumer would hold.
214    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/// Why a replay could not be served.
223#[derive(Clone, Debug, PartialEq, Eq)]
224pub enum ReplayError {
225    /// The cursor was minted by a previous tape lifetime. Its offsets mean
226    /// nothing here, and dense offsets make that undetectable without the
227    /// epoch.
228    EpochMismatch(ReplayWindow),
229    /// The cursor is older than the oldest retained record.
230    CursorExpired(ReplayWindow),
231    /// The cursor is one this view has never issued. Refused rather than
232    /// treated as caught up, which would suppress delivery until the view's
233    /// offsets reached it.
234    CursorBeyondWindow(ReplayWindow),
235    /// Serving this cursor would cross a known hole in the tape.
236    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/// Durable form of one retained record.
251///
252/// `payload` persists as a JSON string rather than a byte array: the frame is
253/// already UTF-8 JSON, and `serde_json` writes `Vec<u8>` as an array of
254/// decimal integers, which inflates it roughly fourfold before compression and
255/// compresses worse than the text it came from. Escaping costs a little; a
256/// `RawValue` would cost nothing at all and is the upgrade if this ever shows
257/// up in a profile.
258#[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
267/// Serialize retained frames as JSON text, straight out of the shared buffer.
268///
269/// Serializing borrows the bytes, so a snapshot dump clones the `Arc` rather
270/// than deep-copying every payload inside the consistency guard.
271mod 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/// The retained tape for every view, carried in the snapshot payload.
299///
300/// ponytail: this rides inside the existing whole-state snapshot rather than
301/// its own segment files. That buys the snapshot's consistency cut for free —
302/// the tape and the entity cache are dumped under one barrier, so a restore
303/// can never leave the cache ahead of the tape — at the cost of rewriting the
304/// retained tape on every snapshot. Retention is bounded, so the cost is
305/// bounded too; if a deployment raises the bounds far enough that whole-blob
306/// rewrites hurt, the upgrade path is append-only segment objects alongside
307/// the snapshot.
308#[derive(Clone, Debug, Default, Serialize, Deserialize)]
309pub struct JournalSnapshot {
310    /// Absent in snapshots written before cursors carried an epoch; such a
311    /// tape is discarded on restore rather than adopted under a new epoch.
312    #[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    /// Offset the next append takes. Never rewound, including after pruning.
320    next_offset: u64,
321    /// Retained records, oldest first.
322    records: VecDeque<JournalRecord>,
323    /// Running total of `charged_bytes`, maintained on append and prune.
324    retained_bytes: u64,
325    /// Set when records after this offset were lost; see [`ReplayWindow`].
326    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        // Timestamps are whole seconds, so a record that has reached exactly
351        // `max_age` is retired. Comparing against a `now - max_age` cutoff
352        // strictly would keep it for another whole second.
353        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        // Always keep one record, so a view whose frames exceed the byte bound
365        // on their own still has a window rather than silently retaining
366        // nothing.
367        while self.retained_bytes > config.max_bytes_per_view && self.records.len() > 1 {
368            self.pop_oldest();
369        }
370        // A hole that has fallen out of the window no longer constrains
371        // anything that can still be replayed — but the record immediately
372        // after it is still in the window, and a cursor at the hole itself
373        // reads as "caught up to just before the window", the one position
374        // that is served rather than refused. The marker has to outlive the
375        // hole by one record.
376        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/// Per-view append-only event log with bounded retention.
385#[derive(Debug)]
386pub struct EventJournal {
387    epoch: RwLock<JournalEpoch>,
388    views: RwLock<HashMap<String, ViewJournal>>,
389    config: JournalConfig,
390    /// Set once the tape has been captured for the last time, after which it
391    /// stops issuing offsets; see [`seal`](EventJournal::seal).
392    sealed: std::sync::atomic::AtomicBool,
393    /// A gap was recorded while some views had no tape entry yet.
394    ///
395    /// `mark_gap` can only mark views it can see, and a view that has never
396    /// appended is not in the map — which is the low-traffic view whose first
397    /// records matter most and whose hole is least visible. The flag carries
398    /// the discontinuity forward to whichever view appends next.
399    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    /// Append one published event, building its frame from the offset it is
426    /// about to take.
427    ///
428    /// Reserving and committing happen under one lock so the offset embedded
429    /// in the published frame is always the offset the record takes. A live
430    /// subscriber checkpoints that value, so a mismatch would hand it a cursor
431    /// that means something else.
432    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        // Checked under the same lock the append takes, so a record either
451        // gets an offset the final snapshot knows about or gets none at all.
452        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            // This view's first record arrives after a hole, so its tape does
459            // not start where the view's history does. `gap_after` names the
460            // last offset before a hole, and there is no earlier record to
461            // name — so offset 0 is reserved as that marker and never issued.
462            // The window then opens at 1, which is the visible signal that the
463            // tape is not complete from the view's beginning, and a cursor at
464            // 0 is refused rather than served as a continuation.
465            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    /// Record that events were lost before the tape resumed.
487    ///
488    /// Called when the stream starts live over a hole: a restore that
489    /// hydrates state without resuming, or an ingestion runtime that gave up
490    /// on its checkpoint. The retained records stay valid, but everything
491    /// between them and the first live append is missing, and dense offsets
492    /// would otherwise present that hole as continuous.
493    /// Stop issuing offsets, permanently.
494    ///
495    /// The final snapshot is taken while the parser is still running — it has
496    /// to be, or an update can be cut between its VM write and its batch — so
497    /// publishing continues after the consistency cut releases, for as long as
498    /// encoding and storing the snapshot takes. Offsets issued in that window
499    /// are not in the file, and a restore that adopted the epoch would re-issue
500    /// them for different records under cursors that still validate.
501    ///
502    /// Sealing at the cut makes "a shutdown snapshot is offset-exact" true
503    /// rather than assumed. Events after it still publish and still reach
504    /// subscribers; they simply carry no cursor, so a consumer's last position
505    /// stays at the cut and the resume after restart replays them.
506    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        // Both the flag and the per-view markers are set under the views lock,
513        // which is also what an append holds. Setting the flag first would let
514        // a concurrent first append see it, take offset 1, and then be marked
515        // as inside the gap by the loop below — refusing a cursor for a record
516        // that was delivered after the hole, not before it.
517        let mut views = self.views.write().await;
518        // Views that have never appended are not in the map; the flag carries
519        // the gap to them when they do.
520        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    /// Offsets this view can currently serve.
530    ///
531    /// Prunes first: the age bound has to hold for a view that has gone
532    /// quiet, otherwise expired records stay advertised and replayable.
533    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    /// Every retained record strictly after `cursor`, in offset order.
552    ///
553    /// A consumer that has seen nothing passes `None`, which replays the whole
554    /// retained window.
555    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            // A cursor from another tape lifetime says nothing about this one.
571            if cursor.epoch != epoch {
572                return Err(ReplayError::EpochMismatch(window));
573            }
574            let offset = cursor.offset;
575            // Below the window: the records are gone.
576            if offset.saturating_add(1) < window.earliest {
577                return Err(ReplayError::CursorExpired(window));
578            }
579            // Above it: a cursor the view has never issued. Accepting it would
580            // silently suppress every later record until the offsets caught
581            // up, so refuse it rather than appear to work.
582            if window.next == 0 || offset >= window.next {
583                return Err(ReplayError::CursorBeyondWindow(window));
584            }
585            // Resuming from before a known hole would present the records
586            // after it as an unbroken continuation.
587            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    /// Durable form of the retained tape, for the snapshot payload.
604    ///
605    /// Payload `Arc`s are cloned, not the bytes, so this stays cheap inside
606    /// the snapshot's consistency guard.
607    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    /// Restore a dumped tape, so the advertised replay window survives a
638    /// restart and a consumer's cursor stays meaningful.
639    ///
640    /// `exact` says whether the snapshot's offsets are exactly what was
641    /// published — true only for a snapshot taken at shutdown, under the
642    /// consistency cut with nothing in flight. A periodic snapshot can be up
643    /// to its write interval behind, so restoring one rewinds `next_offset`
644    /// below offsets that have already been on the wire. Keeping the epoch
645    /// there would re-issue those offsets for different records under a
646    /// cursor that still validates: refused at first because the window has
647    /// not caught up, then silently served once it has. A fresh epoch makes
648    /// those cursors fail closed instead, which is the honest answer — after
649    /// a rewind they genuinely cannot be honoured.
650    ///
651    /// A snapshot without an epoch predates cursor epochs and is discarded —
652    /// adopting it under a fresh epoch would be indistinguishable from a cold
653    /// start anyway, and adopting its offsets under this tape's epoch would
654    /// validate cursors that should fail.
655    ///
656    /// Retention is re-applied on load: a snapshot restored after a long
657    /// outage must not advertise records the age bound has already retired.
658    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    /// Retained record count per view, for snapshot diagnostics.
694    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    /// Retained bytes per view, for capacity reporting.
703    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    /// Run the generated ingestion runtime with this server's tape in scope,
718    /// so it can report a stream discontinuity without being handed a
719    /// journal it has no other use for.
720    ///
721    /// Separate from the snapshot scope: the tape can be enabled with
722    /// snapshots off, and that combination is exactly the one where a lost
723    /// checkpoint has no other way to become visible.
724    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
732/// Called by the generated runtime when it starts the stream live over a
733/// hole, so a replay across that hole is refused instead of served as an
734/// unbroken continuation.
735pub 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        // The frame is built from the offset under the same lock that commits
818        // it, so the two can never disagree.
819        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        // A cold start: new tape, offsets restart at zero.
846        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        // Offset 5 is squarely inside the new window, so without the epoch
852        // this would silently replay unrelated events as a continuation.
853        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        // The stream restarted live: events after offset 4 were never retained.
910        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        // After the hole the tape is trustworthy again.
926        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            // Room for roughly two records once overhead is charged.
942            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        // A bare offset is not a cursor: it carries no lifetime.
1025        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    /// The ingestion runtime reports its own discontinuity through the same
1049    /// marker a restore uses, so the two cannot diverge.
1050    #[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    /// A runtime outside any journal scope — snapshots and the tape both off
1068    /// — must not panic when it reports a gap.
1069    #[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    /// `mark_gap` can only mark views it can see. A view that has not appended
1075    /// yet is the one whose first records matter most and whose hole is least
1076    /// visible: without this it would open at offset 0 with no discontinuity,
1077    /// reading as a complete tape from the beginning of the view's life.
1078    #[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        // Offset 0 is never issued, so no consumer holds it — but it is also
1095        // the position that reads as "caught up to just before the window",
1096        // so it has to be refused rather than served as the start of a
1097        // complete tape.
1098        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    /// The record immediately after a hole can still be in the window once
1109    /// the records before it have aged out. A cursor at the hole then reads as
1110    /// "caught up to just before the window" — the one position that is served
1111    /// rather than refused — so the marker has to outlive the hole by one.
1112    #[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    /// The final snapshot is taken while publishing continues, so a record
1137    /// issued after the cut would carry an offset the file does not hold — and
1138    /// the restore adopts that file's epoch. Sealing is what makes the
1139    /// "shutdown snapshots are offset-exact" assumption true.
1140    #[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    /// Both the flag and the per-view markers have to move under the views
1165    /// lock. Setting the flag first lets a concurrent first append take the
1166    /// post-gap offset and then be marked as inside the gap.
1167    #[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    /// Without a gap pending, a view still starts where it always did.
1192    #[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}