Skip to main content

kevy_replicate/
replica.rs

1//! Replica-side client — connect to a primary's replication listener,
2//! perform the handshake, then yield decoded mutation frames in order.
3//!
4//! The client is **synchronous + blocking** by design: it slots into a
5//! dedicated thread on the replica node alongside (but separate from)
6//! the regular kevy reactor. An async surface is a Phase 4 deliverable
7//! (`kevy-client-async`, the only crate carved out of the 0-dep rule).
8//!
9//! Hot loop usage:
10//!
11//! ```no_run
12//! use kevy_replicate::replica::ReplicaClient;
13//!
14//! let mut client = ReplicaClient::connect("127.0.0.1:16004", "replica-a", 0)
15//!     .expect("connect ok");
16//! while let Some(result) = client.next() {
17//!     let frame = result.expect("decode ok");
18//!     // apply frame.argv at frame.offset — caller's responsibility (T1.19)
19//!     drop(frame);
20//! }
21//! ```
22//!
23//! Errors map to actionable next steps for the caller:
24//! - [`ReplicaError::HandshakeRejected`] / [`ReplicaError::AckMalformed`]
25//!   — primary refused or replied with garbage; drop the link, log,
26//!   maybe back off and retry.
27//! - [`ReplicaError::Truncated`] — peer EOF mid-frame; treat as a
28//!   disconnect, reconnect later.
29//! - [`ReplicaError::OffsetGap { expected, got }`] — frames arrived
30//!   out of order or with a skip; per plan T1.20 the caller should
31//!   trigger a full snapshot resync. v1.18.0 surfaces the gap; the
32//!   snapshot ship machinery itself lands at T1.22.
33//! - [`ReplicaError::Frame`] — wire-level decode error; same
34//!   action as Truncated (drop + reconnect).
35
36use crate::wire::WireError;
37use kevy_resp::Argv;
38use std::io::{self, Read, Write};
39use std::net::{TcpStream, ToSocketAddrs};
40use std::time::Duration;
41
42/// A decoded mutation frame the replica should apply to its local
43/// store. Ownership of the [`Argv`] passes to the caller.
44#[derive(Debug)]
45pub struct DecodedFrame {
46    /// Monotonic offset the primary assigned at apply-time.
47    pub offset: u64,
48    /// Wire-decoded argv — feed to the dispatcher the same way AOF
49    /// replay does (cmd name + arg bytes).
50    pub argv: Argv,
51}
52
53/// Event yielded by [`ReplicaClient::next_event`]. A driver loop
54/// pattern-matches and applies each:
55/// - [`Self::Frame`] → run through the local dispatcher.
56/// - [`Self::SnapshotBegin`] → caller should reset / prepare the
57///   local store for a fresh-from-snapshot fill.
58/// - [`Self::SnapshotChunk`] → append the bytes to the caller's
59///   accumulating snapshot buffer.
60/// - [`Self::SnapshotEnd`] → caller hands the accumulated buffer to
61///   `kevy_persist::load_snapshot`; [`ReplicaClient`] has already
62///   advanced `expected_offset` to `ack_offset`, so the next
63///   [`Self::Frame`] arrives at `ack_offset` with no gap.
64#[derive(Debug)]
65pub enum ReplicaEvent {
66    /// A live mutation frame.
67    Frame(DecodedFrame),
68    /// v3.14 in-stream heartbeat: the primary's `next_offset` at send
69    /// time. Lets the replica compute lag (applied vs primary) and
70    /// judge link liveness. Occupies no offset space.
71    Ping {
72        /// Primary's feed generation at send time (v3.16 — the
73        /// REPL.TOKEN / REPL.WAIT gen truth). `0` = the primary spoke
74        /// the pre-v3.16 one-number heartbeat ("unknown").
75        generation: u64,
76        /// Primary's `next_offset` when the heartbeat was emitted.
77        primary_offset: u64,
78    },
79    /// Snapshot ship begin marker (`+SNAPSHOT\r\n`).
80    SnapshotBegin,
81    /// One snapshot chunk's payload bytes (RESP bulk string body).
82    SnapshotChunk(Vec<u8>),
83    /// Snapshot ship end marker carrying the offset the next live
84    /// frame will have.
85    SnapshotEnd {
86        /// The offset the primary's `next_offset` was at when the
87        /// snapshot started. After this event, [`ReplicaClient::expected_offset`]
88        /// equals this value.
89        ack_offset: u64,
90    },
91}
92
93/// Errors a replica client can surface to its driver loop.
94#[derive(Debug)]
95pub enum ReplicaError {
96    /// Primary closed the connection or never replied during the
97    /// handshake / `+ACK` exchange.
98    HandshakeRejected,
99    /// `+ACK` line was malformed (didn't start with `+ACK `, didn't
100    /// parse the offset).
101    AckMalformed,
102    /// Peer closed the connection mid-frame; reconnect to resume.
103    Truncated,
104    /// Wire-level decode error (envelope shape wrong, payload
105    /// malformed, etc.).
106    Frame(WireError),
107    /// Frame arrived with an offset other than the expected next.
108    /// Caller should trigger a full snapshot resync (T1.22).
109    OffsetGap {
110        /// The offset the client expected next (= `last_seen + 1`).
111        expected: u64,
112        /// The offset the primary actually sent.
113        got: u64,
114    },
115    /// While streaming a snapshot, the primary sent bytes that were
116    /// neither a snapshot chunk nor `+SNAPSHOT_END`. v1.18.0 forbids
117    /// interleaving live frames inside a snapshot (see `docs/snapshot.md`).
118    UnexpectedInSnapshot,
119    /// `next_frame` was called but the next event is a snapshot
120    /// marker / chunk. Callers that want the snapshot-aware surface
121    /// must use [`ReplicaClient::next_event`].
122    SnapshotInProgress,
123    /// Underlying socket I/O failure.
124    Io(io::Error),
125}
126
127impl std::fmt::Display for ReplicaError {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        match self {
130            Self::HandshakeRejected => write!(f, "primary rejected replication handshake"),
131            Self::AckMalformed => write!(f, "primary sent malformed +ACK"),
132            Self::Truncated => write!(f, "replication stream truncated by peer"),
133            Self::Frame(e) => write!(f, "replication frame decode error: {e}"),
134            Self::OffsetGap { expected, got } => {
135                write!(f, "replication offset gap: expected {expected}, got {got}")
136            }
137            Self::UnexpectedInSnapshot => {
138                write!(f, "primary sent non-chunk bytes mid-snapshot")
139            }
140            Self::SnapshotInProgress => {
141                write!(f, "snapshot in progress; use next_event() to consume")
142            }
143            Self::Io(e) => write!(f, "replication socket I/O error: {e}"),
144        }
145    }
146}
147
148impl std::error::Error for ReplicaError {}
149
150impl From<io::Error> for ReplicaError {
151    fn from(e: io::Error) -> Self {
152        ReplicaError::Io(e)
153    }
154}
155
156impl From<WireError> for ReplicaError {
157    fn from(e: WireError) -> Self {
158        match e {
159            WireError::Truncated => ReplicaError::Truncated,
160            other => ReplicaError::Frame(other),
161        }
162    }
163}
164
165/// One blocking TCP connection to a primary's per-shard replication
166/// listener. After [`Self::connect`] completes the handshake, the
167/// client behaves as an `Iterator<Item = Result<DecodedFrame, ReplicaError>>`
168/// yielding frames in offset order until the peer disconnects or a
169/// hard error surfaces.
170pub struct ReplicaClient {
171    pub(crate) sock: TcpStream,
172    /// Bytes pulled off the socket waiting to parse the next frame.
173    pub(crate) buf: Vec<u8>,
174    /// Position into `buf` where the next decode attempt starts. We
175    /// drain `buf` only when this passes a high-water mark, so per-
176    /// frame work avoids repeated `Vec::drain` shifts.
177    pub(crate) cursor: usize,
178    /// Offset the primary advertised at handshake (`+ACK <N>` value).
179    /// Currently informational; T1.20 / T1.22 use it for gap-detection
180    /// decisions (re-handshake vs full sync).
181    pub(crate) primary_offset_at_handshake: u64,
182    /// The next offset we expect from the stream. Initially the
183    /// `from_offset` we requested; advances by 1 on each accepted frame.
184    pub(crate) expected_offset: u64,
185    /// `true` while we're between `+SNAPSHOT` and `+SNAPSHOT_END`.
186    /// In this state, only chunk + end-marker bytes are valid; a
187    /// `*2\r\n` (live frame envelope) returns
188    /// [`ReplicaError::UnexpectedInSnapshot`] per the v1.18 spec
189    /// (`docs/snapshot.md` — interleaving is a T1.25 extension).
190    pub(crate) in_snapshot: bool,
191}
192
193impl ReplicaClient {
194    /// Connect to `addr`, send `REPLICATE FROM <from_offset> ID <replica_id>`,
195    /// read the `+ACK <offset>` reply, and return a ready-to-iterate
196    /// client. Blocks until the handshake completes or the connect
197    /// times out (`connect_timeout` argument).
198    pub fn connect<A: ToSocketAddrs>(
199        addr: A,
200        replica_id: &str,
201        from_offset: u64,
202    ) -> Result<Self, ReplicaError> {
203        Self::connect_with_timeout(addr, replica_id, from_offset, Duration::from_secs(5))
204    }
205
206    /// [`Self::connect`] with an explicit connect timeout. Useful for
207    /// tests that don't want to wait the default 5 s when a port is
208    /// closed.
209    pub fn connect_with_timeout<A: ToSocketAddrs>(
210        addr: A,
211        replica_id: &str,
212        from_offset: u64,
213        connect_timeout: Duration,
214    ) -> Result<Self, ReplicaError> {
215        // Resolve + connect with timeout. ToSocketAddrs returns an
216        // iterator; we try each address until one succeeds.
217        let mut last_err: Option<io::Error> = None;
218        let mut sock: Option<TcpStream> = None;
219        for sa in addr.to_socket_addrs().map_err(ReplicaError::Io)? {
220            match TcpStream::connect_timeout(&sa, connect_timeout) {
221                Ok(s) => {
222                    sock = Some(s);
223                    break;
224                }
225                Err(e) => last_err = Some(e),
226            }
227        }
228        let mut sock = sock.ok_or_else(|| {
229            ReplicaError::Io(last_err.unwrap_or_else(|| {
230                io::Error::new(io::ErrorKind::InvalidInput, "no socket address resolved")
231            }))
232        })?;
233
234        // Send the handshake. `encode_replicate_from` is a private
235        // helper so the on-the-wire shape is one place to change.
236        let req = encode_replicate_from(from_offset, replica_id);
237        sock.write_all(&req)?;
238
239        // Read the `+ACK <offset>\r\n` reply. Use a small read timeout
240        // so a primary that opens the socket but never replies doesn't
241        // hang the replica forever.
242        sock.set_read_timeout(Some(connect_timeout))?;
243        let primary_offset = read_ack(&mut sock)?;
244        // Clear the read timeout for normal streaming (replica may sit
245        // for minutes with no frames if the primary is idle).
246        sock.set_read_timeout(None)?;
247        sock.set_nonblocking(false)?; // explicit: blocking reads after handshake.
248
249        Ok(ReplicaClient {
250            sock,
251            buf: Vec::with_capacity(8 * 1024),
252            cursor: 0,
253            primary_offset_at_handshake: primary_offset,
254            expected_offset: from_offset,
255            in_snapshot: false,
256        })
257    }
258
259    /// Offset the primary reported at handshake (`+ACK <N>` value).
260    /// Informational — exposed so callers can log + future T1.22
261    /// snapshot-ship logic can compare against the local applied
262    /// offset to decide resume vs full-sync.
263    pub fn primary_offset_at_handshake(&self) -> u64 {
264        self.primary_offset_at_handshake
265    }
266
267    /// Return a `try_clone`'d handle on the underlying socket. The
268    /// clone shares the same kernel file description, so calling
269    /// `shutdown(Shutdown::Both)` on it unblocks any in-flight
270    /// blocking read on the original (and vice versa). T1.29.5 uses
271    /// this to interrupt a runner thread parked in `next_event` when
272    /// `REPLICAOF` retargets or `REPLICAOF NO ONE` demotes — without
273    /// this handle, the runner stays blocked until the upstream peer
274    /// closes the connection.
275    pub fn socket_handle(&self) -> io::Result<TcpStream> {
276        self.sock.try_clone()
277    }
278
279    /// v3.14 — write `REPLCONF ACK <offset>` back on the replication
280    /// connection. The primary's pump drains these non-blocking and
281    /// advances the replica's slot; call every ~100ms with the highest
282    /// received frame offset + 1 (i.e. the next offset you expect).
283    pub fn send_ack(&mut self, offset: u64) -> std::io::Result<()> {
284        use std::io::Write as _;
285        self.sock.write_all(&crate::wire::encode_replconf_ack(offset))
286    }
287
288    /// The offset the next frame should carry. Advances on every
289    /// successful `next()`.
290    pub fn expected_offset(&self) -> u64 {
291        self.expected_offset
292    }
293
294    /// Pull the next frame from the stream. Frame-only convenience —
295    /// returns [`ReplicaError::SnapshotInProgress`] if the primary is
296    /// sending a snapshot. Callers that need the snapshot-aware
297    /// surface (T1.22) must use [`Self::next_event`] instead.
298    /// Returns `None` on clean peer EOF (no buffered bytes left).
299    pub fn next_frame(&mut self) -> Option<Result<DecodedFrame, ReplicaError>> {
300        loop {
301            match self.next_event()? {
302                Ok(ReplicaEvent::Frame(f)) => return Some(Ok(f)),
303                // v3.14 heartbeats are out-of-band — invisible to a
304                // frame-only consumer.
305                Ok(ReplicaEvent::Ping { .. }) => {}
306                Ok(_) => return Some(Err(ReplicaError::SnapshotInProgress)),
307                Err(e) => return Some(Err(e)),
308            }
309        }
310    }
311
312    /// Drop already-consumed prefix when the cursor has walked past
313    /// 4 KiB of buffer (amortises per-frame work without doing a full
314    /// `drain` on every frame). Used by the event-decoding helpers
315    /// in [`crate::replica_decode`].
316    pub(crate) fn maybe_compact_buf(&mut self) {
317        if self.cursor >= 4 * 1024 {
318            self.buf.drain(..self.cursor);
319            self.cursor = 0;
320        }
321    }
322}
323
324impl Iterator for ReplicaClient {
325    type Item = Result<DecodedFrame, ReplicaError>;
326    /// Frame-only iterator. Use [`ReplicaClient::next_event`] for the
327    /// snapshot-aware surface.
328    fn next(&mut self) -> Option<Self::Item> {
329        self.next_frame()
330    }
331}
332
333/// Compose a `REPLICATE FROM <offset> ID <id>` RESP2 multi-bulk
334/// request — symmetric to `handshake::parse_replicate_from` on the
335/// primary side.
336fn encode_replicate_from(from_offset: u64, replica_id: &str) -> Vec<u8> {
337    let mut v = Vec::with_capacity(64 + replica_id.len());
338    v.extend_from_slice(b"*5\r\n");
339    let offset_str = from_offset.to_string();
340    for arg in [
341        b"REPLICATE".as_slice(),
342        b"FROM",
343        offset_str.as_bytes(),
344        b"ID",
345        replica_id.as_bytes(),
346    ] {
347        let header = format!("${}\r\n", arg.len());
348        v.extend_from_slice(header.as_bytes());
349        v.extend_from_slice(arg);
350        v.extend_from_slice(b"\r\n");
351    }
352    v
353}
354
355/// Read `+ACK <offset>\r\n` from `sock`, return the parsed offset.
356/// Pulls one byte at a time — the reply is < 30 bytes, so the per-
357/// byte syscall cost is negligible and avoids a buffering surface
358/// we'd have to thread into the client struct just for the handshake.
359fn read_ack(sock: &mut TcpStream) -> Result<u64, ReplicaError> {
360    let mut line = Vec::with_capacity(32);
361    let mut b = [0u8; 1];
362    loop {
363        match sock.read(&mut b) {
364            Ok(0) => return Err(ReplicaError::HandshakeRejected),
365            Ok(_) => {
366                line.push(b[0]);
367                if line.len() >= 2 && line.ends_with(b"\r\n") {
368                    break;
369                }
370                if line.len() > 256 {
371                    return Err(ReplicaError::AckMalformed);
372                }
373            }
374            Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
375            Err(e) => return Err(ReplicaError::Io(e)),
376        }
377    }
378    parse_ack_line(&line)
379}
380
381fn parse_ack_line(line: &[u8]) -> Result<u64, ReplicaError> {
382    let body = line.strip_suffix(b"\r\n").ok_or(ReplicaError::AckMalformed)?;
383    let body = body.strip_prefix(b"+ACK ").ok_or(ReplicaError::AckMalformed)?;
384    let s = std::str::from_utf8(body).map_err(|_| ReplicaError::AckMalformed)?;
385    s.parse::<u64>().map_err(|_| ReplicaError::AckMalformed)
386}
387
388#[cfg(test)]
389impl ReplicaClient {
390    /// Test-only constructor that wraps an already-connected socket
391    /// without doing the handshake. Lets unit tests drive the event
392    /// loop against canned bytes from the other end of a TcpStream pair.
393    pub(crate) fn from_socket_for_test(sock: TcpStream, expected_offset: u64) -> Self {
394        Self {
395            sock,
396            buf: Vec::with_capacity(8 * 1024),
397            cursor: 0,
398            primary_offset_at_handshake: expected_offset,
399            expected_offset,
400            in_snapshot: false,
401        }
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    #[test]
410    fn encoded_replicate_from_matches_what_primary_parses() {
411        // Round-trip: encode here, parse via the primary-side parser.
412        let bytes = encode_replicate_from(42, "replica-a");
413        let mut argv = Argv::default();
414        let consumed = kevy_resp::parse_command_into(&bytes, &mut argv)
415            .expect("parse ok")
416            .expect("complete");
417        assert_eq!(consumed, bytes.len());
418        let req = crate::handshake::parse_replicate_from(&argv).expect("handshake ok");
419        assert_eq!(req.from_offset, 42);
420        assert_eq!(req.replica_id, "replica-a");
421    }
422
423    #[test]
424    fn ack_line_parses_offsets() {
425        assert_eq!(parse_ack_line(b"+ACK 0\r\n").unwrap(), 0);
426        assert_eq!(parse_ack_line(b"+ACK 42\r\n").unwrap(), 42);
427        assert_eq!(parse_ack_line(b"+ACK 12345678\r\n").unwrap(), 12_345_678);
428    }
429
430    #[test]
431    fn ack_line_rejects_malformed() {
432        assert!(matches!(
433            parse_ack_line(b"+PONG\r\n"),
434            Err(ReplicaError::AckMalformed)
435        ));
436        assert!(matches!(
437            parse_ack_line(b"+ACK abc\r\n"),
438            Err(ReplicaError::AckMalformed)
439        ));
440        assert!(matches!(
441            parse_ack_line(b"-ERR nope\r\n"),
442            Err(ReplicaError::AckMalformed)
443        ));
444        // Missing CRLF.
445        assert!(matches!(
446            parse_ack_line(b"+ACK 1"),
447            Err(ReplicaError::AckMalformed)
448        ));
449    }
450
451    #[test]
452    fn ack_line_rejects_offset_overflow() {
453        // 21+ digits — beyond u64::MAX. parse::<u64>() returns Err →
454        // AckMalformed.
455        assert!(matches!(
456            parse_ack_line(b"+ACK 99999999999999999999999\r\n"),
457            Err(ReplicaError::AckMalformed)
458        ));
459    }
460
461    #[test]
462    fn from_io_error_wraps_into_io_variant() {
463        let e: ReplicaError = io::Error::new(io::ErrorKind::ConnectionRefused, "x").into();
464        assert!(matches!(e, ReplicaError::Io(_)));
465    }
466
467    #[test]
468    fn from_wire_error_truncated_maps_to_truncated() {
469        let e: ReplicaError = WireError::Truncated.into();
470        assert!(matches!(e, ReplicaError::Truncated));
471    }
472
473    #[test]
474    fn from_wire_error_other_maps_to_frame() {
475        let e: ReplicaError = WireError::BadEnvelope.into();
476        assert!(matches!(e, ReplicaError::Frame(_)));
477    }
478
479}