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    /// Effective scope when published. Absent in legacy journals.
177    pub row_scope: Option<crate::row_scope::RowScope>,
178    pub offset: u64,
179    pub key: String,
180    pub payload: Arc<Bytes>,
181    /// Unix seconds, for the age bound.
182    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/// The offsets a view can currently serve.
198///
199/// `earliest` is the oldest retained offset, so a cursor below it has fallen
200/// out of the window and cannot be honoured. `next` is the offset the next
201/// append will take, so a consumer at `next - 1` is fully caught up.
202#[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    /// When set, records after this offset were lost before the tape resumed:
209    /// the stream restarted live rather than from the snapshot's watermark.
210    /// Replay across this boundary is refused rather than presented as
211    /// continuous.
212    #[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    /// The cursor a caught-up consumer would hold.
222    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/// Why a replay could not be served.
231#[derive(Clone, Debug, PartialEq, Eq)]
232pub enum ReplayError {
233    /// The cursor was minted by a previous tape lifetime. Its offsets mean
234    /// nothing here, and dense offsets make that undetectable without the
235    /// epoch.
236    EpochMismatch(ReplayWindow),
237    /// The cursor is older than the oldest retained record.
238    CursorExpired(ReplayWindow),
239    /// The cursor is one this view has never issued. Refused rather than
240    /// treated as caught up, which would suppress delivery until the view's
241    /// offsets reached it.
242    CursorBeyondWindow(ReplayWindow),
243    /// Serving this cursor would cross a known hole in the tape.
244    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/// Durable form of one retained record.
259///
260/// `payload` persists as a JSON string rather than a byte array: the frame is
261/// already UTF-8 JSON, and `serde_json` writes `Vec<u8>` as an array of
262/// decimal integers, which inflates it roughly fourfold before compression and
263/// compresses worse than the text it came from. Escaping costs a little; a
264/// `RawValue` would cost nothing at all and is the upgrade if this ever shows
265/// up in a profile.
266#[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
277/// Serialize retained frames as JSON text, straight out of the shared buffer.
278///
279/// Serializing borrows the bytes, so a snapshot dump clones the `Arc` rather
280/// than deep-copying every payload inside the consistency guard.
281mod 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/// The retained tape for every view, carried in the snapshot payload.
309///
310/// ponytail: this rides inside the existing whole-state snapshot rather than
311/// its own segment files. That buys the snapshot's consistency cut for free —
312/// the tape and the entity cache are dumped under one barrier, so a restore
313/// can never leave the cache ahead of the tape — at the cost of rewriting the
314/// retained tape on every snapshot. Retention is bounded, so the cost is
315/// bounded too; if a deployment raises the bounds far enough that whole-blob
316/// rewrites hurt, the upgrade path is append-only segment objects alongside
317/// the snapshot.
318#[derive(Clone, Debug, Default, Serialize, Deserialize)]
319pub struct JournalSnapshot {
320    /// Absent in snapshots written before cursors carried an epoch; such a
321    /// tape is discarded on restore rather than adopted under a new epoch.
322    #[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    /// Offset the next append takes. Never rewound, including after pruning.
330    next_offset: u64,
331    /// Retained records, oldest first.
332    records: VecDeque<JournalRecord>,
333    /// Running total of `charged_bytes`, maintained on append and prune.
334    retained_bytes: u64,
335    /// Set when records after this offset were lost; see [`ReplayWindow`].
336    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        // Timestamps are whole seconds, so a record that has reached exactly
361        // `max_age` is retired. Comparing against a `now - max_age` cutoff
362        // strictly would keep it for another whole second.
363        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        // Always keep one record, so a view whose frames exceed the byte bound
375        // on their own still has a window rather than silently retaining
376        // nothing.
377        while self.retained_bytes > config.max_bytes_per_view && self.records.len() > 1 {
378            self.pop_oldest();
379        }
380        // A hole that has fallen out of the window no longer constrains
381        // anything that can still be replayed — but the record immediately
382        // after it is still in the window, and a cursor at the hole itself
383        // reads as "caught up to just before the window", the one position
384        // that is served rather than refused. The marker has to outlive the
385        // hole by one record.
386        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/// Per-view append-only event log with bounded retention.
395#[derive(Debug)]
396pub struct EventJournal {
397    epoch: RwLock<JournalEpoch>,
398    views: RwLock<HashMap<String, ViewJournal>>,
399    config: JournalConfig,
400    /// Set once the tape has been captured for the last time, after which it
401    /// stops issuing offsets; see [`seal`](EventJournal::seal).
402    sealed: std::sync::atomic::AtomicBool,
403    /// A gap was recorded while some views had no tape entry yet.
404    ///
405    /// `mark_gap` can only mark views it can see, and a view that has never
406    /// appended is not in the map — which is the low-traffic view whose first
407    /// records matter most and whose hole is least visible. The flag carries
408    /// the discontinuity forward to whichever view appends next.
409    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    /// Append one published event, building its frame from the offset it is
436    /// about to take.
437    ///
438    /// Reserving and committing happen under one lock so the offset embedded
439    /// in the published frame is always the offset the record takes. A live
440    /// subscriber checkpoints that value, so a mismatch would hand it a cursor
441    /// that means something else.
442    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    /// Append with the effective row scope captured at publication time.
453    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        // Checked under the same lock the append takes, so a record either
474        // gets an offset the final snapshot knows about or gets none at all.
475        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            // This view's first record arrives after a hole, so its tape does
482            // not start where the view's history does. `gap_after` names the
483            // last offset before a hole, and there is no earlier record to
484            // name — so offset 0 is reserved as that marker and never issued.
485            // The window then opens at 1, which is the visible signal that the
486            // tape is not complete from the view's beginning, and a cursor at
487            // 0 is refused rather than served as a continuation.
488            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    /// Record that events were lost before the tape resumed.
511    ///
512    /// Called when the stream starts live over a hole: a restore that
513    /// hydrates state without resuming, or an ingestion runtime that gave up
514    /// on its checkpoint. The retained records stay valid, but everything
515    /// between them and the first live append is missing, and dense offsets
516    /// would otherwise present that hole as continuous.
517    /// Stop issuing offsets, permanently.
518    ///
519    /// The final snapshot is taken while the parser is still running — it has
520    /// to be, or an update can be cut between its VM write and its batch — so
521    /// publishing continues after the consistency cut releases, for as long as
522    /// encoding and storing the snapshot takes. Offsets issued in that window
523    /// are not in the file, and a restore that adopted the epoch would re-issue
524    /// them for different records under cursors that still validate.
525    ///
526    /// Sealing at the cut makes "a shutdown snapshot is offset-exact" true
527    /// rather than assumed. Events after it still publish and still reach
528    /// subscribers; they simply carry no cursor, so a consumer's last position
529    /// stays at the cut and the resume after restart replays them.
530    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        // Both the flag and the per-view markers are set under the views lock,
537        // which is also what an append holds. Setting the flag first would let
538        // a concurrent first append see it, take offset 1, and then be marked
539        // as inside the gap by the loop below — refusing a cursor for a record
540        // that was delivered after the hole, not before it.
541        let mut views = self.views.write().await;
542        // Views that have never appended are not in the map; the flag carries
543        // the gap to them when they do.
544        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    /// Offsets this view can currently serve.
554    ///
555    /// Prunes first: the age bound has to hold for a view that has gone
556    /// quiet, otherwise expired records stay advertised and replayable.
557    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    /// Every retained record strictly after `cursor`, in offset order.
576    ///
577    /// A consumer that has seen nothing passes `None`, which replays the whole
578    /// retained window.
579    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            // A cursor from another tape lifetime says nothing about this one.
595            if cursor.epoch != epoch {
596                return Err(ReplayError::EpochMismatch(window));
597            }
598            let offset = cursor.offset;
599            // Below the window: the records are gone.
600            if offset.saturating_add(1) < window.earliest {
601                return Err(ReplayError::CursorExpired(window));
602            }
603            // Above it: a cursor the view has never issued. Accepting it would
604            // silently suppress every later record until the offsets caught
605            // up, so refuse it rather than appear to work.
606            if window.next == 0 || offset >= window.next {
607                return Err(ReplayError::CursorBeyondWindow(window));
608            }
609            // Resuming from before a known hole would present the records
610            // after it as an unbroken continuation.
611            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    /// Durable form of the retained tape, for the snapshot payload.
628    ///
629    /// Payload `Arc`s are cloned, not the bytes, so this stays cheap inside
630    /// the snapshot's consistency guard.
631    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    /// Restore a dumped tape, so the advertised replay window survives a
663    /// restart and a consumer's cursor stays meaningful.
664    ///
665    /// `exact` says whether the snapshot's offsets are exactly what was
666    /// published — true only for a snapshot taken at shutdown, under the
667    /// consistency cut with nothing in flight. A periodic snapshot can be up
668    /// to its write interval behind, so restoring one rewinds `next_offset`
669    /// below offsets that have already been on the wire. Keeping the epoch
670    /// there would re-issue those offsets for different records under a
671    /// cursor that still validates: refused at first because the window has
672    /// not caught up, then silently served once it has. A fresh epoch makes
673    /// those cursors fail closed instead, which is the honest answer — after
674    /// a rewind they genuinely cannot be honoured.
675    ///
676    /// A snapshot without an epoch predates cursor epochs and is discarded —
677    /// adopting it under a fresh epoch would be indistinguishable from a cold
678    /// start anyway, and adopting its offsets under this tape's epoch would
679    /// validate cursors that should fail.
680    ///
681    /// Retention is re-applied on load: a snapshot restored after a long
682    /// outage must not advertise records the age bound has already retired.
683    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    /// Retained record count per view, for snapshot diagnostics.
720    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    /// Retained bytes per view, for capacity reporting.
729    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    /// Run the generated ingestion runtime with this server's tape in scope,
744    /// so it can report a stream discontinuity without being handed a
745    /// journal it has no other use for.
746    ///
747    /// Separate from the snapshot scope: the tape can be enabled with
748    /// snapshots off, and that combination is exactly the one where a lost
749    /// checkpoint has no other way to become visible.
750    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
758/// Called by the generated runtime when it starts the stream live over a
759/// hole, so a replay across that hole is refused instead of served as an
760/// unbroken continuation.
761pub 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        // The frame is built from the offset under the same lock that commits
844        // it, so the two can never disagree.
845        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        // A cold start: new tape, offsets restart at zero.
872        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        // Offset 5 is squarely inside the new window, so without the epoch
878        // this would silently replay unrelated events as a continuation.
879        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        // The stream restarted live: events after offset 4 were never retained.
936        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        // After the hole the tape is trustworthy again.
952        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            // Room for roughly two records once overhead is charged.
968            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        // A bare offset is not a cursor: it carries no lifetime.
1051        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    /// The ingestion runtime reports its own discontinuity through the same
1076    /// marker a restore uses, so the two cannot diverge.
1077    #[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    /// A runtime outside any journal scope — snapshots and the tape both off
1095    /// — must not panic when it reports a gap.
1096    #[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    /// `mark_gap` can only mark views it can see. A view that has not appended
1102    /// yet is the one whose first records matter most and whose hole is least
1103    /// visible: without this it would open at offset 0 with no discontinuity,
1104    /// reading as a complete tape from the beginning of the view's life.
1105    #[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        // Offset 0 is never issued, so no consumer holds it — but it is also
1122        // the position that reads as "caught up to just before the window",
1123        // so it has to be refused rather than served as the start of a
1124        // complete tape.
1125        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    /// The record immediately after a hole can still be in the window once
1136    /// the records before it have aged out. A cursor at the hole then reads as
1137    /// "caught up to just before the window" — the one position that is served
1138    /// rather than refused — so the marker has to outlive the hole by one.
1139    #[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    /// The final snapshot is taken while publishing continues, so a record
1164    /// issued after the cut would carry an offset the file does not hold — and
1165    /// the restore adopts that file's epoch. Sealing is what makes the
1166    /// "shutdown snapshots are offset-exact" assumption true.
1167    #[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    /// Both the flag and the per-view markers have to move under the views
1192    /// lock. Setting the flag first lets a concurrent first append take the
1193    /// post-gap offset and then be marked as inside the gap.
1194    #[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    /// Without a gap pending, a view still starts where it always did.
1219    #[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}