Skip to main content

kevy_replicate/
wire_snapshot.rs

1//! Snapshot ship wire format — split out of [`crate::wire`]
2//! to keep that file under the 500-LOC project ceiling.
3//!
4//! See `docs/snapshot.md` for the full spec. The primary sends:
5//!
6//!   `+SNAPSHOT\r\n`
7//!   `$L1\r\n<L1 bytes>\r\n`  (chunk 1)
8//!   `$L2\r\n<L2 bytes>\r\n`  (chunk 2)
9//!   ...
10//!   `+SNAPSHOT_END <ack_offset>\r\n`
11//!
12//! Markers are RESP simple strings, chunks are RESP bulk strings —
13//! any RESP-aware tool can peek a captured stream.
14
15use crate::feed::FeedPosition;
16use crate::wire::{WireError, find_crlf, parse_decimal, push_u64};
17
18/// Per-chunk cap: a chunk's `$L\r\n` length must not exceed this.
19/// Replica drops the link if a chunk header reports a larger size.
20/// 64 KiB matches a typical TCP segment + keeps the per-chunk
21/// allocation modest. The primary may pick any chunk size from
22/// `1` up to this.
23///
24/// ```
25/// use kevy_replicate::wire::{SNAPSHOT_CHUNK_MAX, WireError, decode_snapshot_chunk};
26///
27/// let dump = vec![0u8; 3 * SNAPSHOT_CHUNK_MAX / 2];
28/// assert_eq!(dump.chunks(SNAPSHOT_CHUNK_MAX).count(), 2); // ship it in two chunks
29/// // a header announcing a larger chunk is refused before any allocation
30/// let oversize = format!("${}\r\n", SNAPSHOT_CHUNK_MAX + 1);
31/// assert_eq!(decode_snapshot_chunk(oversize.as_bytes()), Err(WireError::BadEnvelope));
32/// ```
33pub const SNAPSHOT_CHUNK_MAX: usize = 64 * 1024;
34
35/// Maximum length of a snapshot control line (`+SNAPSHOT_END N\r\n`).
36/// 256 B is generous — the longest legal line is `+SNAPSHOT_END ` +
37/// 20 digits + `\r\n` = 38 B.
38///
39/// ```
40/// use kevy_replicate::wire::{SNAPSHOT_LINE_MAX, WireError, decode_snapshot_marker};
41///
42/// assert!(kevy_replicate::wire::encode_snapshot_end(u64::MAX).len() <= SNAPSHOT_LINE_MAX);
43/// // a `+` line that runs on without a terminator is refused, not buffered forever
44/// let runaway = [b"+".as_slice(), &[b'x'; SNAPSHOT_LINE_MAX + 1]].concat();
45/// assert_eq!(decode_snapshot_marker(&runaway), Err(WireError::BadEnvelope));
46/// ```
47pub const SNAPSHOT_LINE_MAX: usize = 256;
48
49/// Decoded snapshot marker, returned by [`decode_snapshot_marker`].
50///
51/// ```
52/// use kevy_replicate::wire::{SnapshotMarker, decode_snapshot_marker, encode_snapshot_end};
53///
54/// let (marker, _) = decode_snapshot_marker(&encode_snapshot_end(12))?.expect("a marker line");
55/// let next_live_offset = match marker {
56///     SnapshotMarker::End(ack) => ack,
57///     other => panic!("unexpected {other:?}"),
58/// };
59/// assert_eq!(next_live_offset, 12);
60/// # Ok::<(), kevy_replicate::wire::WireError>(())
61/// ```
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
63#[non_exhaustive]
64pub enum SnapshotMarker {
65    /// `+SNAPSHOT\r\n` — primary is about to stream snapshot chunks.
66    ///
67    /// ```
68    /// use kevy_replicate::wire::{SnapshotMarker, decode_snapshot_marker};
69    ///
70    /// assert_eq!(decode_snapshot_marker(b"+SNAPSHOT\r\n")?, Some((SnapshotMarker::Begin, 11)));
71    /// # Ok::<(), kevy_replicate::wire::WireError>(())
72    /// ```
73    Begin,
74    /// `+SNAPSHOT_END <ack_offset>\r\n` — end of snapshot; the next
75    /// live frame's offset will equal `ack_offset`.
76    ///
77    /// ```
78    /// use kevy_replicate::wire::{SnapshotMarker, decode_snapshot_marker};
79    ///
80    /// let line = b"+SNAPSHOT_END 42\r\n";
81    /// assert_eq!(decode_snapshot_marker(line)?, Some((SnapshotMarker::End(42), line.len())));
82    /// # Ok::<(), kevy_replicate::wire::WireError>(())
83    /// ```
84    End(u64),
85    /// `+PING <generation> <next_offset>\r\n` — in-stream
86    /// heartbeat: the primary's current feed generation + `next_offset`,
87    /// sent every ~1s so a replica can compute its own lag (applied vs
88    /// primary) and judge link liveness without a request/response
89    /// round trip. Out-of-band: occupies no offset space.
90    ///
91    /// The generation lets REPL.TOKEN / REPL.WAIT compare
92    /// token generations against the replica's last-seen upstream
93    /// generation. A legacy one-number `+PING <next_offset>\r\n` line
94    /// still decodes — `generation` reads as `0`, the "unknown" value
95    /// no real feed ever serves (feed generations start at 1).
96    ///
97    /// ```
98    /// use kevy_replicate::feed::FeedPosition;
99    /// use kevy_replicate::wire::{SnapshotMarker, decode_snapshot_marker};
100    ///
101    /// let (ping, _) = decode_snapshot_marker(b"+PING 3 120\r\n")?.expect("a marker line");
102    /// assert_eq!(ping, SnapshotMarker::Ping(FeedPosition::new(3, 120)));
103    /// let (legacy, _) = decode_snapshot_marker(b"+PING 120\r\n")?.expect("a marker line");
104    /// assert_eq!(legacy, SnapshotMarker::Ping(FeedPosition::new(0, 120))); // generation unknown
105    /// # Ok::<(), kevy_replicate::wire::WireError>(())
106    /// ```
107    Ping(FeedPosition),
108}
109
110/// Encode the snapshot-begin marker. Allocates the exact 11 bytes.
111///
112/// ```
113/// assert_eq!(kevy_replicate::wire::encode_snapshot_begin(), b"+SNAPSHOT\r\n");
114/// ```
115pub fn encode_snapshot_begin() -> Vec<u8> {
116    b"+SNAPSHOT\r\n".to_vec()
117}
118
119/// Encode one snapshot chunk as a RESP bulk string. Caller is
120/// responsible for chunking — typical strategy is fixed
121/// [`SNAPSHOT_CHUNK_MAX`]-sized reads from a snapshot file or
122/// in-memory serializer.
123///
124/// **Debug-asserts** `bytes.len() <= SNAPSHOT_CHUNK_MAX` so an
125/// accidental oversize chunk trips during development; release
126/// builds emit a frame the peer will reject with [`WireError::BadEnvelope`]
127/// (replica's decoder caps incoming chunk lengths).
128///
129/// ```
130/// use kevy_replicate::wire::{SNAPSHOT_CHUNK_MAX, decode_snapshot_chunk, encode_snapshot_chunk};
131///
132/// let dump = b"serialized keyspace";
133/// let mut stream = Vec::new();
134/// for part in dump.chunks(SNAPSHOT_CHUNK_MAX) {
135///     stream.extend(encode_snapshot_chunk(part));
136/// }
137/// assert_eq!(stream, b"$19\r\nserialized keyspace\r\n");
138/// assert_eq!(decode_snapshot_chunk(&stream)?.0, dump);
139/// # Ok::<(), kevy_replicate::wire::WireError>(())
140/// ```
141pub fn encode_snapshot_chunk(bytes: &[u8]) -> Vec<u8> {
142    debug_assert!(
143        bytes.len() <= SNAPSHOT_CHUNK_MAX,
144        "snapshot chunk {} > cap {}",
145        bytes.len(),
146        SNAPSHOT_CHUNK_MAX,
147    );
148    let mut out = Vec::with_capacity(16 + bytes.len());
149    out.push(b'$');
150    push_u64(&mut out, bytes.len() as u64);
151    out.extend_from_slice(b"\r\n");
152    out.extend_from_slice(bytes);
153    out.extend_from_slice(b"\r\n");
154    out
155}
156
157/// Encode the in-stream heartbeat carrying the primary's tail:
158/// `+PING <generation> <next_offset>\r\n`.
159///
160/// ```
161/// use kevy_replicate::feed::FeedPosition;
162///
163/// assert_eq!(kevy_replicate::wire::encode_ping(FeedPosition::new(3, 9)), b"+PING 3 9\r\n");
164/// ```
165pub fn encode_ping(tail: FeedPosition) -> Vec<u8> {
166    let mut out = Vec::with_capacity(48);
167    out.extend_from_slice(b"+PING ");
168    push_u64(&mut out, tail.generation);
169    out.push(b' ');
170    push_u64(&mut out, tail.offset);
171    out.extend_from_slice(b"\r\n");
172    out
173}
174
175/// Encode the replica→primary acknowledgment line:
176/// `REPLCONF ACK <offset>\r\n` — inline RESP, written back on the
177/// SAME replication connection (the pump drains it non-blocking).
178///
179/// ```
180/// use kevy_replicate::wire::{decode_replconf_ack, encode_replconf_ack};
181///
182/// let line = encode_replconf_ack(128);
183/// assert_eq!(line, b"REPLCONF ACK 128\r\n");
184/// assert_eq!(decode_replconf_ack(&line)?, Some((128, line.len())));
185/// # Ok::<(), kevy_replicate::wire::WireError>(())
186/// ```
187pub fn encode_replconf_ack(offset: u64) -> Vec<u8> {
188    let mut out = Vec::with_capacity(32);
189    out.extend_from_slice(b"REPLCONF ACK ");
190    push_u64(&mut out, offset);
191    out.extend_from_slice(b"\r\n");
192    out
193}
194
195/// Parse one `REPLCONF ACK <offset>\r\n` line at the front of `buf`.
196/// `Ok(Some((offset, used)))` on a full line; `Ok(None)` if the buffer
197/// doesn't start with `R` (not an ACK); `Err(Truncated)` if incomplete.
198///
199/// ```
200/// use kevy_replicate::wire::{WireError, decode_replconf_ack};
201///
202/// assert_eq!(decode_replconf_ack(b"REPLCONF ACK 9\r\n")?, Some((9, 16)));
203/// assert_eq!(decode_replconf_ack(b"PING\r\n")?, None); // not an ack
204/// assert_eq!(decode_replconf_ack(b"REPLCONF AC"), Err(WireError::Truncated));
205/// # Ok::<(), WireError>(())
206/// ```
207pub fn decode_replconf_ack(buf: &[u8]) -> Result<Option<(u64, usize)>, WireError> {
208    if buf.is_empty() {
209        return Err(WireError::Truncated);
210    }
211    if buf[0] != b'R' {
212        return Ok(None);
213    }
214    let Some(eol) = find_crlf(buf, 0) else {
215        return if buf.len() > SNAPSHOT_LINE_MAX {
216            Err(WireError::BadEnvelope)
217        } else {
218            Err(WireError::Truncated)
219        };
220    };
221    let line = &buf[..eol];
222    let Some(rest) = line.strip_prefix(b"REPLCONF ACK ") else {
223        return Err(WireError::BadEnvelope);
224    };
225    let offset = parse_decimal(rest).ok_or(WireError::BadEnvelope)?;
226    Ok(Some((offset, eol + 2)))
227}
228
229/// Encode the snapshot-end marker carrying the ack offset (the
230/// next live frame's offset will equal this value).
231///
232/// ```
233/// assert_eq!(kevy_replicate::wire::encode_snapshot_end(57), b"+SNAPSHOT_END 57\r\n");
234/// ```
235pub fn encode_snapshot_end(ack_offset: u64) -> Vec<u8> {
236    let mut out = Vec::with_capacity(32);
237    out.extend_from_slice(b"+SNAPSHOT_END ");
238    push_u64(&mut out, ack_offset);
239    out.extend_from_slice(b"\r\n");
240    out
241}
242
243/// Peek the next line at the front of `buf` to detect a snapshot
244/// marker. Returns:
245/// - `Ok(Some((marker, used)))` — a full marker line was found;
246///   `used` bytes may be dropped.
247/// - `Ok(None)` — the buffer doesn't start with a `+` byte; caller
248///   should treat the bytes as a regular `*2\r\n` frame and feed
249///   the frame decoder instead.
250/// - `Err(WireError::Truncated)` — buffer starts with `+` but the
251///   `\r\n` terminator is not yet in the buffer.
252/// - `Err(WireError::BadEnvelope)` — buffer starts with `+` but the
253///   line is neither `+SNAPSHOT` nor `+SNAPSHOT_END <N>`, or the
254///   line exceeds [`SNAPSHOT_LINE_MAX`].
255///
256/// ```
257/// use kevy_replicate::wire::{SnapshotMarker, WireError, decode_snapshot_marker};
258///
259/// assert_eq!(decode_snapshot_marker(b"+SNAPSHOT\r\n$3")?, Some((SnapshotMarker::Begin, 11)));
260/// assert_eq!(decode_snapshot_marker(b"*2\r\n")?, None); // a live frame: use decode_frame
261/// assert_eq!(decode_snapshot_marker(b"+SNAPSH"), Err(WireError::Truncated));
262/// assert_eq!(decode_snapshot_marker(b"+HELLO\r\n"), Err(WireError::BadEnvelope));
263/// # Ok::<(), WireError>(())
264/// ```
265pub fn decode_snapshot_marker(buf: &[u8]) -> Result<Option<(SnapshotMarker, usize)>, WireError> {
266    if buf.is_empty() {
267        return Err(WireError::Truncated);
268    }
269    if buf[0] != b'+' {
270        return Ok(None);
271    }
272    let Some(eol) = find_crlf(buf, 1) else {
273        return if buf.len() > SNAPSHOT_LINE_MAX {
274            Err(WireError::BadEnvelope)
275        } else {
276            Err(WireError::Truncated)
277        };
278    };
279    if eol > SNAPSHOT_LINE_MAX {
280        return Err(WireError::BadEnvelope);
281    }
282    let line = &buf[1..eol];
283    if line == b"SNAPSHOT" {
284        return Ok(Some((SnapshotMarker::Begin, eol + 2)));
285    }
286    if let Some(rest) = line.strip_prefix(b"SNAPSHOT_END ") {
287        let offset = parse_decimal(rest).ok_or(WireError::BadEnvelope)?;
288        return Ok(Some((SnapshotMarker::End(offset), eol + 2)));
289    }
290    if let Some(rest) = line.strip_prefix(b"PING ") {
291        // Two-number form: `<generation> <next_offset>`.
292        // One-number legacy form: `<next_offset>` — decodes
293        // with generation 0 ("unknown"; real generations start at 1).
294        let marker = match rest.iter().position(|&b| b == b' ') {
295            Some(sp) => {
296                let generation = parse_decimal(&rest[..sp]).ok_or(WireError::BadEnvelope)?;
297                let next_offset = parse_decimal(&rest[sp + 1..]).ok_or(WireError::BadEnvelope)?;
298                SnapshotMarker::Ping(FeedPosition::new(generation, next_offset))
299            }
300            None => SnapshotMarker::Ping(FeedPosition::new(
301                0,
302                parse_decimal(rest).ok_or(WireError::BadEnvelope)?,
303            )),
304        };
305        return Ok(Some((marker, eol + 2)));
306    }
307    Err(WireError::BadEnvelope)
308}
309
310/// Decode the next snapshot chunk (`$L\r\n<L bytes>\r\n`) at the
311/// front of `buf`. Returns:
312/// - `Ok((chunk_bytes, used))` — `chunk_bytes` borrows from `buf`;
313///   `used` bytes were consumed.
314/// - `Err(WireError::Truncated)` — not enough bytes for a complete
315///   chunk yet.
316/// - `Err(WireError::BadEnvelope)` — header wasn't `$L\r\n`, `L`
317///   exceeded [`SNAPSHOT_CHUNK_MAX`], `L` parsed as non-numeric, or
318///   the trailing CRLF was missing.
319///
320/// ```
321/// use kevy_replicate::wire::{WireError, decode_snapshot_chunk};
322///
323/// let (chunk, used) = decode_snapshot_chunk(b"$4\r\nabcd\r\n+SNAPSHOT_END 9\r\n")?;
324/// assert_eq!((chunk, used), (&b"abcd"[..], 10)); // the end marker follows
325/// assert_eq!(decode_snapshot_chunk(b"$4\r\nab"), Err(WireError::Truncated));
326/// # Ok::<(), WireError>(())
327/// ```
328pub fn decode_snapshot_chunk(buf: &[u8]) -> Result<(&[u8], usize), WireError> {
329    if buf.is_empty() {
330        return Err(WireError::Truncated);
331    }
332    if buf[0] != b'$' {
333        return Err(WireError::BadEnvelope);
334    }
335    let len_eol = find_crlf(buf, 1).ok_or(WireError::Truncated)?;
336    let len = parse_decimal(&buf[1..len_eol]).ok_or(WireError::BadEnvelope)?;
337    let len = usize::try_from(len).map_err(|_| WireError::BadEnvelope)?;
338    if len > SNAPSHOT_CHUNK_MAX {
339        return Err(WireError::BadEnvelope);
340    }
341    let data_start = len_eol + 2;
342    let data_end = data_start + len;
343    if buf.len() < data_end + 2 {
344        return Err(WireError::Truncated);
345    }
346    if &buf[data_end..data_end + 2] != b"\r\n" {
347        return Err(WireError::BadEnvelope);
348    }
349    Ok((&buf[data_start..data_end], data_end + 2))
350}