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