Skip to main content

armdb/replication/
protocol.rs

1use std::io::{self, Read, Write};
2
3/// Maximum allowed frame payload size. Guards `read_frame` against a
4/// malformed length prefix that could trigger multi-GiB allocations.
5pub const MAX_FRAME_SIZE: usize = 16 * 1024 * 1024; // 16 MiB cap; protects against malformed length prefix
6
7/// Current version of the variable-length replication wire protocol.
8pub const VAR_PROTOCOL_VERSION: u16 = 2;
9
10/// Message types for the replication protocol.
11#[repr(u8)]
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum MessageType {
14    SyncRequest = 1,
15    ShardInfo = 2,
16    EntryBatch = 3,
17    CaughtUp = 4,
18    Ack = 5,
19    Heartbeat = 6,
20    Error = 255,
21}
22
23impl MessageType {
24    fn from_u8(v: u8) -> Option<Self> {
25        match v {
26            1 => Some(Self::SyncRequest),
27            2 => Some(Self::ShardInfo),
28            3 => Some(Self::EntryBatch),
29            4 => Some(Self::CaughtUp),
30            5 => Some(Self::Ack),
31            6 => Some(Self::Heartbeat),
32            255 => Some(Self::Error),
33            _ => None,
34        }
35    }
36}
37
38impl crate::frame_reader::FrameKind for MessageType {
39    const MAX_PAYLOAD: usize = MAX_FRAME_SIZE;
40    fn from_u8(b: u8) -> Option<Self> {
41        // The inherent from_u8 (above) takes precedence over the trait
42        // method — no recursion.
43        MessageType::from_u8(b)
44    }
45}
46
47/// Wire frame: [type:u8][len:u32 LE][payload:len bytes]
48pub type Frame = crate::frame_reader::GenericFrame<MessageType>;
49
50pub fn write_frame(w: &mut impl Write, frame: &Frame) -> io::Result<()> {
51    w.write_all(&[frame.msg_type as u8])?;
52    w.write_all(&(frame.payload.len() as u32).to_le_bytes())?;
53    w.write_all(&frame.payload)?;
54    w.flush()
55}
56
57pub fn read_frame(r: &mut impl Read) -> io::Result<Frame> {
58    let mut type_buf = [0u8; 1];
59    r.read_exact(&mut type_buf)?;
60    let msg_type = MessageType::from_u8(type_buf[0]).ok_or_else(|| {
61        io::Error::new(
62            io::ErrorKind::InvalidData,
63            format!("unknown message type: {}", type_buf[0]),
64        )
65    })?;
66    let mut len_buf = [0u8; 4];
67    r.read_exact(&mut len_buf)?;
68    let len = u32::from_le_bytes(len_buf) as usize;
69    if len > MAX_FRAME_SIZE {
70        return Err(io::Error::new(
71            io::ErrorKind::InvalidData,
72            "frame too large",
73        ));
74    }
75    let mut payload = vec![0u8; len];
76    if len > 0 {
77        r.read_exact(&mut payload)?;
78    }
79    Ok(Frame { msg_type, payload })
80}
81
82// --- SyncRequest: version:u16, shard_id:u8, from_gsn:u64, key_len:u16 ---
83// Payload layout: version (2 LE) + shard_id (1) + from_gsn (8 LE) + key_len (2 LE) = 13 bytes.
84// One Engine = one Tree, so a single key_len suffices (C7/C20 cleanup).
85
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct SyncRequest {
88    pub protocol_version: u16,
89    pub shard_id: u8,
90    pub from_gsn: u64,
91    pub key_len: u16,
92}
93
94impl SyncRequest {
95    pub fn encode(&self) -> Frame {
96        let mut payload = Vec::with_capacity(13);
97        payload.extend_from_slice(&self.protocol_version.to_le_bytes());
98        payload.push(self.shard_id);
99        payload.extend_from_slice(&self.from_gsn.to_le_bytes());
100        payload.extend_from_slice(&self.key_len.to_le_bytes());
101        Frame {
102            msg_type: MessageType::SyncRequest,
103            payload,
104        }
105    }
106
107    pub fn decode(payload: &[u8]) -> io::Result<Self> {
108        if payload.len() != 13 {
109            return Err(io::Error::new(
110                io::ErrorKind::InvalidData,
111                "invalid SyncRequest length",
112            ));
113        }
114        let protocol_version =
115            u16::from_le_bytes(payload[0..2].try_into().expect("validated length"));
116        let shard_id = payload[2];
117        let from_gsn = u64::from_le_bytes(payload[3..11].try_into().expect("validated length"));
118        let key_len = u16::from_le_bytes(payload[11..13].try_into().expect("validated length"));
119        Ok(Self {
120            protocol_version,
121            shard_id,
122            from_gsn,
123            key_len,
124        })
125    }
126}
127
128// --- ShardInfo: version:u16, shard_count:u8, max_file_size:u64 ---
129
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct ShardInfo {
132    pub protocol_version: u16,
133    pub shard_count: u8,
134    pub max_file_size: u64,
135}
136
137impl ShardInfo {
138    pub fn encode(&self) -> Frame {
139        let mut payload = Vec::with_capacity(11);
140        payload.extend_from_slice(&self.protocol_version.to_le_bytes());
141        payload.push(self.shard_count);
142        payload.extend_from_slice(&self.max_file_size.to_le_bytes());
143        Frame {
144            msg_type: MessageType::ShardInfo,
145            payload,
146        }
147    }
148
149    pub fn decode(payload: &[u8]) -> io::Result<Self> {
150        if payload.len() != 11 {
151            return Err(io::Error::new(
152                io::ErrorKind::InvalidData,
153                "invalid ShardInfo length",
154            ));
155        }
156        Ok(Self {
157            protocol_version: u16::from_le_bytes(
158                payload[0..2].try_into().expect("validated length"),
159            ),
160            shard_count: payload[2],
161            max_file_size: u64::from_le_bytes(payload[3..11].try_into().expect("validated length")),
162        })
163    }
164}
165
166// --- EntryBatch: shard_id:u8, count:u32, entries:[entry_len:u32 + key_len:u16 + gsn:u64 + data] ---
167
168pub struct WireEntry {
169    pub entry_len: u32,
170    pub key_len: u16,
171    pub gsn: u64,
172    pub data: Vec<u8>,
173}
174
175pub struct EntryBatch {
176    pub shard_id: u8,
177    pub entries: Vec<WireEntry>,
178}
179
180impl EntryBatch {
181    pub fn encode(&self) -> Frame {
182        let mut payload = Vec::with_capacity(5 + self.entries.len() * 64);
183        payload.push(self.shard_id);
184        payload.extend_from_slice(&(self.entries.len() as u32).to_le_bytes());
185        for e in &self.entries {
186            payload.extend_from_slice(&e.entry_len.to_le_bytes());
187            payload.extend_from_slice(&e.key_len.to_le_bytes());
188            payload.extend_from_slice(&e.gsn.to_le_bytes());
189            payload.extend_from_slice(&e.data);
190        }
191        Frame {
192            msg_type: MessageType::EntryBatch,
193            payload,
194        }
195    }
196
197    pub fn decode(payload: &[u8]) -> io::Result<Self> {
198        if payload.len() < 5 {
199            return Err(io::Error::new(
200                io::ErrorKind::InvalidData,
201                "EntryBatch too short",
202            ));
203        }
204        let shard_id = payload[0];
205        let count = u32::from_le_bytes(payload[1..5].try_into().expect("impossible")) as usize;
206        let mut entries = Vec::with_capacity(count);
207        let mut off = 5;
208        for _ in 0..count {
209            if off + 14 > payload.len() {
210                return Err(io::Error::new(
211                    io::ErrorKind::InvalidData,
212                    "EntryBatch truncated",
213                ));
214            }
215            let entry_len =
216                u32::from_le_bytes(payload[off..off + 4].try_into().expect("impossible"));
217            let key_len =
218                u16::from_le_bytes(payload[off + 4..off + 6].try_into().expect("impossible"));
219            let gsn =
220                u64::from_le_bytes(payload[off + 6..off + 14].try_into().expect("impossible"));
221            off += 14;
222            if off + entry_len as usize > payload.len() {
223                return Err(io::Error::new(
224                    io::ErrorKind::InvalidData,
225                    "EntryBatch data truncated",
226                ));
227            }
228            let data = payload[off..off + entry_len as usize].to_vec();
229            off += entry_len as usize;
230            entries.push(WireEntry {
231                entry_len,
232                key_len,
233                gsn,
234                data,
235            });
236        }
237        Ok(Self { shard_id, entries })
238    }
239}
240
241// --- CaughtUp: shard_id:u8, leader_gsn:u64 ---
242
243pub struct CaughtUp {
244    pub shard_id: u8,
245    pub leader_gsn: u64,
246}
247
248impl CaughtUp {
249    pub fn encode(&self) -> Frame {
250        let mut payload = Vec::with_capacity(9);
251        payload.push(self.shard_id);
252        payload.extend_from_slice(&self.leader_gsn.to_le_bytes());
253        Frame {
254            msg_type: MessageType::CaughtUp,
255            payload,
256        }
257    }
258
259    pub fn decode(payload: &[u8]) -> io::Result<Self> {
260        if payload.len() < 9 {
261            return Err(io::Error::new(
262                io::ErrorKind::InvalidData,
263                "CaughtUp too short",
264            ));
265        }
266        Ok(Self {
267            shard_id: payload[0],
268            leader_gsn: u64::from_le_bytes(payload[1..9].try_into().expect("impossible")),
269        })
270    }
271}
272
273// --- Ack: shard_id:u8, last_gsn:u64 ---
274
275pub struct AckMessage {
276    pub shard_id: u8,
277    pub last_gsn: u64,
278}
279
280impl AckMessage {
281    pub fn encode(&self) -> Frame {
282        let mut payload = Vec::with_capacity(9);
283        payload.push(self.shard_id);
284        payload.extend_from_slice(&self.last_gsn.to_le_bytes());
285        Frame {
286            msg_type: MessageType::Ack,
287            payload,
288        }
289    }
290
291    pub fn decode(payload: &[u8]) -> io::Result<Self> {
292        if payload.len() < 9 {
293            return Err(io::Error::new(io::ErrorKind::InvalidData, "Ack too short"));
294        }
295        Ok(Self {
296            shard_id: payload[0],
297            last_gsn: u64::from_le_bytes(payload[1..9].try_into().expect("impossible")),
298        })
299    }
300}
301
302// --- Heartbeat ---
303
304pub fn encode_heartbeat() -> Frame {
305    Frame {
306        msg_type: MessageType::Heartbeat,
307        payload: Vec::new(),
308    }
309}
310
311// --- Error ---
312
313/// Stable error categories carried by variable-replication `Error` frames.
314#[repr(u8)]
315#[derive(Debug, Clone, Copy, PartialEq, Eq)]
316pub enum ReplicationErrorCode {
317    /// A transient I/O failure for which the follower may reconnect and retry.
318    RetryableIo = 1,
319    /// A configured resource limit prevented the requested catch-up operation.
320    ResourceLimit = 2,
321    /// The leader detected corrupted replication log data.
322    CorruptedLog = 3,
323    /// Leader and follower use incompatible variable-replication protocol versions.
324    ProtocolMismatch = 4,
325    /// The peer sent a request that violates the replication protocol.
326    InvalidRequest = 5,
327}
328
329impl ReplicationErrorCode {
330    fn from_u8(value: u8) -> Option<Self> {
331        match value {
332            1 => Some(Self::RetryableIo),
333            2 => Some(Self::ResourceLimit),
334            3 => Some(Self::CorruptedLog),
335            4 => Some(Self::ProtocolMismatch),
336            5 => Some(Self::InvalidRequest),
337            _ => None,
338        }
339    }
340}
341
342/// A validated typed error decoded from a variable-replication `Error` frame.
343#[derive(Debug, Clone, PartialEq, Eq)]
344pub struct ReplicationWireError {
345    /// Machine-readable error category used for retry classification.
346    pub code: ReplicationErrorCode,
347    /// Human-readable diagnostic detail supplied by the peer.
348    pub message: String,
349}
350
351/// Encodes a typed variable-replication error frame.
352///
353/// Messages longer than `u16::MAX` bytes are truncated at a valid UTF-8 boundary.
354pub fn encode_error(code: ReplicationErrorCode, message: &str) -> Frame {
355    let max = message.len().min(u16::MAX as usize);
356    let end = message.floor_char_boundary(max);
357    let message = &message.as_bytes()[..end];
358    let mut payload = Vec::with_capacity(3 + message.len());
359    payload.push(code as u8);
360    payload.extend_from_slice(&(message.len() as u16).to_le_bytes());
361    payload.extend_from_slice(message);
362    Frame {
363        msg_type: MessageType::Error,
364        payload,
365    }
366}
367
368/// Decodes and validates a typed variable-replication error payload.
369///
370/// # Errors
371///
372/// Returns [`io::ErrorKind::InvalidData`] for unknown codes, short or trailing
373/// payload bytes, a truncated declared message, or invalid UTF-8.
374pub fn decode_error(payload: &[u8]) -> io::Result<ReplicationWireError> {
375    if payload.len() < 3 {
376        return Err(io::Error::new(
377            io::ErrorKind::InvalidData,
378            "replication error payload too short",
379        ));
380    }
381
382    let code = ReplicationErrorCode::from_u8(payload[0]).ok_or_else(|| {
383        io::Error::new(
384            io::ErrorKind::InvalidData,
385            format!("unknown replication error code: {}", payload[0]),
386        )
387    })?;
388    let message_len = u16::from_le_bytes([payload[1], payload[2]]) as usize;
389    let expected_len = 3 + message_len;
390    if payload.len() != expected_len {
391        return Err(io::Error::new(
392            io::ErrorKind::InvalidData,
393            "invalid replication error payload length",
394        ));
395    }
396    let message = std::str::from_utf8(&payload[3..])
397        .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?
398        .to_owned();
399
400    Ok(ReplicationWireError { code, message })
401}
402
403#[cfg(test)]
404mod max_frame_size_tests {
405    use super::*;
406    use std::io::Cursor;
407
408    #[test]
409    fn read_frame_rejects_oversized_length() {
410        // Manually construct a frame header: type byte + 4-byte LE length too big
411        let mut bytes = Vec::new();
412        bytes.push(MessageType::SyncRequest as u8);
413        bytes.extend_from_slice(&((MAX_FRAME_SIZE + 1) as u32).to_le_bytes());
414        let mut cursor = Cursor::new(bytes);
415        let err = read_frame(&mut cursor).unwrap_err();
416        let msg = format!("{err}");
417        assert!(msg.contains("frame too large"), "got: {msg}");
418    }
419
420    #[test]
421    fn frame_reader_rejects_garbage_type_byte_immediately() {
422        // R3: a single garbage byte, then silence. Early validation must
423        // yield InvalidData immediately — the client's StreamFailure maps it
424        // to Terminal, and degrading into a retryable timeout is not
425        // acceptable.
426        let mut fr = crate::frame_reader::FrameReader::<MessageType>::new();
427        let err = fr.read_frame(&mut Cursor::new([0u8])).unwrap_err();
428        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
429        assert!(err.to_string().contains("unknown message type"));
430    }
431
432    #[test]
433    fn frame_alias_keeps_debug_contract() {
434        // R5: Frame is now an alias of GenericFrame<MessageType>; Debug must
435        // survive for downstream code using format!("{frame:?}").
436        let frame = Frame {
437            msg_type: MessageType::Heartbeat,
438            payload: Vec::new(),
439        };
440        assert!(format!("{frame:?}").contains("Heartbeat"));
441    }
442}
443
444#[cfg(test)]
445mod sync_request_tests {
446    use super::*;
447
448    #[test]
449    fn sync_request_v2_round_trip() {
450        let req = SyncRequest {
451            protocol_version: VAR_PROTOCOL_VERSION,
452            shard_id: 7,
453            from_gsn: 0xDEAD_BEEF_CAFE_BABE,
454            key_len: 32,
455        };
456        let frame = req.encode();
457        assert_eq!(frame.payload.len(), 13);
458        assert_eq!(SyncRequest::decode(&frame.payload).unwrap(), req);
459    }
460
461    #[test]
462    fn shard_info_v2_round_trip() {
463        let info = ShardInfo {
464            protocol_version: VAR_PROTOCOL_VERSION,
465            shard_count: 4,
466            max_file_size: 64 * 1024 * 1024,
467        };
468        let frame = info.encode();
469        assert_eq!(frame.payload.len(), 11);
470        assert_eq!(ShardInfo::decode(&frame.payload).unwrap(), info);
471    }
472
473    #[test]
474    fn typed_error_round_trips_every_code() {
475        for code in [
476            ReplicationErrorCode::RetryableIo,
477            ReplicationErrorCode::ResourceLimit,
478            ReplicationErrorCode::CorruptedLog,
479            ReplicationErrorCode::ProtocolMismatch,
480            ReplicationErrorCode::InvalidRequest,
481        ] {
482            let frame = encode_error(code, "detail");
483            assert_eq!(
484                decode_error(&frame.payload).unwrap(),
485                ReplicationWireError {
486                    code,
487                    message: "detail".to_owned(),
488                }
489            );
490        }
491    }
492
493    #[test]
494    fn typed_error_rejects_trailing_and_invalid_utf8() {
495        let mut trailing = encode_error(ReplicationErrorCode::InvalidRequest, "x").payload;
496        trailing.push(0);
497        assert_eq!(
498            decode_error(&trailing).unwrap_err().kind(),
499            io::ErrorKind::InvalidData
500        );
501
502        let invalid = [ReplicationErrorCode::InvalidRequest as u8, 1, 0, 0xFF];
503        assert_eq!(
504            decode_error(&invalid).unwrap_err().kind(),
505            io::ErrorKind::InvalidData
506        );
507    }
508
509    #[test]
510    fn typed_error_truncates_on_utf8_boundary() {
511        let message = format!("{}€", "a".repeat(u16::MAX as usize - 1));
512        let decoded =
513            decode_error(&encode_error(ReplicationErrorCode::RetryableIo, &message).payload)
514                .unwrap();
515        assert!(decoded.message.is_char_boundary(decoded.message.len()));
516        assert_eq!(decoded.message.len(), u16::MAX as usize - 1);
517    }
518}