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}