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;