Skip to main content

kevy_replicate/
wire_snapshot.rs

1//! Snapshot ship wire format (T1.22) — 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::wire::{WireError, find_crlf, parse_decimal, push_u64};
16
17/// Per-chunk cap: a chunk's `$L\r\n` length must not exceed this.
18/// Replica drops the link if a chunk header reports a larger size.
19/// 64 KiB matches a typical TCP segment + keeps the per-chunk
20/// allocation modest. The primary may pick any chunk size from
21/// `1` up to this.
22pub const SNAPSHOT_CHUNK_MAX: usize = 64 * 1024;
23
24/// Maximum length of a snapshot control line (`+SNAPSHOT_END N\r\n`).
25/// 256 B is generous — the longest legal line is `+SNAPSHOT_END ` +
26/// 20 digits + `\r\n` = 38 B.
27pub const SNAPSHOT_LINE_MAX: usize = 256;
28
29/// Decoded snapshot marker, returned by [`decode_snapshot_marker`].
30#[derive(Debug, PartialEq, Eq)]
31pub enum SnapshotMarker {
32    /// `+SNAPSHOT\r\n` — primary is about to stream snapshot chunks.
33    Begin,
34    /// `+SNAPSHOT_END <ack_offset>\r\n` — end of snapshot; the next
35    /// live frame's offset will equal `ack_offset`.
36    End(u64),
37    /// `+PING <generation> <next_offset>\r\n` — v3.14 in-stream
38    /// heartbeat: the primary's current feed generation + `next_offset`,
39    /// sent every ~1s so a replica can compute its own lag (applied vs
40    /// primary) and judge link liveness without a request/response
41    /// round trip. Out-of-band: occupies no offset space.
42    ///
43    /// v3.16 D2 added the generation (REPL.TOKEN / REPL.WAIT compare
44    /// token generations against the replica's last-seen upstream
45    /// generation). A legacy one-number `+PING <next_offset>\r\n` line
46    /// still decodes — `generation` reads as `0`, the "unknown" value
47    /// no real feed ever serves (feed generations start at 1).
48    Ping {
49        /// Primary's feed generation at send time.
50        generation: u64,
51        /// Primary's `next_offset` when the heartbeat was emitted.
52        next_offset: u64,
53    },
54}
55
56/// Encode the snapshot-begin marker. Allocates the exact 11 bytes.
57pub fn encode_snapshot_begin() -> Vec<u8> {
58    b"+SNAPSHOT\r\n".to_vec()
59}
60
61/// Encode one snapshot chunk as a RESP bulk string. Caller is
62/// responsible for chunking — typical strategy is fixed
63/// [`SNAPSHOT_CHUNK_MAX`]-sized reads from a snapshot file or
64/// in-memory serializer.
65///
66/// **Debug-asserts** `bytes.len() <= SNAPSHOT_CHUNK_MAX` so an
67/// accidental oversize chunk trips during development; release
68/// builds emit a frame the peer will reject with [`WireError::BadEnvelope`]
69/// (replica's decoder caps incoming chunk lengths).
70pub fn encode_snapshot_chunk(bytes: &[u8]) -> Vec<u8> {
71    debug_assert!(
72        bytes.len() <= SNAPSHOT_CHUNK_MAX,
73        "snapshot chunk {} > cap {}",
74        bytes.len(),
75        SNAPSHOT_CHUNK_MAX,
76    );
77    let mut out = Vec::with_capacity(16 + bytes.len());
78    out.push(b'$');
79    push_u64(&mut out, bytes.len() as u64);
80    out.extend_from_slice(b"\r\n");
81    out.extend_from_slice(bytes);
82    out.extend_from_slice(b"\r\n");
83    out
84}
85
86/// Encode the in-stream heartbeat (v3.14, gen-carrying since v3.16):
87/// `+PING <generation> <next_offset>\r\n`.
88pub fn encode_ping(generation: u64, next_offset: u64) -> Vec<u8> {
89    let mut out = Vec::with_capacity(48);
90    out.extend_from_slice(b"+PING ");
91    push_u64(&mut out, generation);
92    out.push(b' ');
93    push_u64(&mut out, next_offset);
94    out.extend_from_slice(b"\r\n");
95    out
96}
97
98/// Encode the replica→primary acknowledgment line (v3.14):
99/// `REPLCONF ACK <offset>\r\n` — inline RESP, written back on the
100/// SAME replication connection (the pump drains it non-blocking).
101pub fn encode_replconf_ack(offset: u64) -> Vec<u8> {
102    let mut out = Vec::with_capacity(32);
103    out.extend_from_slice(b"REPLCONF ACK ");
104    push_u64(&mut out, offset);
105    out.extend_from_slice(b"\r\n");
106    out
107}
108
109/// Parse one `REPLCONF ACK <offset>\r\n` line at the front of `buf`.
110/// `Ok(Some((offset, used)))` on a full line; `Ok(None)` if the buffer
111/// doesn't start with `R` (not an ACK); `Err(Truncated)` if incomplete.
112pub fn decode_replconf_ack(buf: &[u8]) -> Result<Option<(u64, usize)>, WireError> {
113    if buf.is_empty() {
114        return Err(WireError::Truncated);
115    }
116    if buf[0] != b'R' {
117        return Ok(None);
118    }
119    let Some(eol) = find_crlf(buf, 0) else {
120        return if buf.len() > SNAPSHOT_LINE_MAX {
121            Err(WireError::BadEnvelope)
122        } else {
123            Err(WireError::Truncated)
124        };
125    };
126    let line = &buf[..eol];
127    let Some(rest) = line.strip_prefix(b"REPLCONF ACK ") else {
128        return Err(WireError::BadEnvelope);
129    };
130    let offset = parse_decimal(rest).ok_or(WireError::BadEnvelope)?;
131    Ok(Some((offset, eol + 2)))
132}
133
134/// Encode the snapshot-end marker carrying the ack offset (the
135/// next live frame's offset will equal this value).
136pub fn encode_snapshot_end(ack_offset: u64) -> Vec<u8> {
137    let mut out = Vec::with_capacity(32);
138    out.extend_from_slice(b"+SNAPSHOT_END ");
139    push_u64(&mut out, ack_offset);
140    out.extend_from_slice(b"\r\n");
141    out
142}
143
144/// Peek the next line at the front of `buf` to detect a snapshot
145/// marker. Returns:
146/// - `Ok(Some((marker, used)))` — a full marker line was found;
147///   `used` bytes may be dropped.
148/// - `Ok(None)` — the buffer doesn't start with a `+` byte; caller
149///   should treat the bytes as a regular `*2\r\n` frame and feed
150///   the frame decoder instead.
151/// - `Err(WireError::Truncated)` — buffer starts with `+` but the
152///   `\r\n` terminator is not yet in the buffer.
153/// - `Err(WireError::BadEnvelope)` — buffer starts with `+` but the
154///   line is neither `+SNAPSHOT` nor `+SNAPSHOT_END <N>`, or the
155///   line exceeds [`SNAPSHOT_LINE_MAX`].
156pub fn decode_snapshot_marker(buf: &[u8]) -> Result<Option<(SnapshotMarker, usize)>, WireError> {
157    if buf.is_empty() {
158        return Err(WireError::Truncated);
159    }
160    if buf[0] != b'+' {
161        return Ok(None);
162    }
163    let Some(eol) = find_crlf(buf, 1) else {
164        return if buf.len() > SNAPSHOT_LINE_MAX {
165            Err(WireError::BadEnvelope)
166        } else {
167            Err(WireError::Truncated)
168        };
169    };
170    if eol > SNAPSHOT_LINE_MAX {
171        return Err(WireError::BadEnvelope);
172    }
173    let line = &buf[1..eol];
174    if line == b"SNAPSHOT" {
175        return Ok(Some((SnapshotMarker::Begin, eol + 2)));
176    }
177    if let Some(rest) = line.strip_prefix(b"SNAPSHOT_END ") {
178        let offset = parse_decimal(rest).ok_or(WireError::BadEnvelope)?;
179        return Ok(Some((SnapshotMarker::End(offset), eol + 2)));
180    }
181    if let Some(rest) = line.strip_prefix(b"PING ") {
182        // Two-number form (v3.16+): `<generation> <next_offset>`.
183        // One-number legacy form (v3.14): `<next_offset>` — decodes
184        // with generation 0 ("unknown"; real generations start at 1).
185        let marker = match rest.iter().position(|&b| b == b' ') {
186            Some(sp) => {
187                let generation =
188                    parse_decimal(&rest[..sp]).ok_or(WireError::BadEnvelope)?;
189                let next_offset =
190                    parse_decimal(&rest[sp + 1..]).ok_or(WireError::BadEnvelope)?;
191                SnapshotMarker::Ping { generation, next_offset }
192            }
193            None => SnapshotMarker::Ping {
194                generation: 0,
195                next_offset: parse_decimal(rest).ok_or(WireError::BadEnvelope)?,
196            },
197        };
198        return Ok(Some((marker, eol + 2)));
199    }
200    Err(WireError::BadEnvelope)
201}
202
203/// Decode the next snapshot chunk (`$L\r\n<L bytes>\r\n`) at the
204/// front of `buf`. Returns:
205/// - `Ok((chunk_bytes, used))` — `chunk_bytes` borrows from `buf`;
206///   `used` bytes were consumed.
207/// - `Err(WireError::Truncated)` — not enough bytes for a complete
208///   chunk yet.
209/// - `Err(WireError::BadEnvelope)` — header wasn't `$L\r\n`, `L`
210///   exceeded [`SNAPSHOT_CHUNK_MAX`], `L` parsed as non-numeric, or
211///   the trailing CRLF was missing.
212pub fn decode_snapshot_chunk(buf: &[u8]) -> Result<(&[u8], usize), WireError> {
213    if buf.is_empty() {
214        return Err(WireError::Truncated);
215    }
216    if buf[0] != b'$' {
217        return Err(WireError::BadEnvelope);
218    }
219    let len_eol = find_crlf(buf, 1).ok_or(WireError::Truncated)?;
220    let len = parse_decimal(&buf[1..len_eol]).ok_or(WireError::BadEnvelope)?;
221    let len = usize::try_from(len).map_err(|_| WireError::BadEnvelope)?;
222    if len > SNAPSHOT_CHUNK_MAX {
223        return Err(WireError::BadEnvelope);
224    }
225    let data_start = len_eol + 2;
226    let data_end = data_start + len;
227    if buf.len() < data_end + 2 {
228        return Err(WireError::Truncated);
229    }
230    if &buf[data_end..data_end + 2] != b"\r\n" {
231        return Err(WireError::BadEnvelope);
232    }
233    Ok((&buf[data_start..data_end], data_end + 2))
234}