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