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//!   The value is an OPAQUE random identity, not a counter: two nodes
9//!   counting in lockstep (a startup election on one, a failover
10//!   promotion on the other) land on the same number for different
11//!   histories, and a stale cursor then slips through the fence into
12//!   offset aliasing (the availgate failover wedge). Randomness is
13//!   what makes cross-node fencing sound; there is no ordering.
14//! - Clean shutdown + restart preserves both (continuity); FLUSHALL,
15//!   restore-from-snapshot, or an unclean shutdown draws a fresh
16//!   `gen` and resets `offset` to 0.
17//! - A cursor from any OTHER generation, or one whose offsets were
18//!   evicted, answers `FeedRead::Resync` carrying the current tail —
19//!   the consumer rebuilds (SCAN) and resumes from there.
20
21use crate::source::{FromOffset, ReplicationSource};
22
23/// Why a feed read could not be served from the backlog.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum FeedRead {
26    /// Cursor unservable (stale generation or evicted offset): rebuild
27    /// from a scan, then resume from the carried tail cursor.
28    Resync {
29        /// Current generation.
30        generation: u64,
31        /// Next offset the source will assign (resume point).
32        tail: u64,
33    },
34    /// Cursor is ahead of the stream (`offset > next`) in the CURRENT
35    /// generation — caller bug or epoch confusion; reject the read.
36    /// (A mismatched generation is `Resync`, never `Future`:
37    /// generations carry no order to be "ahead" in.)
38    Future,
39}
40
41/// One decoded feed entry: the offset plus the frame's wire bytes
42/// (envelope + offset + RESP argv — same encoding replicas consume;
43/// [`crate::replica_decode`] parses it).
44pub struct FeedFrame<'a> {
45    /// Offset the source assigned at push time.
46    pub offset: u64,
47    /// Wire-encoded frame bytes.
48    pub bytes: &'a [u8],
49}
50
51/// Generation-aware wrapper: owns the generation number alongside the
52/// backlog. The runtime persists `generation` via `kevy-persist`'s
53/// feed sidecars; this type only holds the in-memory value.
54pub struct FeedSource {
55    generation: u64,
56    source: ReplicationSource,
57}
58
59impl FeedSource {
60    /// Wrap a backlog at an explicit generation (loaded from the feed
61    /// sidecar at boot, or 1 for a fresh data dir).
62    pub fn new(generation: u64, source: ReplicationSource) -> Self {
63        Self { generation, source }
64    }
65
66    /// Current generation.
67    pub fn generation(&self) -> u64 {
68        self.generation
69    }
70
71    /// Access the wrapped backlog (push side + replica streaming keep
72    /// their existing [`ReplicationSource`] API).
73    pub fn source(&self) -> &ReplicationSource {
74        &self.source
75    }
76
77    /// Mutable access for `push_mutation` / `drop_up_to`.
78    pub fn source_mut(&mut self) -> &mut ReplicationSource {
79        &mut self.source
80    }
81
82    /// Break continuity: draw a fresh generation and restart offsets
83    /// at 0 (FLUSHALL / restore / promotion / unclean-boot policy).
84    /// The backlog empties — frames from the old generation must
85    /// never be served under the new one.
86    pub fn bump_generation(&mut self) {
87        self.generation = fresh_generation(self.generation);
88        let budget = self.source.max_bytes();
89        self.source = ReplicationSource::new(budget);
90    }
91
92    /// The tail cursor: `(generation, next_offset)` — where a consumer
93    /// resuming after a rebuild starts.
94    pub fn tail(&self) -> (u64, u64) {
95        (self.generation, self.source.next_offset())
96    }
97
98    /// Serve up to `max` frames at cursor `(generation, offset)`.
99    ///
100    /// - Any OTHER generation → `Resync` (the cursor belongs to a
101    ///   different offset history — uniqueness of `(gen, offset)`
102    ///   forbids serving it; generations are identities, not ordered).
103    /// - Offset ahead of this generation's stream → `Future`.
104    /// - Evicted offset → `Resync` with the current tail.
105    pub fn read(
106        &self,
107        cursor_gen: u64,
108        offset: u64,
109        max: usize,
110    ) -> Result<Vec<FeedFrame<'_>>, FeedRead> {
111        if cursor_gen != self.generation {
112            let (generation, tail) = self.tail();
113            return Err(FeedRead::Resync { generation, tail });
114        }
115        match self.source.frames_from(offset) {
116            Ok(iter) => Ok(iter
117                .take(max)
118                .map(|f| FeedFrame { offset: f.offset, bytes: &f.bytes })
119                .collect()),
120            Err(FromOffset::TooOld) => {
121                let (generation, tail) = self.tail();
122                Err(FeedRead::Resync { generation, tail })
123            }
124            Err(FromOffset::Future) => Err(FeedRead::Future),
125        }
126    }
127}
128
129/// Draw a fresh generation: a random nonzero u64 distinct from `old`.
130/// A generation is a HISTORY IDENTITY. Counters are unsound here: two
131/// nodes bumping in lockstep (startup election vs failover promotion)
132/// collide on the same number for different histories, and a replica's
133/// stale cursor then passes the generation fence into offset aliasing
134/// — served as "caught up" at a foreign offset, it wedges forever
135/// (the availgate failover-convergence flake). `RandomState`
136/// carries the process's OS-seeded entropy; identity, not crypto.
137pub(crate) fn fresh_generation(old: u64) -> u64 {
138    use std::hash::{BuildHasher, Hasher};
139    loop {
140        let mut h = std::collections::hash_map::RandomState::new().build_hasher();
141        h.write_u64(old);
142        // Mask to 53 bits: generations ride RESP integers (REPL.TOKEN
143        // / REPL.WAIT / FEED.TAIL), and client bindings surface them
144        // as JS Numbers, whose integer precision ends at 2^53 — a
145        // wider value round-trips corrupted and self-resyncs forever.
146        // A 2^53 identity space still makes collisions negligible.
147        let g = h.finish() & ((1u64 << 53) - 1);
148        if g != 0 && g != old {
149            return g;
150        }
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use kevy_resp::Argv;
158
159    fn argv(parts: &[&[u8]]) -> Argv {
160        let mut a = Argv::default();
161        for p in parts {
162            a.push(p);
163        }
164        a
165    }
166
167    fn feed_with(n: usize) -> FeedSource {
168        let mut f = FeedSource::new(1, ReplicationSource::new(1 << 20));
169        for i in 0..n {
170            f.source_mut().push_mutation(&argv(&[b"SET", format!("k{i}").as_bytes(), b"v"]));
171        }
172        f
173    }
174
175    #[test]
176    fn read_serves_in_order_and_caps_at_max() {
177        let f = feed_with(5);
178        let frames = f.read(1, 0, 3).unwrap();
179        assert_eq!(frames.len(), 3);
180        assert_eq!(frames[0].offset, 0);
181        assert_eq!(frames[2].offset, 2);
182        // caught-up cursor = empty ok
183        assert!(f.read(1, 5, 10).unwrap().is_empty());
184    }
185
186    #[test]
187    fn stale_generation_resyncs_with_tail() {
188        let mut f = feed_with(3);
189        f.bump_generation();
190        let g2 = f.generation();
191        f.source_mut().push_mutation(&argv(&[b"SET", b"new", b"v"]));
192        match f.read(1, 2, 10) {
193            Err(FeedRead::Resync { generation, tail }) => {
194                assert_eq!(generation, g2);
195                assert_eq!(tail, 1);
196            }
197            other => panic!("expected Resync, got {:?}", other.map(|v| v.len())),
198        }
199        // old-generation frames are gone — new gen serves only its own
200        let frames = f.read(g2, 0, 10).unwrap();
201        assert_eq!(frames.len(), 1);
202    }
203
204    /// The availgate failover wedge, distilled: generations are
205    /// identities, not counters. A bump must never land on a
206    /// PREDICTABLE next value (old+1 is what a peer node's own bump
207    /// would produce for a DIFFERENT history), and any mismatched
208    /// cursor — including one "from the future" of a counter's view —
209    /// resyncs instead of erroring.
210    #[test]
211    fn generations_are_random_identities() {
212        let mut a = FeedSource::new(1, ReplicationSource::new(1 << 20));
213        let mut b = FeedSource::new(1, ReplicationSource::new(1 << 20));
214        a.bump_generation();
215        b.bump_generation();
216        assert_ne!(a.generation(), 0);
217        assert_ne!(a.generation(), 1, "old value must not repeat");
218        assert_ne!(a.generation(), 2, "a counter's next value is the collision");
219        assert_ne!(
220            a.generation(),
221            b.generation(),
222            "two nodes bumping from the same value must diverge"
223        );
224        // A cursor from a foreign history (any unknown gen) resyncs.
225        let g = a.generation();
226        match a.read(g.wrapping_add(1), 0, 10) {
227            Err(FeedRead::Resync { generation, .. }) => assert_eq!(generation, g),
228            other => panic!("expected Resync, got {:?}", other.map(|v| v.len())),
229        }
230    }
231
232    #[test]
233    fn future_cursors_rejected() {
234        let f = feed_with(2);
235        // Unknown generation (a counter would call gen 3 "the
236        // future") → Resync, not Future: identities have no order.
237        assert!(matches!(f.read(3, 0, 1), Err(FeedRead::Resync { .. })));
238        // Offset ahead within the CURRENT generation → Future.
239        assert!(matches!(f.read(1, 99, 1), Err(FeedRead::Future)));
240    }
241
242    #[test]
243    fn evicted_offset_resyncs() {
244        // Tiny budget: pushing enough evicts the front.
245        let mut f = FeedSource::new(1, ReplicationSource::new(64));
246        for i in 0..50 {
247            f.source_mut().push_mutation(&argv(&[b"SET", format!("k{i}").as_bytes(), b"v"]));
248        }
249        match f.read(1, 0, 10) {
250            Err(FeedRead::Resync { generation, tail }) => {
251                assert_eq!(generation, 1);
252                assert_eq!(tail, 50);
253            }
254            other => panic!("expected Resync, got {:?}", other.map(|v| v.len())),
255        }
256    }
257}