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