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
29use kevy_resp::Argv;
30
31/// Parsed `REPLICATE FROM <generation> <from-offset> ID <replica-id>`
32/// request.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct HandshakeReq {
35    /// Feed generation the replica's data reflects. `0` = unknown /
36    /// fresh — the primary treats it as "no continuity claim".
37    pub generation: u64,
38    /// Offset the replica wants to resume from. `0` = fresh replica.
39    /// Only meaningful within `generation`.
40    pub from_offset: u64,
41    /// Replica-supplied identifier (operator-set, opaque to the
42    /// primary other than for slot bookkeeping).
43    pub replica_id: String,
44}
45
46/// Why a [`parse_replicate_from`] call rejected its input.
47#[derive(Debug, PartialEq, Eq)]
48pub enum HandshakeError {
49    /// First arg is not "REPLICATE" (case-insensitive).
50    BadCommand,
51    /// Argument count is not exactly 6.
52    WrongArity(usize),
53    /// Second arg is not "FROM" (case-insensitive).
54    BadFromKeyword,
55    /// Third arg (generation) did not parse as an unsigned decimal `u64`.
56    BadGeneration,
57    /// Fourth arg (offset) did not parse as an unsigned decimal `u64`.
58    BadOffset,
59    /// Fifth arg is not "ID" (case-insensitive).
60    BadIdKeyword,
61    /// Replica id is empty or not valid UTF-8.
62    BadReplicaId,
63}
64
65impl std::fmt::Display for HandshakeError {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        match self {
68            Self::BadCommand => write!(f, "expected REPLICATE command"),
69            Self::WrongArity(n) => write!(f, "REPLICATE expects 6 args, got {n}"),
70            Self::BadFromKeyword => write!(f, "expected 'FROM' keyword"),
71            Self::BadGeneration => write!(f, "generation must be an unsigned decimal"),
72            Self::BadOffset => write!(f, "from-offset must be an unsigned decimal"),
73            Self::BadIdKeyword => write!(f, "expected 'ID' keyword"),
74            Self::BadReplicaId => write!(f, "replica id must be non-empty UTF-8"),
75        }
76    }
77}
78
79impl std::error::Error for HandshakeError {}
80
81/// Parse a `REPLICATE FROM <generation> <offset> ID <id>` command
82/// from an already-decoded [`Argv`] (the caller has run the bytes
83/// through `kevy_resp::parse_command_into` first).
84// missing_panics_doc: the unwraps are guarded by the `len() != 6` arity check
85// above them — unreachable, not a caller-facing panic condition.
86#[allow(clippy::missing_panics_doc)]
87pub fn parse_replicate_from(argv: &Argv) -> Result<HandshakeReq, HandshakeError> {
88    if argv.len() != 6 {
89        return Err(HandshakeError::WrongArity(argv.len()));
90    }
91    if !eq_ascii_ci(argv.get(0).unwrap(), b"REPLICATE") {
92        return Err(HandshakeError::BadCommand);
93    }
94    if !eq_ascii_ci(argv.get(1).unwrap(), b"FROM") {
95        return Err(HandshakeError::BadFromKeyword);
96    }
97    let generation =
98        parse_decimal_u64(argv.get(2).unwrap()).ok_or(HandshakeError::BadGeneration)?;
99    let from_offset =
100        parse_decimal_u64(argv.get(3).unwrap()).ok_or(HandshakeError::BadOffset)?;
101    if !eq_ascii_ci(argv.get(4).unwrap(), b"ID") {
102        return Err(HandshakeError::BadIdKeyword);
103    }
104    let id_bytes = argv.get(5).unwrap();
105    if id_bytes.is_empty() {
106        return Err(HandshakeError::BadReplicaId);
107    }
108    let replica_id =
109        std::str::from_utf8(id_bytes).map_err(|_| HandshakeError::BadReplicaId)?.to_string();
110    Ok(HandshakeReq {
111        generation,
112        from_offset,
113        replica_id,
114    })
115}
116
117/// Encode the primary's `+ACK <generation> <current-offset>\r\n`
118/// response. `generation` is the primary's CURRENT feed generation —
119/// the replica records it as the generation of whatever data this
120/// session delivers (frames or snapshot).
121pub fn encode_ack(generation: u64, current_offset: u64) -> Vec<u8> {
122    let mut out = Vec::with_capacity(8 + 20 + 20 + 3);
123    out.extend_from_slice(b"+ACK ");
124    push_u64(&mut out, generation);
125    out.push(b' ');
126    push_u64(&mut out, current_offset);
127    out.extend_from_slice(b"\r\n");
128    out
129}
130
131fn eq_ascii_ci(a: &[u8], b: &[u8]) -> bool {
132    a.len() == b.len()
133        && a.iter()
134            .zip(b)
135            .all(|(x, y)| x.eq_ignore_ascii_case(y))
136}
137
138fn parse_decimal_u64(bytes: &[u8]) -> Option<u64> {
139    if bytes.is_empty() {
140        return None;
141    }
142    let mut n: u64 = 0;
143    for &b in bytes {
144        if !b.is_ascii_digit() {
145            return None;
146        }
147        n = n.checked_mul(10)?.checked_add(u64::from(b - b'0'))?;
148    }
149    Some(n)
150}
151
152fn push_u64(out: &mut Vec<u8>, n: u64) {
153    if n == 0 {
154        out.push(b'0');
155        return;
156    }
157    let mut tmp = [0u8; 20];
158    let mut i = tmp.len();
159    let mut v = n;
160    while v != 0 {
161        i -= 1;
162        tmp[i] = b'0' + (v % 10) as u8;
163        v /= 10;
164    }
165    out.extend_from_slice(&tmp[i..]);
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    fn argv(args: &[&[u8]]) -> Argv {
173        let mut a = Argv::default();
174        for arg in args {
175            a.push(arg);
176        }
177        a
178    }
179
180    #[test]
181    fn parses_fresh_replica_from_zero() {
182        let req = parse_replicate_from(&argv(&[
183            b"REPLICATE",
184            b"FROM",
185            b"0",
186            b"0",
187            b"ID",
188            b"replica-a",
189        ]))
190        .unwrap();
191        assert_eq!(req.generation, 0);
192        assert_eq!(req.from_offset, 0);
193        assert_eq!(req.replica_id, "replica-a");
194    }
195
196    #[test]
197    fn parses_reconnect_with_generation_and_large_offset() {
198        let req = parse_replicate_from(&argv(&[
199            b"REPLICATE",
200            b"FROM",
201            b"7",
202            b"4294967296", // 2^32 — guarantees u64 path
203            b"ID",
204            b"node-7",
205        ]))
206        .unwrap();
207        assert_eq!(req.generation, 7);
208        assert_eq!(req.from_offset, 4_294_967_296);
209        assert_eq!(req.replica_id, "node-7");
210    }
211
212    #[test]
213    fn keywords_are_case_insensitive() {
214        let req = parse_replicate_from(&argv(&[
215            b"replicate", b"from", b"2", b"1", b"id", b"x",
216        ]))
217        .unwrap();
218        assert_eq!(req.generation, 2);
219        assert_eq!(req.from_offset, 1);
220        assert_eq!(req.replica_id, "x");
221    }
222
223    #[test]
224    fn wrong_arity_rejected_with_actual_count() {
225        // The legacy 5-arg (gen-less) form is a WrongArity rejection,
226        // not a silent downgrade — 4.0 is a clean wire break.
227        let err = parse_replicate_from(&argv(&[b"REPLICATE", b"FROM", b"0", b"ID", b"a"]))
228            .unwrap_err();
229        assert_eq!(err, HandshakeError::WrongArity(5));
230    }
231
232    #[test]
233    fn wrong_command_rejected() {
234        let err =
235            parse_replicate_from(&argv(&[b"SUBSCRIBE", b"FROM", b"0", b"0", b"ID", b"a"]))
236                .unwrap_err();
237        assert_eq!(err, HandshakeError::BadCommand);
238    }
239
240    #[test]
241    fn wrong_from_keyword_rejected() {
242        let err =
243            parse_replicate_from(&argv(&[b"REPLICATE", b"AT", b"0", b"0", b"ID", b"a"]))
244                .unwrap_err();
245        assert_eq!(err, HandshakeError::BadFromKeyword);
246    }
247
248    #[test]
249    fn wrong_id_keyword_rejected() {
250        let err = parse_replicate_from(&argv(&[
251            b"REPLICATE",
252            b"FROM",
253            b"0",
254            b"0",
255            b"NAME",
256            b"a",
257        ]))
258        .unwrap_err();
259        assert_eq!(err, HandshakeError::BadIdKeyword);
260    }
261
262    #[test]
263    fn non_decimal_generation_rejected() {
264        let err =
265            parse_replicate_from(&argv(&[b"REPLICATE", b"FROM", b"NaN", b"0", b"ID", b"a"]))
266                .unwrap_err();
267        assert_eq!(err, HandshakeError::BadGeneration);
268    }
269
270    #[test]
271    fn non_decimal_offset_rejected() {
272        let err =
273            parse_replicate_from(&argv(&[b"REPLICATE", b"FROM", b"1", b"NaN", b"ID", b"a"]))
274                .unwrap_err();
275        assert_eq!(err, HandshakeError::BadOffset);
276    }
277
278    #[test]
279    fn negative_offset_rejected_as_bad_offset() {
280        let err =
281            parse_replicate_from(&argv(&[b"REPLICATE", b"FROM", b"1", b"-1", b"ID", b"a"]))
282                .unwrap_err();
283        assert_eq!(err, HandshakeError::BadOffset);
284    }
285
286    #[test]
287    fn empty_replica_id_rejected() {
288        let err =
289            parse_replicate_from(&argv(&[b"REPLICATE", b"FROM", b"0", b"0", b"ID", b""]))
290                .unwrap_err();
291        assert_eq!(err, HandshakeError::BadReplicaId);
292    }
293
294    #[test]
295    fn non_utf8_replica_id_rejected() {
296        let err = parse_replicate_from(&argv(&[
297            b"REPLICATE",
298            b"FROM",
299            b"0",
300            b"0",
301            b"ID",
302            &[0xFF, 0xFE, 0xFD], // invalid UTF-8
303        ]))
304        .unwrap_err();
305        assert_eq!(err, HandshakeError::BadReplicaId);
306    }
307
308    #[test]
309    fn ack_format_for_zero() {
310        assert_eq!(encode_ack(1, 0), b"+ACK 1 0\r\n");
311    }
312
313    #[test]
314    fn ack_format_for_large_values() {
315        assert_eq!(encode_ack(12, 987_654_321), b"+ACK 12 987654321\r\n");
316    }
317}