Skip to main content

kevy_replicate/
feed.rs

1//! CDC feed layer over [`crate::source::ReplicationSource`] —
2//! the `(generation, offset)` cursor semantics the public FEED.* /
3//! `changes_since` surfaces speak.
4//!
5//! Cursor contract:
6//! - `generation` identifies one unbroken offset history. A given
7//!   `(gen, offset)` pair refers to the same stream prefix forever.
8//! - Clean shutdown + restart preserves both (continuity); FLUSHALL,
9//!   restore-from-snapshot, or an unclean shutdown bumps `gen` and
10//!   resets `offset` to 0.
11//! - A cursor the backlog can no longer serve (older generation, or
12//!   evicted offsets) answers `FeedRead::Resync` carrying the current
13//!   tail — the consumer rebuilds (SCAN) and resumes from there.
14
15use crate::source::{FromOffset, ReplicationSource};
16
17/// Why a feed read could not be served from the backlog.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum FeedRead {
20    /// Cursor unservable (stale generation or evicted offset): rebuild
21    /// from a scan, then resume from the carried tail cursor.
22    Resync {
23        /// Current generation.
24        generation: u64,
25        /// Next offset the source will assign (resume point).
26        tail: u64,
27    },
28    /// Cursor is ahead of the stream (`offset > next`) in the current
29    /// generation — caller bug or epoch confusion; reject the read.
30    Future,
31}
32
33/// One decoded feed entry: the offset plus the frame's wire bytes
34/// (envelope + offset + RESP argv — same encoding replicas consume;
35/// [`crate::replica_decode`] parses it).
36pub struct FeedFrame<'a> {
37    /// Offset the source assigned at push time.
38    pub offset: u64,
39    /// Wire-encoded frame bytes.
40    pub bytes: &'a [u8],
41}
42
43/// Generation-aware wrapper: owns the generation number alongside the
44/// backlog. The runtime persists `generation` via `kevy-persist`'s
45/// feed sidecars; this type only holds the in-memory value.
46pub struct FeedSource {
47    generation: u64,
48    source: ReplicationSource,
49}
50
51impl FeedSource {
52    /// Wrap a backlog at an explicit generation (loaded from the feed
53    /// sidecar at boot, or 1 for a fresh data dir).
54    pub fn new(generation: u64, source: ReplicationSource) -> Self {
55        Self { generation, source }
56    }
57
58    /// Current generation.
59    pub fn generation(&self) -> u64 {
60        self.generation
61    }
62
63    /// Access the wrapped backlog (push side + replica streaming keep
64    /// their existing [`ReplicationSource`] API).
65    pub fn source(&self) -> &ReplicationSource {
66        &self.source
67    }
68
69    /// Mutable access for `push_mutation` / `drop_up_to`.
70    pub fn source_mut(&mut self) -> &mut ReplicationSource {
71        &mut self.source
72    }
73
74    /// Break continuity: bump the generation and restart offsets at 0
75    /// (FLUSHALL / restore / unclean-boot policy). The backlog empties
76    /// — frames from the old generation must never be served under
77    /// the new one.
78    pub fn bump_generation(&mut self) {
79        self.generation += 1;
80        let budget = self.source.max_bytes();
81        self.source = ReplicationSource::new(budget);
82    }
83
84    /// The tail cursor: `(generation, next_offset)` — where a consumer
85    /// resuming after a rebuild starts.
86    pub fn tail(&self) -> (u64, u64) {
87        (self.generation, self.source.next_offset())
88    }
89
90    /// Serve up to `max` frames at cursor `(generation, offset)`.
91    ///
92    /// - Stale generation → `Resync` (any pre-bump cursor is
93    ///   unservable — uniqueness of `(gen, offset)` forbids
94    ///   re-serving).
95    /// - Generation from the future → `Future` (caller confusion).
96    /// - Evicted offset → `Resync` with the current tail.
97    pub fn read(
98        &self,
99        cursor_gen: u64,
100        offset: u64,
101        max: usize,
102    ) -> Result<Vec<FeedFrame<'_>>, FeedRead> {
103        if cursor_gen > self.generation {
104            return Err(FeedRead::Future);
105        }
106        if cursor_gen < self.generation {
107            let (generation, tail) = self.tail();
108            return Err(FeedRead::Resync { generation, tail });
109        }
110        match self.source.frames_from(offset) {
111            Ok(iter) => Ok(iter
112                .take(max)
113                .map(|f| FeedFrame { offset: f.offset, bytes: &f.bytes })
114                .collect()),
115            Err(FromOffset::TooOld) => {
116                let (generation, tail) = self.tail();
117                Err(FeedRead::Resync { generation, tail })
118            }
119            Err(FromOffset::Future) => Err(FeedRead::Future),
120        }
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use kevy_resp::Argv;
128
129    fn argv(parts: &[&[u8]]) -> Argv {
130        let mut a = Argv::default();
131        for p in parts {
132            a.push(p);
133        }
134        a
135    }
136
137    fn feed_with(n: usize) -> FeedSource {
138        let mut f = FeedSource::new(1, ReplicationSource::new(1 << 20));
139        for i in 0..n {
140            f.source_mut().push_mutation(&argv(&[b"SET", format!("k{i}").as_bytes(), b"v"]));
141        }
142        f
143    }
144
145    #[test]
146    fn read_serves_in_order_and_caps_at_max() {
147        let f = feed_with(5);
148        let frames = f.read(1, 0, 3).unwrap();
149        assert_eq!(frames.len(), 3);
150        assert_eq!(frames[0].offset, 0);
151        assert_eq!(frames[2].offset, 2);
152        // caught-up cursor = empty ok
153        assert!(f.read(1, 5, 10).unwrap().is_empty());
154    }
155
156    #[test]
157    fn stale_generation_resyncs_with_tail() {
158        let mut f = feed_with(3);
159        f.bump_generation();
160        f.source_mut().push_mutation(&argv(&[b"SET", b"new", b"v"]));
161        match f.read(1, 2, 10) {
162            Err(FeedRead::Resync { generation, tail }) => {
163                assert_eq!(generation, 2);
164                assert_eq!(tail, 1);
165            }
166            other => panic!("expected Resync, got {:?}", other.map(|v| v.len())),
167        }
168        // old-generation frames are gone — new gen serves only its own
169        let frames = f.read(2, 0, 10).unwrap();
170        assert_eq!(frames.len(), 1);
171    }
172
173    #[test]
174    fn future_cursors_rejected() {
175        let f = feed_with(2);
176        assert!(matches!(f.read(3, 0, 1), Err(FeedRead::Future)));
177        assert!(matches!(f.read(1, 99, 1), Err(FeedRead::Future)));
178    }
179
180    #[test]
181    fn evicted_offset_resyncs() {
182        // Tiny budget: pushing enough evicts the front.
183        let mut f = FeedSource::new(1, ReplicationSource::new(64));
184        for i in 0..50 {
185            f.source_mut().push_mutation(&argv(&[b"SET", format!("k{i}").as_bytes(), b"v"]));
186        }
187        match f.read(1, 0, 10) {
188            Err(FeedRead::Resync { generation, tail }) => {
189                assert_eq!(generation, 1);
190                assert_eq!(tail, 50);
191            }
192            other => panic!("expected Resync, got {:?}", other.map(|v| v.len())),
193        }
194    }
195}