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 { .. }) => continue,
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 => continue,
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}