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