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