Skip to main content

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}