kevy_replicate/handshake.rs
1//! Replication handshake — the first round-trip a replica makes
2//! against the primary's replication TCP listener.
3//!
4//! Wire shape:
5//!
6//! - Replica → primary: a RESP2 multi-bulk command
7//! `REPLICATE FROM <generation> <from-offset> ID <replica-id>`
8//! (6 args). `<generation>` is the feed generation the replica's
9//! DATA reflects (`0` = unknown / fresh); `<from-offset>` is `0`
10//! for a fresh replica (full-sync intent) or the last applied
11//! offset from a reconnecting replica. Offsets are only meaningful
12//! within one generation — the primary must never serve
13//! `<from-offset>` continuity across a generation mismatch (that
14//! is the offset-aliasing hole this field closes).
15//! - Primary → replica: a RESP2 simple string
16//! `+ACK <generation> <current-offset>`, where `<generation>` is
17//! the primary's current feed generation and `<current-offset>`
18//! echoes the granted resume offset. The replica records both and
19//! starts consuming; if the primary's
20//! [`crate::source::ReplicationSource`] cannot serve from
21//! `<from-offset>` (TooOld), or the generations mismatch on a
22//! nonzero offset, the primary instead begins a snapshot ship
23//! (handled by the wiring layer, not here).
24//!
25//! This module owns only the parse + format primitives. Socket I/O,
26//! retry, and "did the primary choose snapshot vs live stream" logic
27//! live in the future replication source/replica modules.
28//!
29//! ```
30//! use kevy_replicate::feed::FeedPosition;
31//! use kevy_replicate::handshake::{HandshakeReq, encode_ack};
32//!
33//! // what a fresh replica sends, as the primary's RESP parser decodes it
34//! let wire = b"*6\r\n$9\r\nREPLICATE\r\n$4\r\nFROM\r\n$1\r\n0\r\n$1\r\n0\r\n$2\r\nID\r\n$2\r\nr1\r\n";
35//! let mut argv = kevy_resp::Argv::default();
36//! kevy_resp::parse_command_into(wire, &mut argv)?;
37//! let req = HandshakeReq::parse(&argv)?;
38//! assert_eq!(req.from, FeedPosition::default()); // no continuity claim: full sync
39//! assert_eq!(encode_ack(FeedPosition::new(5, 0)), b"+ACK 5 0\r\n");
40//! # Ok::<(), Box<dyn std::error::Error>>(())
41//! ```
42
43use kevy_resp::Argv;
44
45use crate::feed::FeedPosition;
46
47/// Parsed `REPLICATE FROM <generation> <from-offset> ID <replica-id>`
48/// request.
49///
50/// ```
51/// use kevy_replicate::handshake::HandshakeReq;
52///
53/// let argv = |w: &[&str]| kevy_resp::Argv::from(w.iter().map(|w| w.as_bytes().to_vec()).collect::<Vec<_>>());
54/// let req = HandshakeReq::parse(&argv(&["replicate", "from", "3", "17", "id", "r1"]))?;
55/// assert_eq!(req.replica_id, "r1"); // keywords are case-insensitive
56/// # Ok::<(), kevy_replicate::handshake::HandshakeError>(())
57/// ```
58#[derive(Debug, Clone, PartialEq, Eq, Hash)]
59#[non_exhaustive]
60pub struct HandshakeReq {
61 /// Where the replica wants to resume: the feed generation its data
62 /// reflects and the offset within it. Generation `0` = unknown /
63 /// fresh — the primary treats it as "no continuity claim"; offset
64 /// `0` = fresh replica.
65 ///
66 /// ```
67 /// use kevy_replicate::feed::FeedPosition;
68 /// use kevy_replicate::handshake::HandshakeReq;
69 ///
70 /// let argv = |w: &[&str]| kevy_resp::Argv::from(w.iter().map(|w| w.as_bytes().to_vec()).collect::<Vec<_>>());
71 /// let req = HandshakeReq::parse(&argv(&["REPLICATE", "FROM", "3", "17", "ID", "r1"]))?;
72 /// assert_eq!(req.from, FeedPosition::new(3, 17)); // resume at 17 if the primary is still on generation 3
73 /// # Ok::<(), kevy_replicate::handshake::HandshakeError>(())
74 /// ```
75 pub from: FeedPosition,
76 /// Replica-supplied identifier (operator-set, opaque to the
77 /// primary other than for slot bookkeeping).
78 ///
79 /// ```
80 /// use kevy_replicate::handshake::HandshakeReq;
81 /// use kevy_replicate::slot::SlotTable;
82 ///
83 /// let argv = |w: &[&str]| kevy_resp::Argv::from(w.iter().map(|w| w.as_bytes().to_vec()).collect::<Vec<_>>());
84 /// let req = HandshakeReq::parse(&argv(&["REPLICATE", "FROM", "0", "0", "ID", "eu-west-2"]))?;
85 /// let mut slots = SlotTable::new();
86 /// slots.insert_or_touch(&req.replica_id, req.from.offset, 0);
87 /// assert!(slots.get("eu-west-2").is_some());
88 /// # Ok::<(), kevy_replicate::handshake::HandshakeError>(())
89 /// ```
90 pub replica_id: String,
91}
92
93/// Why [`HandshakeReq::parse`] rejected its input.
94///
95/// ```
96/// use kevy_replicate::handshake::{HandshakeError, HandshakeReq};
97///
98/// let argv = |w: &[&str]| kevy_resp::Argv::from(w.iter().map(|w| w.as_bytes().to_vec()).collect::<Vec<_>>());
99/// let err = HandshakeReq::parse(&argv(&["REPLICATE", "FROM", "0", "0", "ID", ""])).unwrap_err();
100/// assert_eq!(err, HandshakeError::BadReplicaId);
101/// assert_eq!(err.to_string(), "replica id must be non-empty UTF-8");
102/// ```
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
104#[non_exhaustive]
105pub enum HandshakeError {
106 /// First arg is not "REPLICATE" (case-insensitive).
107 ///
108 /// ```
109 /// use kevy_replicate::handshake::{HandshakeError, HandshakeReq};
110 ///
111 /// let argv = |w: &[&str]| kevy_resp::Argv::from(w.iter().map(|w| w.as_bytes().to_vec()).collect::<Vec<_>>());
112 /// let err = HandshakeReq::parse(&argv(&["SYNC", "FROM", "0", "0", "ID", "r1"])).unwrap_err();
113 /// assert_eq!(err, HandshakeError::BadCommand);
114 /// ```
115 BadCommand,
116 /// Argument count is not exactly 6.
117 ///
118 /// ```
119 /// use kevy_replicate::handshake::{HandshakeError, HandshakeReq};
120 ///
121 /// let argv = |w: &[&str]| kevy_resp::Argv::from(w.iter().map(|w| w.as_bytes().to_vec()).collect::<Vec<_>>());
122 /// let err = HandshakeReq::parse(&argv(&["REPLICATE", "FROM", "0", "ID", "r1"])).unwrap_err();
123 /// assert_eq!(err, HandshakeError::WrongArity(5));
124 /// ```
125 WrongArity(usize),
126 /// Second arg is not "FROM" (case-insensitive).
127 ///
128 /// ```
129 /// use kevy_replicate::handshake::{HandshakeError, HandshakeReq};
130 ///
131 /// let argv = |w: &[&str]| kevy_resp::Argv::from(w.iter().map(|w| w.as_bytes().to_vec()).collect::<Vec<_>>());
132 /// let err = HandshakeReq::parse(&argv(&["REPLICATE", "AT", "0", "0", "ID", "r1"])).unwrap_err();
133 /// assert_eq!(err, HandshakeError::BadFromKeyword);
134 /// ```
135 BadFromKeyword,
136 /// Third arg (generation) did not parse as an unsigned decimal `u64`.
137 ///
138 /// ```
139 /// use kevy_replicate::handshake::{HandshakeError, HandshakeReq};
140 ///
141 /// let argv = |w: &[&str]| kevy_resp::Argv::from(w.iter().map(|w| w.as_bytes().to_vec()).collect::<Vec<_>>());
142 /// let err = HandshakeReq::parse(&argv(&["REPLICATE", "FROM", "g1", "0", "ID", "r1"])).unwrap_err();
143 /// assert_eq!(err, HandshakeError::BadGeneration);
144 /// ```
145 BadGeneration,
146 /// Fourth arg (offset) did not parse as an unsigned decimal `u64`.
147 ///
148 /// ```
149 /// use kevy_replicate::handshake::{HandshakeError, HandshakeReq};
150 ///
151 /// let argv = |w: &[&str]| kevy_resp::Argv::from(w.iter().map(|w| w.as_bytes().to_vec()).collect::<Vec<_>>());
152 /// let err = HandshakeReq::parse(&argv(&["REPLICATE", "FROM", "1", "-1", "ID", "r1"])).unwrap_err();
153 /// assert_eq!(err, HandshakeError::BadOffset);
154 /// ```
155 BadOffset,
156 /// Fifth arg is not "ID" (case-insensitive).
157 ///
158 /// ```
159 /// use kevy_replicate::handshake::{HandshakeError, HandshakeReq};
160 ///
161 /// let argv = |w: &[&str]| kevy_resp::Argv::from(w.iter().map(|w| w.as_bytes().to_vec()).collect::<Vec<_>>());
162 /// let err = HandshakeReq::parse(&argv(&["REPLICATE", "FROM", "0", "0", "NAME", "r1"])).unwrap_err();
163 /// assert_eq!(err, HandshakeError::BadIdKeyword);
164 /// ```
165 BadIdKeyword,
166 /// Replica id is empty or not valid UTF-8.
167 ///
168 /// ```
169 /// use kevy_replicate::handshake::{HandshakeError, HandshakeReq};
170 ///
171 /// let argv = |w: &[&str]| kevy_resp::Argv::from(w.iter().map(|w| w.as_bytes().to_vec()).collect::<Vec<_>>());
172 /// let err = HandshakeReq::parse(&argv(&["REPLICATE", "FROM", "0", "0", "ID", ""])).unwrap_err();
173 /// assert_eq!(err, HandshakeError::BadReplicaId);
174 /// ```
175 BadReplicaId,
176}
177
178impl std::fmt::Display for HandshakeError {
179 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180 match self {
181 Self::BadCommand => write!(f, "expected REPLICATE command"),
182 Self::WrongArity(n) => write!(f, "REPLICATE expects 6 args, got {n}"),
183 Self::BadFromKeyword => write!(f, "expected 'FROM' keyword"),
184 Self::BadGeneration => write!(f, "generation must be an unsigned decimal"),
185 Self::BadOffset => write!(f, "from-offset must be an unsigned decimal"),
186 Self::BadIdKeyword => write!(f, "expected 'ID' keyword"),
187 Self::BadReplicaId => write!(f, "replica id must be non-empty UTF-8"),
188 }
189 }
190}
191
192impl std::error::Error for HandshakeError {}
193
194impl HandshakeReq {
195 /// Parse a `REPLICATE FROM <generation> <offset> ID <id>` command
196 /// from an already-decoded [`Argv`] (the caller has run the bytes
197 /// through `kevy_resp::parse_command_into` first).
198 ///
199 /// ```
200 /// use kevy_replicate::handshake::HandshakeReq;
201 ///
202 /// let words = ["REPLICATE", "FROM", "7", "42", "ID", "r1"];
203 /// let argv = kevy_resp::Argv::from(words.iter().map(|w| w.as_bytes().to_vec()).collect::<Vec<_>>());
204 /// let req = HandshakeReq::parse(&argv)?;
205 /// assert_eq!((req.from.generation, req.from.offset, req.replica_id.as_str()), (7, 42, "r1"));
206 /// # Ok::<(), kevy_replicate::handshake::HandshakeError>(())
207 /// ```
208 // missing_panics_doc: the unwraps are guarded by the `len() != 6` arity check
209 // above them — unreachable, not a caller-facing panic condition.
210 #[allow(clippy::missing_panics_doc)]
211 pub fn parse(argv: &Argv) -> Result<HandshakeReq, HandshakeError> {
212 if argv.len() != 6 {
213 return Err(HandshakeError::WrongArity(argv.len()));
214 }
215 if !eq_ascii_ci(argv.get(0).expect("the argv.len() != 6 return above"), b"REPLICATE") {
216 return Err(HandshakeError::BadCommand);
217 }
218 if !eq_ascii_ci(argv.get(1).expect("the argv.len() != 6 return above"), b"FROM") {
219 return Err(HandshakeError::BadFromKeyword);
220 }
221 let generation = parse_decimal_u64(argv.get(2).expect("the argv.len() != 6 return above"))
222 .ok_or(HandshakeError::BadGeneration)?;
223 let from_offset = parse_decimal_u64(argv.get(3).expect("the argv.len() != 6 return above"))
224 .ok_or(HandshakeError::BadOffset)?;
225 if !eq_ascii_ci(argv.get(4).expect("the argv.len() != 6 return above"), b"ID") {
226 return Err(HandshakeError::BadIdKeyword);
227 }
228 let id_bytes = argv.get(5).expect("the argv.len() != 6 return above");
229 if id_bytes.is_empty() {
230 return Err(HandshakeError::BadReplicaId);
231 }
232 let replica_id =
233 std::str::from_utf8(id_bytes).map_err(|_| HandshakeError::BadReplicaId)?.to_string();
234 Ok(HandshakeReq { from: FeedPosition::new(generation, from_offset), replica_id })
235 }
236}
237
238/// Encode the primary's `+ACK <generation> <current-offset>\r\n`
239/// response. `at.generation` is the primary's CURRENT feed generation —
240/// the replica records it as the generation of whatever data this
241/// session delivers (frames or snapshot) — and `at.offset` the granted
242/// resume offset.
243///
244/// ```
245/// use kevy_replicate::feed::FeedPosition;
246///
247/// assert_eq!(kevy_replicate::handshake::encode_ack(FeedPosition::new(7, 42)), b"+ACK 7 42\r\n");
248/// ```
249pub fn encode_ack(at: FeedPosition) -> Vec<u8> {
250 let mut out = Vec::with_capacity(8 + 20 + 20 + 3);
251 out.extend_from_slice(b"+ACK ");
252 push_u64(&mut out, at.generation);
253 out.push(b' ');
254 push_u64(&mut out, at.offset);
255 out.extend_from_slice(b"\r\n");
256 out
257}
258
259fn eq_ascii_ci(a: &[u8], b: &[u8]) -> bool {
260 a.len() == b.len() && a.iter().zip(b).all(|(x, y)| x.eq_ignore_ascii_case(y))
261}
262
263fn parse_decimal_u64(bytes: &[u8]) -> Option<u64> {
264 if bytes.is_empty() {
265 return None;
266 }
267 let mut n: u64 = 0;
268 for &b in bytes {
269 if !b.is_ascii_digit() {
270 return None;
271 }
272 n = n.checked_mul(10)?.checked_add(u64::from(b - b'0'))?;
273 }
274 Some(n)
275}
276
277fn push_u64(out: &mut Vec<u8>, n: u64) {
278 if n == 0 {
279 out.push(b'0');
280 return;
281 }
282 let mut tmp = [0u8; 20];
283 let mut i = tmp.len();
284 let mut v = n;
285 while v != 0 {
286 i -= 1;
287 tmp[i] = b'0' + (v % 10) as u8;
288 v /= 10;
289 }
290 out.extend_from_slice(&tmp[i..]);
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296
297 fn argv(args: &[&[u8]]) -> Argv {
298 let mut a = Argv::default();
299 for arg in args {
300 a.push(arg);
301 }
302 a
303 }
304
305 #[test]
306 fn parses_fresh_replica_from_zero() {
307 let req =
308 HandshakeReq::parse(&argv(&[b"REPLICATE", b"FROM", b"0", b"0", b"ID", b"replica-a"]))
309 .unwrap();
310 assert_eq!(req.from, FeedPosition::new(0, 0));
311 assert_eq!(req.replica_id, "replica-a");
312 }
313
314 #[test]
315 fn parses_reconnect_with_generation_and_large_offset() {
316 let req = HandshakeReq::parse(&argv(&[
317 b"REPLICATE",
318 b"FROM",
319 b"7",
320 b"4294967296", // 2^32 — guarantees u64 path
321 b"ID",
322 b"node-7",
323 ]))
324 .unwrap();
325 assert_eq!(req.from, FeedPosition::new(7, 4_294_967_296));
326 assert_eq!(req.replica_id, "node-7");
327 }
328
329 #[test]
330 fn keywords_are_case_insensitive() {
331 let req =
332 HandshakeReq::parse(&argv(&[b"replicate", b"from", b"2", b"1", b"id", b"x"])).unwrap();
333 assert_eq!(req.from, FeedPosition::new(2, 1));
334 assert_eq!(req.replica_id, "x");
335 }
336
337 #[test]
338 fn wrong_arity_rejected_with_actual_count() {
339 // The legacy 5-arg (gen-less) form is a WrongArity rejection,
340 // not a silent downgrade — 4.0 is a clean wire break.
341 let err =
342 HandshakeReq::parse(&argv(&[b"REPLICATE", b"FROM", b"0", b"ID", b"a"])).unwrap_err();
343 assert_eq!(err, HandshakeError::WrongArity(5));
344 }
345
346 #[test]
347 fn wrong_command_rejected() {
348 let err = HandshakeReq::parse(&argv(&[b"SUBSCRIBE", b"FROM", b"0", b"0", b"ID", b"a"]))
349 .unwrap_err();
350 assert_eq!(err, HandshakeError::BadCommand);
351 }
352
353 #[test]
354 fn wrong_from_keyword_rejected() {
355 let err = HandshakeReq::parse(&argv(&[b"REPLICATE", b"AT", b"0", b"0", b"ID", b"a"]))
356 .unwrap_err();
357 assert_eq!(err, HandshakeError::BadFromKeyword);
358 }
359
360 #[test]
361 fn wrong_id_keyword_rejected() {
362 let err = HandshakeReq::parse(&argv(&[b"REPLICATE", b"FROM", b"0", b"0", b"NAME", b"a"]))
363 .unwrap_err();
364 assert_eq!(err, HandshakeError::BadIdKeyword);
365 }
366
367 #[test]
368 fn non_decimal_generation_rejected() {
369 let err = HandshakeReq::parse(&argv(&[b"REPLICATE", b"FROM", b"NaN", b"0", b"ID", b"a"]))
370 .unwrap_err();
371 assert_eq!(err, HandshakeError::BadGeneration);
372 }
373
374 #[test]
375 fn non_decimal_offset_rejected() {
376 let err = HandshakeReq::parse(&argv(&[b"REPLICATE", b"FROM", b"1", b"NaN", b"ID", b"a"]))
377 .unwrap_err();
378 assert_eq!(err, HandshakeError::BadOffset);
379 }
380
381 #[test]
382 fn negative_offset_rejected_as_bad_offset() {
383 let err = HandshakeReq::parse(&argv(&[b"REPLICATE", b"FROM", b"1", b"-1", b"ID", b"a"]))
384 .unwrap_err();
385 assert_eq!(err, HandshakeError::BadOffset);
386 }
387
388 #[test]
389 fn empty_replica_id_rejected() {
390 let err = HandshakeReq::parse(&argv(&[b"REPLICATE", b"FROM", b"0", b"0", b"ID", b""]))
391 .unwrap_err();
392 assert_eq!(err, HandshakeError::BadReplicaId);
393 }
394
395 #[test]
396 fn non_utf8_replica_id_rejected() {
397 let err = HandshakeReq::parse(&argv(&[
398 b"REPLICATE",
399 b"FROM",
400 b"0",
401 b"0",
402 b"ID",
403 &[0xFF, 0xFE, 0xFD], // invalid UTF-8
404 ]))
405 .unwrap_err();
406 assert_eq!(err, HandshakeError::BadReplicaId);
407 }
408
409 #[test]
410 fn ack_format_for_zero() {
411 assert_eq!(encode_ack(FeedPosition::new(1, 0)), b"+ACK 1 0\r\n");
412 }
413
414 #[test]
415 fn ack_format_for_large_values() {
416 assert_eq!(encode_ack(FeedPosition::new(12, 987_654_321)), b"+ACK 12 987654321\r\n");
417 }
418}