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}