Skip to main content

kevy_replicate/
source.rs

1//! Primary-side replication source — bounded backlog of recent
2//! mutations, indexed by monotonic offset.
3//!
4//! Behaviour at a glance:
5//! - [`ReplicationSource::push_mutation`] is called on every applied
6//!   write. It assigns the next monotonic offset, encodes the frame
7//!   with [`crate::wire::encode_frame`], and appends to the backlog.
8//! - The backlog is bounded by a byte budget (`max_bytes`, fed from
9//!   `[replication]` `replication_buffer_size` in config). When a new
10//!   frame would exceed the budget, the oldest frames are dropped to
11//!   make room.
12//! - Replicas that disconnect and reconnect within the backlog window
13//!   resume via [`ReplicationSource::frames_from`]. Replicas that fall
14//!   off the back of the buffer get `Err(FromOffset::TooOld)` and the
15//!   caller initiates a full snapshot ship.
16//!
17//! The source does **not** know about replicas — slot tracking lives
18//! in [`crate::slot::SlotTable`]. The source is a passive structure
19//! the streaming loop reads; mutation/serialisation lock policy is the
20//! wiring layer's concern.
21
22use crate::wire::encode_frame;
23#[cfg(test)]
24use kevy_resp::Argv;
25use kevy_resp::ArgvView;
26
27/// One encoded mutation frame parked in the backlog.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct Frame {
30    /// Monotonic offset the source assigned at push time.
31    pub offset: u64,
32    /// Wire-encoded frame bytes (envelope + offset + RESP argv).
33    pub bytes: Vec<u8>,
34}
35
36/// Reason [`ReplicationSource::frames_from`] cannot serve a replica
37/// from the backlog.
38#[derive(Debug, PartialEq, Eq)]
39pub enum FromOffset {
40    /// The replica is asking for an offset we already evicted; the
41    /// streaming loop must initiate a snapshot ship.
42    TooOld,
43    /// The replica's requested offset is greater than the next offset
44    /// we would assign — peer is ahead of us (data-dir wipe, epoch
45    /// confusion, or bug). The caller should drop the link.
46    Future,
47}
48
49/// Bounded backlog of recent replicated mutations.
50#[derive(Debug)]
51pub struct ReplicationSource {
52    next_offset: u64,
53    bytes_in_buf: usize,
54    max_bytes: usize,
55    buf: std::collections::VecDeque<Frame>,
56}
57
58impl ReplicationSource {
59    /// Create a new source with the given byte budget. `max_bytes` must
60    /// be > 0; the source guarantees at most one over-budget frame at
61    /// a time (the most recently pushed) so a single huge command does
62    /// not silently disappear before its replicas even see it.
63    ///
64    /// # Panics
65    ///
66    /// Panics if `max_bytes == 0` — a zero budget could never hold
67    /// even one frame, so it is a caller bug, not a runtime state.
68    pub fn new(max_bytes: usize) -> Self {
69        assert!(max_bytes > 0, "ReplicationSource max_bytes must be > 0");
70        Self { next_offset: 0, bytes_in_buf: 0, max_bytes, buf: std::collections::VecDeque::new() }
71    }
72
73    /// Resume offset assignment at `next` (boot continuity from the
74    /// feed sidecar). Only meaningful on an empty, freshly created
75    /// source — asserts the backlog has no frames.
76    ///
77    /// # Panics
78    ///
79    /// Panics if the backlog already holds frames: renumbering live
80    /// frames would corrupt every replica's ack bookkeeping.
81    pub fn set_next_offset(&mut self, next: u64) {
82        assert!(self.buf.is_empty(), "set_next_offset on non-empty backlog");
83        self.next_offset = next;
84    }
85
86    /// The byte budget this source was created with.
87    pub fn max_bytes(&self) -> usize {
88        self.max_bytes
89    }
90
91    /// Next offset this source would assign. Equal to one past the
92    /// last assigned offset; equals `0` for a fresh source.
93    pub fn next_offset(&self) -> u64 {
94        self.next_offset
95    }
96
97    /// Lowest offset still in the backlog, or `None` if empty.
98    pub fn oldest_offset(&self) -> Option<u64> {
99        self.buf.front().map(|f| f.offset)
100    }
101
102    /// Highest offset still in the backlog, or `None` if empty.
103    pub fn newest_offset(&self) -> Option<u64> {
104        self.buf.back().map(|f| f.offset)
105    }
106
107    /// Total bytes occupied by frames currently in the backlog.
108    pub fn buffered_bytes(&self) -> usize {
109        self.bytes_in_buf
110    }
111
112    /// Number of frames currently in the backlog.
113    pub fn len(&self) -> usize {
114        self.buf.len()
115    }
116
117    /// Whether the backlog has no frames.
118    pub fn is_empty(&self) -> bool {
119        self.buf.is_empty()
120    }
121
122    /// Append one applied mutation. Returns the offset assigned to it.
123    /// Generic over [`ArgvView`] so the dispatcher's borrowed argv can
124    /// flow straight in — no `Argv` materialisation on the write path.
125    ///
126    /// May evict older frames if the new frame would exceed the byte
127    /// budget; the new frame is always retained (even if it is larger
128    /// than `max_bytes` on its own — losing the most recent applied
129    /// write before any replica has had a chance to ack it would be
130    /// a worse failure than briefly running over budget).
131    // missing_panics_doc: the eviction loop's expect pops a front the loop
132    // guard just observed — unreachable, not a caller-facing panic condition.
133    #[allow(clippy::missing_panics_doc)]
134    pub fn push_mutation<A: ArgvView + ?Sized>(&mut self, argv: &A) -> u64 {
135        let offset = self.next_offset;
136        let bytes = encode_frame(offset, argv);
137        let frame_len = bytes.len();
138
139        // Evict from the front until either the new frame fits or
140        // the buffer is empty.
141        while self.bytes_in_buf + frame_len > self.max_bytes && !self.buf.is_empty() {
142            let dropped = self.buf.pop_front().expect("non-empty checked");
143            self.bytes_in_buf -= dropped.bytes.len();
144        }
145
146        self.bytes_in_buf += frame_len;
147        self.buf.push_back(Frame { offset, bytes });
148        self.next_offset = self
149            .next_offset
150            .checked_add(1)
151            .expect("replication offset wrap — i64::MAX guard tripped");
152        offset
153    }
154
155    /// Drop every buffered frame whose offset is `< watermark` —
156    /// i.e. every replica has consumed past it. Used by the per-
157    /// shard tick to enforce a retention floor tighter
158    /// than the raw byte budget; lets the backlog reclaim space
159    /// for live frames once all consumers have advanced.
160    ///
161    /// No-op when `watermark <= oldest_offset()` (nothing to drop)
162    /// or when the buffer is empty. Updates the internal byte
163    /// accounting to stay consistent with the live buffer length.
164    // missing_panics_doc: same front-of-loop expect rationale as
165    // `push_mutation` — unreachable by the `while let` guard.
166    #[allow(clippy::missing_panics_doc)]
167    pub fn drop_up_to(&mut self, watermark: u64) {
168        while let Some(front) = self.buf.front() {
169            if front.offset >= watermark {
170                break;
171            }
172            let dropped = self.buf.pop_front().expect("front-of-loop");
173            self.bytes_in_buf -= dropped.bytes.len();
174        }
175    }
176
177    /// Borrow the slice of frames with offset ≥ `from`. Suitable for
178    /// the streaming loop to write each frame's `bytes` to a replica
179    /// socket. Returns:
180    /// - `Ok(iter)` — zero or more frames in offset order (empty iter
181    ///   means the replica is caught up).
182    /// - `Err(FromOffset::TooOld)` — `from` is older than the oldest
183    ///   buffered frame; the streaming loop must snapshot-ship.
184    /// - `Err(FromOffset::Future)` — `from > next_offset()`; peer is
185    ///   ahead of us, drop the link.
186    pub fn frames_from(&self, from: u64) -> Result<FramesIter<'_>, FromOffset> {
187        if from > self.next_offset {
188            return Err(FromOffset::Future);
189        }
190        // from == next_offset → replica is exactly caught up; empty slice.
191        if from == self.next_offset {
192            return Ok(FramesIter { buf: &self.buf, cursor: self.buf.len() });
193        }
194        // from < next_offset: the requested frame either is still in
195        // the backlog or was evicted. Empty buf with from < next_offset
196        // means every frame ever pushed has been evicted — same TooOld
197        // outcome as `from < oldest`. (Without this branch the function
198        // returns an empty iterator and the streaming pump silently
199        // stalls — an embed-replica restart test caught this.)
200        match self.oldest_offset() {
201            Some(oldest) if from < oldest => return Err(FromOffset::TooOld),
202            None => return Err(FromOffset::TooOld),
203            _ => {}
204        }
205        // Offsets are monotonic, so the start index is a binary search. The
206        // comment here used to say exactly that — and then called
207        // `iter().position(...)`, an O(B) walk of the whole backlog, on a path
208        // that both FEED.READ and the replica stream take on every poll. A
209        // consumer resuming from an old cursor paid for the entire backlog
210        // before it saw its first frame.
211        //
212        // `VecDeque` is two contiguous runs, each individually sorted, so
213        // `partition_point` on each half gives the answer in O(log B).
214        let (a, b) = self.buf.as_slices();
215        let start = match a.partition_point(|f| f.offset < from) {
216            i if i < a.len() => i,
217            _ => a.len() + b.partition_point(|f| f.offset < from),
218        };
219        Ok(FramesIter { buf: &self.buf, cursor: start })
220    }
221}
222
223/// Iterator over backlog frames returned by [`ReplicationSource::frames_from`].
224#[derive(Debug)]
225pub struct FramesIter<'a> {
226    buf: &'a std::collections::VecDeque<Frame>,
227    cursor: usize,
228}
229
230impl<'a> Iterator for FramesIter<'a> {
231    type Item = &'a Frame;
232    fn next(&mut self) -> Option<&'a Frame> {
233        let item = self.buf.get(self.cursor)?;
234        self.cursor += 1;
235        Some(item)
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use crate::wire::decode_frame;
243
244    fn argv(args: &[&[u8]]) -> Argv {
245        let mut a = Argv::default();
246        for arg in args {
247            a.push(arg);
248        }
249        a
250    }
251
252    #[test]
253    fn fresh_source_is_empty() {
254        let s = ReplicationSource::new(1024);
255        assert!(s.is_empty());
256        assert_eq!(s.len(), 0);
257        assert_eq!(s.next_offset(), 0);
258        assert_eq!(s.oldest_offset(), None);
259        assert_eq!(s.newest_offset(), None);
260        assert_eq!(s.buffered_bytes(), 0);
261    }
262
263    #[test]
264    fn push_assigns_monotonic_offsets() {
265        let mut s = ReplicationSource::new(64 * 1024);
266        let o0 = s.push_mutation(&argv(&[b"SET", b"a", b"1"]));
267        let o1 = s.push_mutation(&argv(&[b"SET", b"b", b"2"]));
268        let o2 = s.push_mutation(&argv(&[b"DEL", b"a"]));
269        assert_eq!((o0, o1, o2), (0, 1, 2));
270        assert_eq!(s.oldest_offset(), Some(0));
271        assert_eq!(s.newest_offset(), Some(2));
272        assert_eq!(s.next_offset(), 3);
273        assert_eq!(s.len(), 3);
274    }
275
276    #[test]
277    fn pushed_frames_decode_back_to_the_pushed_argv() {
278        let mut s = ReplicationSource::new(1024);
279        let a = argv(&[b"HSET", b"h", b"f", b"v"]);
280        let off = s.push_mutation(&a);
281        let frame = s.buf.front().expect("one frame");
282        assert_eq!(frame.offset, off);
283        let (decoded_off, decoded_argv, used) = decode_frame(&frame.bytes).expect("decode");
284        assert_eq!(decoded_off, off);
285        assert_eq!(decoded_argv, a);
286        assert_eq!(used, frame.bytes.len());
287    }
288
289    #[test]
290    fn eviction_drops_oldest_when_budget_exceeded() {
291        // Each frame encodes to ~37 bytes (envelope + offset + 3-arg SET).
292        // Budget of 80 bytes holds 2 frames; pushing a 3rd evicts oldest.
293        let mut s = ReplicationSource::new(80);
294        let _ = s.push_mutation(&argv(&[b"SET", b"a", b"1"]));
295        let _ = s.push_mutation(&argv(&[b"SET", b"b", b"2"]));
296        assert_eq!(s.oldest_offset(), Some(0));
297        let _ = s.push_mutation(&argv(&[b"SET", b"c", b"3"]));
298        assert_eq!(s.oldest_offset(), Some(1));
299        assert_eq!(s.newest_offset(), Some(2));
300        assert!(s.buffered_bytes() <= 80);
301        // next_offset keeps climbing even when older frames are evicted.
302        assert_eq!(s.next_offset(), 3);
303    }
304
305    #[test]
306    fn oversized_single_frame_is_retained_against_budget() {
307        // Budget of 8 bytes — smaller than any real frame. The most
308        // recent push always survives so a freshly-applied write is
309        // never lost before any replica can see it.
310        let mut s = ReplicationSource::new(8);
311        let off = s.push_mutation(&argv(&[b"SET", b"k", b"v"]));
312        assert_eq!(s.len(), 1);
313        assert_eq!(s.oldest_offset(), Some(off));
314        assert!(s.buffered_bytes() > 8); // ran over budget; expected.
315        // Pushing again still keeps only the newest (older is evicted).
316        let off2 = s.push_mutation(&argv(&[b"DEL", b"k"]));
317        assert_eq!(s.len(), 1);
318        assert_eq!(s.oldest_offset(), Some(off2));
319    }
320
321    #[test]
322    fn frames_from_at_exact_offset_returns_that_frame_first() {
323        let mut s = ReplicationSource::new(1024);
324        for i in 0..5 {
325            let _ = s.push_mutation(&argv(&[b"SET", b"k", format!("{i}").as_bytes()]));
326        }
327        let mut it = s.frames_from(2).unwrap();
328        let f = it.next().expect("frame");
329        assert_eq!(f.offset, 2);
330        let remaining: Vec<u64> = it.map(|f| f.offset).collect();
331        assert_eq!(remaining, vec![3, 4]);
332    }
333
334    #[test]
335    fn frames_from_at_next_offset_is_empty_caught_up() {
336        let mut s = ReplicationSource::new(1024);
337        let _ = s.push_mutation(&argv(&[b"PING"]));
338        let _ = s.push_mutation(&argv(&[b"PING"]));
339        let it = s.frames_from(s.next_offset()).unwrap();
340        assert_eq!(it.count(), 0);
341    }
342
343    #[test]
344    fn frames_from_too_old_after_eviction() {
345        // Tight budget; push enough to evict offset 0.
346        let mut s = ReplicationSource::new(80);
347        for _ in 0..5 {
348            let _ = s.push_mutation(&argv(&[b"SET", b"k", b"v"]));
349        }
350        // Offset 0 was evicted.
351        assert!(s.oldest_offset().unwrap() > 0);
352        assert!(matches!(s.frames_from(0), Err(FromOffset::TooOld)));
353    }
354
355    #[test]
356    fn frames_from_future_offset_rejected() {
357        let mut s = ReplicationSource::new(1024);
358        let _ = s.push_mutation(&argv(&[b"PING"]));
359        // next_offset is 1; asking for 2 is a future-offset peer.
360        assert!(matches!(s.frames_from(2), Err(FromOffset::Future)));
361    }
362
363    #[test]
364    fn frames_from_empty_source_at_zero_is_caught_up_not_too_old() {
365        // A fresh source has nothing buffered but is at offset 0; a
366        // replica asking from 0 is up-to-date (the source has nothing
367        // to send yet), not too-old.
368        let s = ReplicationSource::new(1024);
369        assert_eq!(s.frames_from(0).unwrap().count(), 0);
370        // Asking for offset 1 (one past empty next_offset 0) = Future.
371        assert!(matches!(s.frames_from(1), Err(FromOffset::Future)));
372    }
373
374    #[test]
375    fn push_mutation_accepts_argv_borrowed_from_dispatcher_hot_path() {
376        // The reactor's local fast path holds the parsed argv as an
377        // `ArgvBorrowed` over the connection read buffer (zero-copy);
378        // `push_mutation` must accept that view directly, not force
379        // a materialised `Argv`. Parse one with the public parser and
380        // push it; the decoded round-trip must match a hand-built Argv
381        // of the same command.
382        let resp = b"*3\r\n$3\r\nSET\r\n$3\r\nfoo\r\n$3\r\nbar\r\n";
383        let (borrowed, consumed) =
384            kevy_resp::parse_command_borrowed(resp).expect("parse ok").expect("complete frame");
385        assert_eq!(consumed, resp.len());
386
387        let mut s = ReplicationSource::new(1024);
388        let off = s.push_mutation(&borrowed);
389        assert_eq!(off, 0);
390
391        let frame = s.buf.front().expect("one frame");
392        let (decoded_off, decoded_argv, _) =
393            crate::wire::decode_frame(&frame.bytes).expect("decode");
394        assert_eq!(decoded_off, 0);
395        assert_eq!(decoded_argv, argv(&[b"SET", b"foo", b"bar"]));
396    }
397
398    #[test]
399    fn buffered_bytes_tracks_actual_frame_total() {
400        let mut s = ReplicationSource::new(1024);
401        let _ = s.push_mutation(&argv(&[b"SET", b"k", b"v"]));
402        let _ = s.push_mutation(&argv(&[b"DEL", b"k"]));
403        let actual: usize = s.buf.iter().map(|f| f.bytes.len()).sum();
404        assert_eq!(s.buffered_bytes(), actual);
405    }
406
407    #[test]
408    fn drop_up_to_evicts_below_watermark() {
409        // drop_up_to(w) evicts every frame with offset < w.
410        let mut s = ReplicationSource::new(64 * 1024);
411        for i in 0..5 {
412            let v = format!("v{i}");
413            let _ = s.push_mutation(&argv(&[b"SET", b"k", v.as_bytes()]));
414        }
415        assert_eq!(s.len(), 5);
416        let bytes_before = s.buffered_bytes();
417        // Watermark = 3 → drop offsets 0, 1, 2; keep 3, 4.
418        s.drop_up_to(3);
419        assert_eq!(s.len(), 2);
420        assert_eq!(s.oldest_offset(), Some(3));
421        assert_eq!(s.newest_offset(), Some(4));
422        // bytes accounting must shrink.
423        assert!(s.buffered_bytes() < bytes_before);
424        // Frames-from at the watermark works without TooOld.
425        let kept: Vec<_> = s.frames_from(3).unwrap().collect();
426        assert_eq!(kept.len(), 2);
427    }
428
429    #[test]
430    fn drop_up_to_below_oldest_is_noop() {
431        let mut s = ReplicationSource::new(64 * 1024);
432        let _ = s.push_mutation(&argv(&[b"SET", b"k", b"v"]));
433        let _ = s.push_mutation(&argv(&[b"SET", b"k", b"v"]));
434        assert_eq!(s.oldest_offset(), Some(0));
435        s.drop_up_to(0); // already-at-or-past-oldest
436        assert_eq!(s.len(), 2);
437    }
438
439    #[test]
440    fn drop_up_to_at_or_past_newest_drops_everything() {
441        let mut s = ReplicationSource::new(64 * 1024);
442        for _ in 0..3 {
443            let _ = s.push_mutation(&argv(&[b"SET", b"k", b"v"]));
444        }
445        s.drop_up_to(99);
446        assert!(s.is_empty());
447        assert_eq!(s.buffered_bytes(), 0);
448    }
449}