1use crate::Op;
20use crc32fast::Hasher as Crc32;
21use rushdb_types::command::CommitBatch;
22use std::io;
23
24const WAL_MAGIC: [u8; 8] = *b"RDBWAL01";
26
27pub const WAL_FORMAT_VERSION: u32 = 2;
29
30const WAL_HEADER_LEN: usize = 8 + 4 + 4 + 8 + 4 + 4;
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub struct WalHeader {
36 pub shard_id: u32,
38 pub lsn: u64,
40 pub payload_len: u32,
42}
43
44#[derive(Debug, thiserror::Error)]
46pub enum WalDecodeError {
47 #[error("short WAL record: expected {expected} bytes, got {actual}")]
49 Truncated {
50 expected: usize,
52 actual: usize,
54 },
55 #[error("invalid WAL magic")]
57 BadMagic,
58 #[error("unsupported WAL format version {0}")]
60 UnsupportedVersion(u32),
61 #[error("WAL record checksum mismatch")]
63 ChecksumMismatch,
64 #[error("payload length {0} exceeds the 64 MiB record ceiling")]
66 OversizedPayload(u32),
67}
68
69#[must_use]
75pub fn encode_wal_record(shard_id: u32, lsn: u64, payload: &[u8]) -> Vec<u8> {
76 let payload_len = u32::try_from(payload.len()).expect("payload length fits u32");
77 let mut out = Vec::with_capacity(WAL_HEADER_LEN + payload.len());
78 out.extend_from_slice(&WAL_MAGIC);
79 out.extend_from_slice(&WAL_FORMAT_VERSION.to_le_bytes());
80 out.extend_from_slice(&shard_id.to_le_bytes());
81 out.extend_from_slice(&lsn.to_le_bytes());
82 out.extend_from_slice(&payload_len.to_le_bytes());
83 let mut hasher = Crc32::new();
87 hasher.update(&WAL_MAGIC);
88 hasher.update(&WAL_FORMAT_VERSION.to_le_bytes());
89 hasher.update(&shard_id.to_le_bytes());
90 hasher.update(&lsn.to_le_bytes());
91 hasher.update(&payload_len.to_le_bytes());
92 hasher.update(payload);
93 out.extend_from_slice(&hasher.finalize().to_le_bytes());
94 out.extend_from_slice(payload);
95 out
96}
97
98pub fn decode_wal_record(raw: &[u8]) -> Result<(WalHeader, &[u8]), WalDecodeError> {
108 if raw.len() < WAL_HEADER_LEN {
109 return Err(WalDecodeError::Truncated {
110 expected: WAL_HEADER_LEN,
111 actual: raw.len(),
112 });
113 }
114 if raw[0..8] != WAL_MAGIC {
115 return Err(WalDecodeError::BadMagic);
116 }
117 let version = u32::from_le_bytes(raw[8..12].try_into().expect("slice"));
118 if version != WAL_FORMAT_VERSION {
119 return Err(WalDecodeError::UnsupportedVersion(version));
120 }
121 let shard_id = u32::from_le_bytes(raw[12..16].try_into().expect("slice"));
122 let lsn = u64::from_le_bytes(raw[16..24].try_into().expect("slice"));
123 let payload_len = u32::from_le_bytes(raw[24..28].try_into().expect("slice"));
124 if payload_len > 64 * 1024 * 1024 {
125 return Err(WalDecodeError::OversizedPayload(payload_len));
126 }
127 let expected = WAL_HEADER_LEN + payload_len as usize;
128 if raw.len() < expected {
129 return Err(WalDecodeError::Truncated {
130 expected,
131 actual: raw.len(),
132 });
133 }
134 let stored_crc = u32::from_le_bytes(raw[28..32].try_into().expect("slice"));
135 let mut hasher = Crc32::new();
136 hasher.update(&raw[0..28]);
137 hasher.update(&raw[WAL_HEADER_LEN..expected]);
138 let computed = hasher.finalize();
139 if stored_crc != computed {
140 return Err(WalDecodeError::ChecksumMismatch);
141 }
142 Ok((
143 WalHeader {
144 shard_id,
145 lsn,
146 payload_len,
147 },
148 &raw[WAL_HEADER_LEN..expected],
149 ))
150}
151
152#[must_use]
155pub fn now_ms() -> u64 {
156 std::time::SystemTime::now()
157 .duration_since(std::time::UNIX_EPOCH)
158 .map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
159}
160
161#[must_use]
164pub fn encode_last_applied(shard_id: u32, lsn: u64) -> Vec<u8> {
165 encode_wal_record(shard_id, lsn, &[])
166}
167
168pub fn decode_last_applied(raw: &[u8]) -> Result<u64, String> {
174 let (header, _) = decode_wal_record(raw).map_err(|e| e.to_string())?;
175 Ok(header.lsn)
176}
177
178#[must_use]
187pub fn encode_op_set(ops: &[Op]) -> Vec<u8> {
188 let mut out = Vec::with_capacity(op_set_encoded_len(ops));
189 write_op_set(&mut out, ops);
190 out
191}
192
193fn write_op_set(out: &mut Vec<u8>, ops: &[Op]) {
195 let count = u32::try_from(ops.len()).expect("op count fits u32");
196 out.extend_from_slice(&count.to_le_bytes());
197 for op in ops {
198 encode_one_op(out, op);
199 }
200}
201
202#[must_use]
207pub fn op_set_encoded_len(ops: &[Op]) -> usize {
208 let mut len = 4; for op in ops {
210 len += 1; len += match op {
212 Op::Put(key, value) | Op::Set(key, value) => 4 + key.len() + 4 + value.len(),
213 Op::Del(key) | Op::Purge(key) => 4 + key.len(),
214 };
215 }
216 len
217}
218
219fn put_len_prefixed(out: &mut Vec<u8>, bytes: &[u8]) {
220 let len = u32::try_from(bytes.len()).expect("field length fits u32");
221 out.extend_from_slice(&len.to_le_bytes());
222 out.extend_from_slice(bytes);
223}
224
225fn take_u32(cur: &mut &[u8]) -> Result<u32, String> {
226 if cur.len() < 4 {
227 return Err("truncated op-set length".into());
228 }
229 let (head, rest) = cur.split_at(4);
230 *cur = rest;
231 Ok(u32::from_le_bytes(head.try_into().expect("4-byte slice")))
232}
233
234fn take_bytes(cur: &mut &[u8]) -> Result<Vec<u8>, String> {
235 let len = take_u32(cur)? as usize;
236 if cur.len() < len {
237 return Err("truncated op-set field".into());
238 }
239 let (head, rest) = cur.split_at(len);
240 *cur = rest;
241 Ok(head.to_vec())
242}
243
244fn encode_one_op(out: &mut Vec<u8>, op: &Op) {
247 match op {
248 Op::Put(key, value) => {
249 out.push(0);
250 put_len_prefixed(out, key);
251 put_len_prefixed(out, value);
252 }
253 Op::Del(key) => {
254 out.push(1);
255 put_len_prefixed(out, key);
256 }
257 Op::Set(key, value) => {
258 out.push(2);
259 put_len_prefixed(out, key);
260 put_len_prefixed(out, value);
261 }
262 Op::Purge(key) => {
263 out.push(3);
264 put_len_prefixed(out, key);
265 }
266 }
267}
268
269fn decode_one_op(cur: &mut &[u8]) -> Result<Op, String> {
272 let tag = *cur.first().ok_or("truncated op tag")?;
273 *cur = &cur[1..];
274 let key = take_bytes(cur)?;
275 Ok(match tag {
276 0 => Op::Put(key, take_bytes(cur)?),
277 1 => Op::Del(key),
278 2 => Op::Set(key, take_bytes(cur)?),
279 3 => Op::Purge(key),
280 other => return Err(format!("unknown op tag {other}")),
281 })
282}
283
284pub fn decode_op_set(raw: &[u8]) -> Result<Vec<Op>, String> {
290 let mut cur = raw;
291 let count = take_u32(&mut cur)? as usize;
292 let mut ops = Vec::with_capacity(count.min(1024));
293 for _ in 0..count {
294 ops.push(decode_one_op(&mut cur)?);
295 }
296 if !cur.is_empty() {
297 return Err("trailing bytes in op set".into());
298 }
299 Ok(ops)
300}
301
302const OPS_RECORD_KIND_BARE: u8 = 0;
309const OPS_RECORD_KIND_WITH_COMMAND: u8 = 1;
310const OPS_RECORD_KIND_CHAIN_CONTINUE: u8 = 2;
319const OPS_RECORD_KIND_CHAIN_FINAL: u8 = 3;
323
324#[must_use]
336pub fn encode_ops_record(
337 shard_id: u32,
338 lsn: u64,
339 ops: &[Op],
340 command_payload: Option<&[u8]>,
341) -> Vec<u8> {
342 let op_set_len = op_set_encoded_len(ops);
346 let body_len = 1 + command_payload.map_or(op_set_len, |command| 4 + op_set_len + command.len());
347 let payload_len = u32::try_from(body_len).expect("payload length fits u32");
348 let mut out = Vec::with_capacity(WAL_HEADER_LEN + body_len);
349 out.extend_from_slice(&WAL_MAGIC);
350 out.extend_from_slice(&WAL_FORMAT_VERSION.to_le_bytes());
351 out.extend_from_slice(&shard_id.to_le_bytes());
352 out.extend_from_slice(&lsn.to_le_bytes());
353 out.extend_from_slice(&payload_len.to_le_bytes());
354 out.extend_from_slice(&[0; 4]); if let Some(command) = command_payload {
356 out.push(OPS_RECORD_KIND_WITH_COMMAND);
357 out.extend_from_slice(
358 &u32::try_from(op_set_len)
359 .expect("op-set fits u32")
360 .to_le_bytes(),
361 );
362 write_op_set(&mut out, ops);
363 out.extend_from_slice(command);
364 } else {
365 out.push(OPS_RECORD_KIND_BARE);
366 write_op_set(&mut out, ops);
367 }
368 debug_assert_eq!(out.len(), WAL_HEADER_LEN + body_len);
369 let crc_at = WAL_HEADER_LEN - 4;
370 let mut hasher = Crc32::new();
371 hasher.update(&out[..crc_at]);
372 hasher.update(&out[WAL_HEADER_LEN..]);
373 out[crc_at..WAL_HEADER_LEN].copy_from_slice(&hasher.finalize().to_le_bytes());
374 out
375}
376
377pub fn decode_ops_record(raw: &[u8]) -> Result<(WalHeader, Vec<Op>, Option<CommitBatch>), String> {
390 let (header, payload) = decode_wal_record(raw).map_err(|e| e.to_string())?;
391 let (&kind, rest) = payload.split_first().ok_or("empty WAL record payload")?;
392 match kind {
393 OPS_RECORD_KIND_BARE => Ok((header, decode_op_set(rest)?, None)),
394 OPS_RECORD_KIND_WITH_COMMAND => {
395 if rest.len() < 4 {
396 return Err("truncated combined WAL record".into());
397 }
398 let op_set_len =
399 u32::from_le_bytes(rest[0..4].try_into().expect("4-byte slice")) as usize;
400 if rest.len() < 4 + op_set_len {
401 return Err("truncated combined WAL record op-set".into());
402 }
403 let ops = decode_op_set(&rest[4..4 + op_set_len])?;
404 let batch: CommitBatch = rushdb_types::command_codec::decode(&rest[4 + op_set_len..])
405 .map_err(|e| format!("invalid commit batch: {e}"))?;
406 Ok((header, ops, Some(batch)))
407 }
408 other => Err(format!("unknown WAL record kind {other}")),
409 }
410}
411
412#[must_use]
431pub fn encode_chained_ops_record(
432 shard_id: u32,
433 lsn: u64,
434 ops: &[Op],
435 command_payload: Option<&[u8]>,
436 max_record_bytes: usize,
437) -> Vec<Vec<u8>> {
438 let whole_len = WAL_HEADER_LEN
439 + 1
440 + command_payload.map_or(op_set_encoded_len(ops), |command| {
441 4 + op_set_encoded_len(ops) + command.len()
442 });
443 if whole_len <= max_record_bytes {
444 return vec![encode_ops_record(shard_id, lsn, ops, command_payload)];
445 }
446
447 let op_set = encode_op_set(ops);
452 let op_set_len = u32::try_from(op_set.len()).expect("op-set fits u32");
453 let mut logical_body =
454 Vec::with_capacity(4 + op_set.len() + command_payload.map_or(0, <[u8]>::len));
455 logical_body.extend_from_slice(&op_set_len.to_le_bytes());
456 logical_body.extend_from_slice(&op_set);
457 if let Some(command) = command_payload {
458 logical_body.extend_from_slice(command);
459 }
460
461 let chunk_budget = max_record_bytes
462 .checked_sub(WAL_HEADER_LEN + 1)
463 .expect("max_record_bytes covers at least the frame header and kind byte");
464 assert!(
465 chunk_budget > 0,
466 "max_record_bytes ({max_record_bytes}) leaves no room for any payload"
467 );
468
469 let chunks: Vec<&[u8]> = logical_body.chunks(chunk_budget).collect();
470 let last_index = chunks.len().saturating_sub(1);
471 chunks
472 .into_iter()
473 .enumerate()
474 .map(|(i, chunk)| {
475 let kind = if i == last_index {
476 OPS_RECORD_KIND_CHAIN_FINAL
477 } else {
478 OPS_RECORD_KIND_CHAIN_CONTINUE
479 };
480 let mut body = Vec::with_capacity(1 + chunk.len());
481 body.push(kind);
482 body.extend_from_slice(chunk);
483 encode_wal_record(shard_id, lsn, &body)
484 })
485 .collect()
486}
487
488#[derive(Default)]
497pub struct ChainAssembler {
498 open: Option<OpenChain>,
499}
500
501struct OpenChain {
502 lsn: u64,
503 shard_id: u32,
504 start_offset: u64,
509 fragments: Vec<u8>,
512}
513
514pub enum ChainFeed {
516 Complete(WalHeader, Vec<u8>),
528 Pending,
530}
531
532pub fn decode_logical_body(
546 header: WalHeader,
547 body: &[u8],
548) -> Result<(WalHeader, Vec<Op>, Option<CommitBatch>), String> {
549 if body.len() < 4 {
550 return Err("truncated reassembled chain body".into());
551 }
552 let op_set_len = u32::from_le_bytes(body[0..4].try_into().expect("4-byte slice")) as usize;
553 if body.len() < 4 + op_set_len {
554 return Err("truncated reassembled chain op-set".into());
555 }
556 let ops = decode_op_set(&body[4..4 + op_set_len])?;
557 let command_bytes = &body[4 + op_set_len..];
558 let batch = if command_bytes.is_empty() {
559 None
560 } else {
561 Some(
562 rushdb_types::command_codec::decode(command_bytes)
563 .map_err(|e| format!("invalid commit batch: {e}"))?,
564 )
565 };
566 Ok((header, ops, batch))
567}
568
569impl ChainAssembler {
570 pub fn feed(&mut self, raw: &[u8], offset: u64) -> Result<ChainFeed, String> {
584 let (header, payload) = decode_wal_record(raw).map_err(|e| e.to_string())?;
585 let (&kind, rest) = payload.split_first().ok_or("empty WAL record payload")?;
586 match kind {
587 OPS_RECORD_KIND_BARE => {
588 if self.open.is_some() {
589 return Err(format!(
590 "non-chained record at lsn {} arrived while a chain was open",
591 header.lsn
592 ));
593 }
594 let op_set_len =
599 u32::try_from(rest.len()).map_err(|_| "op-set too large".to_string())?;
600 let mut body = Vec::with_capacity(4 + rest.len());
601 body.extend_from_slice(&op_set_len.to_le_bytes());
602 body.extend_from_slice(rest);
603 Ok(ChainFeed::Complete(header, body))
604 }
605 OPS_RECORD_KIND_WITH_COMMAND => {
606 if self.open.is_some() {
607 return Err(format!(
608 "non-chained record at lsn {} arrived while a chain was open",
609 header.lsn
610 ));
611 }
612 Ok(ChainFeed::Complete(header, rest.to_vec()))
614 }
615 OPS_RECORD_KIND_CHAIN_CONTINUE => {
616 match &mut self.open {
617 Some(open) if open.lsn == header.lsn && open.shard_id == header.shard_id => {
618 open.fragments.extend_from_slice(rest);
619 }
620 Some(open) => {
621 return Err(format!(
622 "chain continuation at (lsn {}, shard {}) arrived while (lsn {}, shard {}) was open",
623 header.lsn, header.shard_id, open.lsn, open.shard_id
624 ));
625 }
626 None => {
627 self.open = Some(OpenChain {
628 lsn: header.lsn,
629 shard_id: header.shard_id,
630 start_offset: offset,
631 fragments: rest.to_vec(),
632 });
633 }
634 }
635 Ok(ChainFeed::Pending)
636 }
637 OPS_RECORD_KIND_CHAIN_FINAL => {
638 if let Some(open) = &self.open {
639 if open.lsn != header.lsn || open.shard_id != header.shard_id {
640 return Err(format!(
641 "chain final at (lsn {}, shard {}) arrived while (lsn {}, shard {}) was open",
642 header.lsn, header.shard_id, open.lsn, open.shard_id
643 ));
644 }
645 }
646 let mut logical_body = self.open.take().map_or_else(Vec::new, |o| o.fragments);
647 logical_body.extend_from_slice(rest);
648 Ok(ChainFeed::Complete(header, logical_body))
649 }
650 other => Err(format!("unknown WAL record kind {other}")),
651 }
652 }
653
654 #[must_use]
656 pub fn is_open(&self) -> bool {
657 self.open.is_some()
658 }
659
660 #[must_use]
662 pub fn open_lsn(&self) -> Option<u64> {
663 self.open.as_ref().map(|o| o.lsn)
664 }
665
666 #[must_use]
671 pub fn open_start_offset(&self) -> Option<u64> {
672 self.open.as_ref().map(|o| o.start_offset)
673 }
674}
675
676#[derive(Clone, Debug, PartialEq, Eq)]
680pub struct Checkpoint {
681 pub shard_id: u32,
683 pub durable_lsn: u64,
685 pub gc_watermark: u64,
687 pub published_at_ms: u64,
689}
690
691#[must_use]
694pub fn encode_checkpoint(checkpoint: &Checkpoint) -> Vec<u8> {
695 let mut body = Vec::with_capacity(16);
696 body.extend_from_slice(&checkpoint.gc_watermark.to_le_bytes());
697 body.extend_from_slice(&checkpoint.published_at_ms.to_le_bytes());
698 encode_wal_record(checkpoint.shard_id, checkpoint.durable_lsn, &body)
699}
700
701pub fn decode_checkpoint(raw: &[u8]) -> Result<Checkpoint, String> {
707 let (header, body) = decode_wal_record(raw).map_err(|e| e.to_string())?;
708 if body.len() != 16 {
709 return Err(format!("invalid checkpoint body length: {}", body.len()));
710 }
711 let mut gc = [0u8; 8];
712 let mut ts = [0u8; 8];
713 gc.copy_from_slice(&body[0..8]);
714 ts.copy_from_slice(&body[8..16]);
715 Ok(Checkpoint {
716 shard_id: header.shard_id,
717 durable_lsn: header.lsn,
718 gc_watermark: u64::from_le_bytes(gc),
719 published_at_ms: u64::from_le_bytes(ts),
720 })
721}
722
723pub fn write_framed_chunk<W: io::Write>(writer: &mut W, bytes: &[u8]) -> io::Result<()> {
731 let mut hasher = Crc32::new();
732 hasher.update(bytes);
733 let len = u32::try_from(bytes.len()).map_err(|_| {
734 io::Error::new(
735 io::ErrorKind::InvalidInput,
736 "framed chunk exceeds u32 length",
737 )
738 })?;
739 writer.write_all(&len.to_le_bytes())?;
740 writer.write_all(&hasher.finalize().to_le_bytes())?;
741 writer.write_all(bytes)
742}
743
744pub fn read_framed_chunk<R: io::Read>(reader: &mut R) -> io::Result<Option<Vec<u8>>> {
751 let mut len_buf = [0u8; 4];
752 match reader.read_exact(&mut len_buf) {
753 Ok(()) => {}
754 Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
755 Err(e) => return Err(e),
756 }
757 let len = u32::from_le_bytes(len_buf) as usize;
758 if len > 64 * 1024 * 1024 {
759 return Err(io::Error::new(
760 io::ErrorKind::InvalidData,
761 "framed chunk exceeds 64 MiB",
762 ));
763 }
764 let mut crc_buf = [0u8; 4];
765 reader.read_exact(&mut crc_buf)?;
766 let stored_crc = u32::from_le_bytes(crc_buf);
767 let mut payload = vec![0u8; len];
768 reader.read_exact(&mut payload)?;
769 let mut hasher = Crc32::new();
770 hasher.update(&payload);
771 if hasher.finalize() != stored_crc {
772 return Err(io::Error::new(
773 io::ErrorKind::InvalidData,
774 "framed chunk checksum mismatch",
775 ));
776 }
777 Ok(Some(payload))
778}
779
780#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
783pub struct WalSegmentManifest {
784 pub magic: String,
786 pub format_version: u32,
788 pub shard_id: u32,
790 pub from_lsn: u64,
792 pub to_lsn: u64,
794 pub record_count: u64,
796 pub records_crc: u32,
798}
799
800pub const SEGMENT_MAGIC: &str = "RDBWALSEG";
802
803pub const SNAPSHOT_MAGIC: &str = "RDBWALSNAP";
806
807#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
812pub struct SnapshotManifest {
813 pub magic: String,
815 pub format_version: u32,
817 pub shard_id: u32,
819 pub from_lsn: u64,
821 pub to_lsn: u64,
823 pub tenant_count: u64,
825 pub tenants_crc: u32,
827}
828
829#[derive(Debug, Clone, PartialEq, Eq)]
831pub struct TenantChunk {
832 pub tenant: Vec<u8>,
834 pub jsonl: Vec<u8>,
836}
837
838#[must_use]
845pub fn encode_tenant_chunk(chunk: &TenantChunk) -> Vec<u8> {
846 let mut body = Vec::with_capacity(4 + chunk.tenant.len() + chunk.jsonl.len());
847 let tenant_len = u32::try_from(chunk.tenant.len()).expect("tenant id fits u32");
848 body.extend_from_slice(&tenant_len.to_le_bytes());
849 body.extend_from_slice(&chunk.tenant);
850 body.extend_from_slice(&chunk.jsonl);
851 body
852}
853
854pub fn decode_tenant_chunk(raw: &[u8]) -> Result<TenantChunk, String> {
864 if raw.len() < 4 {
865 return Err(format!("tenant chunk too short: {} bytes", raw.len()));
866 }
867 let tenant_len = u32::from_le_bytes(
868 raw[0..4]
869 .try_into()
870 .map_err(|_| "tenant chunk header is corrupt")?,
871 ) as usize;
872 if 4 + tenant_len > raw.len() {
873 return Err("tenant chunk length overflow".into());
874 }
875 Ok(TenantChunk {
876 tenant: raw[4..4 + tenant_len].to_vec(),
877 jsonl: raw[4 + tenant_len..].to_vec(),
878 })
879}
880
881pub fn write_snapshot<W: io::Write>(
888 writer: &mut W,
889 shard_id: u32,
890 from_lsn: u64,
891 to_lsn: u64,
892 tenants: &[(Vec<u8>, Vec<u8>)],
893) -> io::Result<SnapshotManifest> {
894 let mut hasher = Crc32::new();
895 for (tenant, jsonl) in tenants {
896 let chunk = encode_tenant_chunk(&TenantChunk {
897 tenant: tenant.clone(),
898 jsonl: jsonl.clone(),
899 });
900 hasher.update(&chunk);
901 }
902 let manifest = SnapshotManifest {
903 magic: SNAPSHOT_MAGIC.to_string(),
904 format_version: WAL_FORMAT_VERSION,
905 shard_id,
906 from_lsn,
907 to_lsn,
908 tenant_count: tenants.len() as u64,
909 tenants_crc: hasher.finalize(),
910 };
911 write_framed_chunk(writer, &encode_manifest(&manifest))?;
912 for (tenant, jsonl) in tenants {
913 write_framed_chunk(
914 writer,
915 &encode_tenant_chunk(&TenantChunk {
916 tenant: tenant.clone(),
917 jsonl: jsonl.clone(),
918 }),
919 )?;
920 }
921 Ok(manifest)
922}
923
924pub fn read_snapshot_manifest<R: io::Read>(reader: &mut R) -> io::Result<SnapshotManifest> {
932 let Some(manifest_chunk) = read_framed_chunk(reader)? else {
933 return Err(io::Error::new(
934 io::ErrorKind::UnexpectedEof,
935 "snapshot stream empty: missing manifest",
936 ));
937 };
938 let manifest: SnapshotManifest = serde_json::from_slice(&manifest_chunk).map_err(|e| {
939 io::Error::new(
940 io::ErrorKind::InvalidData,
941 format!("invalid snapshot manifest: {e}"),
942 )
943 })?;
944 if manifest.magic != SNAPSHOT_MAGIC {
945 return Err(io::Error::new(
946 io::ErrorKind::InvalidData,
947 format!(
948 "snapshot magic mismatch: expected {SNAPSHOT_MAGIC}, got {}",
949 manifest.magic
950 ),
951 ));
952 }
953 if manifest.format_version != WAL_FORMAT_VERSION {
954 return Err(io::Error::new(
955 io::ErrorKind::InvalidData,
956 format!(
957 "snapshot format version {} unsupported (expected {WAL_FORMAT_VERSION})",
958 manifest.format_version
959 ),
960 ));
961 }
962 Ok(manifest)
963}
964
965pub fn read_snapshot<R: io::Read>(
975 reader: &mut R,
976) -> io::Result<(SnapshotManifest, Vec<TenantChunk>)> {
977 let manifest = read_snapshot_manifest(reader)?;
978 let tenant_capacity = usize::try_from(manifest.tenant_count).map_err(|_| {
979 io::Error::new(
980 io::ErrorKind::InvalidData,
981 "snapshot tenant_count does not fit in usize",
982 )
983 })?;
984 let mut tenants = Vec::new();
985 tenants
986 .try_reserve_exact(tenant_capacity)
987 .map_err(|error| {
988 io::Error::new(
989 io::ErrorKind::InvalidData,
990 format!("snapshot tenant_count allocation rejected: {error}"),
991 )
992 })?;
993 for _ in 0..manifest.tenant_count {
994 let Some(chunk) = read_framed_chunk(reader)? else {
995 return Err(io::Error::new(
996 io::ErrorKind::UnexpectedEof,
997 format!(
998 "snapshot truncated: {} of {} tenant chunks present",
999 tenants.len(),
1000 manifest.tenant_count
1001 ),
1002 ));
1003 };
1004 tenants.push(
1005 decode_tenant_chunk(&chunk)
1006 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?,
1007 );
1008 }
1009 let mut hasher = Crc32::new();
1011 for chunk in &tenants {
1012 hasher.update(&encode_tenant_chunk(chunk));
1013 }
1014 if hasher.finalize() != manifest.tenants_crc {
1015 return Err(io::Error::new(
1016 io::ErrorKind::InvalidData,
1017 "snapshot tenant CRC mismatch",
1018 ));
1019 }
1020 Ok((manifest, tenants))
1021}
1022
1023pub fn encode_manifest<M: serde::Serialize>(manifest: &M) -> Vec<u8> {
1029 serde_json::to_vec(manifest).expect("serializable manifest")
1030}
1031
1032pub fn write_wal_segment<W: io::Write>(
1039 writer: &mut W,
1040 shard_id: u32,
1041 records: &[(u64, &[u8])],
1042) -> io::Result<WalSegmentManifest> {
1043 let from_lsn = records.first().map_or(0, |(lsn, _)| *lsn);
1044 let to_lsn = records.last().map_or(0, |(lsn, _)| *lsn);
1045 let mut hasher = Crc32::new();
1046 for (_, record) in records {
1047 hasher.update(record);
1048 }
1049 let manifest = WalSegmentManifest {
1050 magic: SEGMENT_MAGIC.to_string(),
1051 format_version: WAL_FORMAT_VERSION,
1052 shard_id,
1053 from_lsn,
1054 to_lsn,
1055 record_count: records.len() as u64,
1056 records_crc: hasher.finalize(),
1057 };
1058 write_framed_chunk(writer, &encode_manifest(&manifest))?;
1059 for (_, record) in records {
1060 write_framed_chunk(writer, record)?;
1061 }
1062 Ok(manifest)
1063}
1064
1065pub fn read_wal_segment<R: io::Read>(
1073 reader: &mut R,
1074) -> io::Result<Option<(WalSegmentManifest, Vec<Vec<u8>>)>> {
1075 let Some(manifest_raw) = read_framed_chunk(reader)? else {
1076 return Ok(None);
1077 };
1078 let manifest: WalSegmentManifest = serde_json::from_slice(&manifest_raw).map_err(|e| {
1079 io::Error::new(
1080 io::ErrorKind::InvalidData,
1081 format!("bad segment manifest: {e}"),
1082 )
1083 })?;
1084 if manifest.magic != SEGMENT_MAGIC {
1085 return Err(io::Error::new(
1086 io::ErrorKind::InvalidData,
1087 "bad segment magic",
1088 ));
1089 }
1090 if manifest.format_version != WAL_FORMAT_VERSION {
1091 return Err(io::Error::new(
1092 io::ErrorKind::InvalidData,
1093 "unsupported segment version",
1094 ));
1095 }
1096 let record_capacity = usize::try_from(manifest.record_count).map_err(|_| {
1097 io::Error::new(
1098 io::ErrorKind::InvalidData,
1099 "segment record_count does not fit in usize",
1100 )
1101 })?;
1102 let mut records = Vec::new();
1103 records
1104 .try_reserve_exact(record_capacity)
1105 .map_err(|error| {
1106 io::Error::new(
1107 io::ErrorKind::InvalidData,
1108 format!("segment record_count allocation rejected: {error}"),
1109 )
1110 })?;
1111 let mut hasher = Crc32::new();
1112 for _ in 0..manifest.record_count {
1113 let Some(record) = read_framed_chunk(reader)? else {
1114 return Err(io::Error::new(
1115 io::ErrorKind::UnexpectedEof,
1116 "segment ended before record_count",
1117 ));
1118 };
1119 hasher.update(&record);
1120 records.push(record);
1121 }
1122 if hasher.finalize() != manifest.records_crc {
1123 return Err(io::Error::new(
1124 io::ErrorKind::InvalidData,
1125 "segment records CRC mismatch",
1126 ));
1127 }
1128 Ok(Some((manifest, records)))
1129}
1130#[cfg(test)]
1131mod tests {
1132 use super::*;
1133
1134 fn sample_batch() -> CommitBatch {
1135 CommitBatch {
1136 command_version: rushdb_types::command::COMMAND_VERSION,
1137 tenant: rushdb_types::ids::TenantId::single(),
1138 transaction_id: "t".into(),
1139 commit_key: uuid::Uuid::now_v7(),
1140 start_index: 0,
1141 isolation: rushdb_types::command::IsolationMode::Snapshot,
1142 preconditions: vec![],
1143 mutations: vec![],
1144 }
1145 }
1146
1147 fn assemble_all(records: &[Vec<u8>]) -> (WalHeader, Vec<Op>, Option<CommitBatch>) {
1151 let mut assembler = ChainAssembler::default();
1152 let mut result = None;
1153 for (i, record) in records.iter().enumerate() {
1154 match assembler.feed(record, i as u64).unwrap() {
1155 ChainFeed::Pending => {}
1156 ChainFeed::Complete(header, body) => {
1157 assert!(result.is_none(), "chain completed more than once");
1158 let (_, ops, batch) = decode_logical_body(header, &body).unwrap();
1159 result = Some((header, ops, batch));
1160 }
1161 }
1162 }
1163 result.expect("chain never completed")
1164 }
1165
1166 fn big_value(tag: u8, len: usize) -> Vec<u8> {
1167 vec![tag; len]
1168 }
1169
1170 #[test]
1171 fn wal_record_encodes_once_and_roundtrips() {
1172 let ops = vec![
1173 Op::Put(b"k1".to_vec(), b"v1".to_vec()),
1174 Op::Set(b"k2".to_vec(), vec![7; 300]),
1175 Op::Del(b"k3".to_vec()),
1176 Op::Purge(b"k4".to_vec()),
1177 ];
1178 let command = rushdb_types::command_codec::encode(&sample_batch());
1179 for payload in [None, Some(command.as_slice())] {
1180 let record = encode_ops_record(3, 9, &ops, payload);
1181 assert_eq!(record.capacity(), record.len());
1183 let mut body = Vec::new();
1185 if let Some(command) = payload {
1186 body.push(OPS_RECORD_KIND_WITH_COMMAND);
1187 body.extend_from_slice(
1188 &u32::try_from(encode_op_set(&ops).len())
1189 .unwrap()
1190 .to_le_bytes(),
1191 );
1192 body.extend_from_slice(&encode_op_set(&ops));
1193 body.extend_from_slice(command);
1194 } else {
1195 body.push(OPS_RECORD_KIND_BARE);
1196 body.extend_from_slice(&encode_op_set(&ops));
1197 }
1198 assert_eq!(record, encode_wal_record(3, 9, &body));
1199 let (header, decoded, batch) = decode_ops_record(&record).unwrap();
1200 assert_eq!((header.shard_id, header.lsn), (3, 9));
1201 assert_eq!(decoded, ops);
1202 assert_eq!(batch.is_some(), payload.is_some());
1203 }
1204 let mut record = encode_ops_record(3, 9, &ops, Some(&command));
1206 let last = record.len() - 1;
1207 record[last] ^= 1;
1208 assert!(decode_ops_record(&record).is_err());
1209 }
1210
1211 #[test]
1212 fn chained_record_fitting_in_one_frame_is_byte_identical_to_unchained() {
1213 let ops = vec![
1214 Op::Put(b"k1".to_vec(), b"v1".to_vec()),
1215 Op::Del(b"k2".to_vec()),
1216 ];
1217 let batch = sample_batch();
1218 let command_payload = rushdb_types::command_codec::encode(&batch);
1219 let plain = encode_ops_record(1, 5, &ops, Some(&command_payload));
1220 let chained =
1221 encode_chained_ops_record(1, 5, &ops, Some(&command_payload), 64 * 1024 * 1024);
1222 assert_eq!(chained.len(), 1, "small op-set must not be split");
1223 assert_eq!(
1224 chained[0], plain,
1225 "must be byte-identical to the unchained encoding"
1226 );
1227 }
1228
1229 #[test]
1230 fn chain_splits_and_reassembles_many_fragments() {
1231 let ops: Vec<Op> = (0u8..50)
1233 .map(|i| Op::Put(format!("key-{i}").into_bytes(), big_value(i, 1024)))
1234 .collect();
1235 let batch = sample_batch();
1236 let command_payload = rushdb_types::command_codec::encode(&batch);
1237 let max_record_bytes = 2048;
1238 let records =
1239 encode_chained_ops_record(7, 42, &ops, Some(&command_payload), max_record_bytes);
1240 assert!(
1241 records.len() > 10,
1242 "expected many fragments, got {}",
1243 records.len()
1244 );
1245 for record in &records {
1246 assert!(record.len() <= max_record_bytes, "fragment exceeds ceiling");
1247 }
1248
1249 let (header, decoded_ops, decoded_batch) = assemble_all(&records);
1250 assert_eq!(header.shard_id, 7);
1251 assert_eq!(header.lsn, 42);
1252 assert_eq!(decoded_ops, ops);
1253 assert_eq!(decoded_batch.unwrap().commit_key, batch.commit_key);
1254 }
1255
1256 #[test]
1257 fn chain_without_command_round_trips() {
1258 let ops: Vec<Op> = (0..20)
1259 .map(|i| Op::Set(format!("s-{i}").into_bytes(), big_value(1, 512)))
1260 .collect();
1261 let records = encode_chained_ops_record(0, 3, &ops, None, 1024);
1262 assert!(records.len() > 1);
1263 let (_, decoded_ops, decoded_batch) = assemble_all(&records);
1264 assert_eq!(decoded_ops, ops);
1265 assert!(decoded_batch.is_none());
1266 }
1267
1268 #[test]
1269 fn chain_separates_ops_from_an_invalid_command_payload_only_at_completion() {
1270 let ops: Vec<Op> = (0..8)
1282 .map(|i| Op::Put(format!("p{i}").into_bytes(), big_value(2, 900)))
1283 .collect();
1284 let invalid_command = vec![b'{'; 100]; let max_record_bytes = 1024;
1286 let records =
1287 encode_chained_ops_record(0, 9, &ops, Some(&invalid_command), max_record_bytes);
1288 assert!(records.len() > 1, "expected this to need chaining");
1289 for record in &records {
1290 assert!(record.len() <= max_record_bytes);
1291 }
1292 let mut assembler = ChainAssembler::default();
1293 let mut completed = None;
1294 for (i, record) in records.iter().enumerate() {
1295 match assembler.feed(record, i as u64).unwrap() {
1296 ChainFeed::Pending => {}
1297 ChainFeed::Complete(header, body) => {
1298 assert!(completed.is_none(), "chain completed more than once");
1299 completed = Some((header, body));
1300 }
1301 }
1302 }
1303 let (header, body) = completed.expect("feed must structurally complete the chain");
1304 assert!(
1306 decode_logical_body(header, &body).is_err(),
1307 "invalid JSON command must fail to decode"
1308 );
1309 }
1310
1311 #[test]
1312 fn chain_assembler_rejects_interleaved_non_chained_record() {
1313 let ops: Vec<Op> = (0..10)
1314 .map(|i| Op::Put(format!("k{i}").into_bytes(), big_value(3, 300)))
1315 .collect();
1316 let records = encode_chained_ops_record(0, 1, &ops, None, 512);
1317 assert!(records.len() > 1);
1318 let mut assembler = ChainAssembler::default();
1319 assembler.feed(&records[0], 0).unwrap();
1320 assert!(assembler.is_open());
1321 let interloper = encode_ops_record(0, 99, &[Op::Del(b"x".to_vec())], None);
1322 assert!(assembler.feed(&interloper, 1).is_err());
1323 }
1324
1325 #[test]
1326 fn chain_assembler_rejects_lsn_mismatch_mid_chain() {
1327 let ops: Vec<Op> = (0..10)
1328 .map(|i| Op::Put(format!("k{i}").into_bytes(), big_value(4, 300)))
1329 .collect();
1330 let records = encode_chained_ops_record(0, 1, &ops, None, 512);
1331 assert!(records.len() > 2);
1332 let mut assembler = ChainAssembler::default();
1333 assembler.feed(&records[0], 0).unwrap();
1334 let (_, body) = decode_wal_record(&records[1]).unwrap();
1339 let retagged = encode_wal_record(0, 999, body);
1340 assert!(assembler.feed(&retagged, 1).is_err());
1341 }
1342
1343 #[test]
1344 fn chain_assembler_open_tracks_lsn_and_start_offset() {
1345 let ops: Vec<Op> = (0..10)
1346 .map(|i| Op::Put(format!("k{i}").into_bytes(), big_value(5, 300)))
1347 .collect();
1348 let records = encode_chained_ops_record(0, 77, &ops, None, 512);
1349 assert!(records.len() > 1);
1350 let mut assembler = ChainAssembler::default();
1351 assert!(!assembler.is_open());
1352 assembler.feed(&records[0], 1234).unwrap();
1353 assert!(assembler.is_open());
1354 assert_eq!(assembler.open_lsn(), Some(77));
1355 assert_eq!(assembler.open_start_offset(), Some(1234));
1356 for (i, record) in records.iter().enumerate().skip(1) {
1357 assembler.feed(record, 1234 + i as u64).unwrap();
1358 }
1359 assert!(!assembler.is_open(), "chain must close on CHAIN_FINAL");
1360 }
1361
1362 #[test]
1363 fn rejects_corrupted_payload() {
1364 let framed = encode_wal_record(0, 42, b"arbitrary payload bytes");
1365 let mut corrupted = framed;
1366 let last = corrupted.last_mut().unwrap();
1367 *last ^= 0xFF;
1368 let err = decode_wal_record(&corrupted).unwrap_err();
1369 assert!(
1370 matches!(err, WalDecodeError::ChecksumMismatch),
1371 "got: {err}"
1372 );
1373 }
1374
1375 #[test]
1376 fn rejects_truncated_record() {
1377 let framed = encode_wal_record(0, 42, b"arbitrary payload bytes");
1378 let err = decode_wal_record(&framed[..framed.len() - 3]).unwrap_err();
1379 assert!(
1380 matches!(err, WalDecodeError::Truncated { .. }),
1381 "got: {err}"
1382 );
1383 }
1384
1385 #[test]
1386 fn rejects_bad_magic_and_version() {
1387 let framed = encode_wal_record(0, 42, b"arbitrary payload bytes");
1388 let mut bad_magic = framed.clone();
1389 bad_magic[0] = b'X';
1390 assert!(matches!(
1391 decode_wal_record(&bad_magic).unwrap_err(),
1392 WalDecodeError::BadMagic
1393 ));
1394 let mut bad_version = framed;
1395 bad_version[8..12].copy_from_slice(&99u32.to_le_bytes());
1396 assert!(matches!(
1397 decode_wal_record(&bad_version).unwrap_err(),
1398 WalDecodeError::UnsupportedVersion(99)
1399 ));
1400 }
1401
1402 #[test]
1403 fn last_applied_round_trip() {
1404 let raw = encode_last_applied(0, 1234);
1405 assert_eq!(decode_last_applied(&raw).unwrap(), 1234);
1406 }
1407
1408 #[test]
1409 fn op_set_round_trips_all_variants() {
1410 let ops = vec![
1411 Op::Put(b"k1".to_vec(), b"v1".to_vec()),
1412 Op::Del(b"k2".to_vec()),
1413 Op::Set(b"k3".to_vec(), b"v3".to_vec()),
1414 Op::Purge(b"k4".to_vec()),
1415 Op::Put(vec![], vec![]),
1416 ];
1417 let raw = encode_ops_record(3, 7, &ops, None);
1418 let (header, decoded, batch) = decode_ops_record(&raw).unwrap();
1419 assert_eq!(header.shard_id, 3);
1420 assert_eq!(header.lsn, 7);
1421 assert_eq!(decoded, ops);
1422 assert!(batch.is_none(), "a bare op-set record carries no command");
1423 }
1424
1425 #[test]
1431 fn op_set_with_command_round_trips_both() {
1432 let ops = vec![Op::Put(b"key".to_vec(), b"value".to_vec())];
1433 let batch = sample_batch();
1434 let command_payload = rushdb_types::command_codec::encode(&batch);
1435 let raw = encode_ops_record(2, 11, &ops, Some(&command_payload));
1436 let (header, decoded_ops, decoded_batch) = decode_ops_record(&raw).unwrap();
1437 assert_eq!(header.shard_id, 2);
1438 assert_eq!(header.lsn, 11);
1439 assert_eq!(decoded_ops, ops);
1440 assert_eq!(decoded_batch.unwrap().commit_key, batch.commit_key);
1441 }
1442
1443 #[test]
1444 fn op_set_rejects_corruption_and_trailing_bytes() {
1445 let ops = vec![Op::Put(b"key".to_vec(), b"value".to_vec())];
1446 let mut raw = encode_ops_record(0, 1, &ops, None);
1447 let last = raw.len() - 1;
1448 raw[last] ^= 0xFF;
1449 assert!(decode_ops_record(&raw).is_err());
1450
1451 let mut trailing = encode_op_set(&ops);
1452 trailing.push(0);
1453 assert!(decode_op_set(&trailing).unwrap_err().contains("trailing"));
1454
1455 assert!(decode_op_set(&[1, 0, 0, 0])
1456 .unwrap_err()
1457 .contains("truncated"));
1458 }
1459
1460 #[test]
1461 fn framed_chunk_stream_round_trip_and_corruption() {
1462 let mut out = Vec::new();
1463 for chunk in &["alpha", "beta", "gamma"] {
1464 write_framed_chunk(&mut out, chunk.as_bytes()).unwrap();
1465 }
1466 let mut reader = std::io::Cursor::new(&out);
1467 let mut got = Vec::new();
1468 while let Some(chunk) = read_framed_chunk(&mut reader).unwrap() {
1469 got.push(String::from_utf8(chunk).unwrap());
1470 }
1471 assert_eq!(got, vec!["alpha", "beta", "gamma"]);
1472
1473 let mut corrupted = out.clone();
1474 let mid = corrupted.len() / 2;
1475 corrupted[mid] ^= 0x40;
1476 let mut reader = std::io::Cursor::new(&corrupted);
1477 let res = (|| loop {
1478 if read_framed_chunk(&mut reader)?.is_none() {
1479 return Ok::<_, std::io::Error>(());
1480 }
1481 })();
1482 assert!(res.is_err(), "corruption must be detected");
1483 }
1484
1485 #[test]
1486 fn wal_segment_round_trip_and_manifest() {
1487 let batch = sample_batch();
1488 let command_payload = rushdb_types::command_codec::encode(&batch);
1489 let records: Vec<(u64, Vec<u8>)> = vec![
1490 (10, encode_ops_record(0, 10, &[], Some(&command_payload))),
1491 (11, encode_ops_record(0, 11, &[], Some(&command_payload))),
1492 (12, encode_ops_record(0, 12, &[], Some(&command_payload))),
1493 ];
1494 let refs: Vec<(u64, &[u8])> = records
1495 .iter()
1496 .map(|(lsn, b)| (*lsn, b.as_slice()))
1497 .collect();
1498 let mut out = Vec::new();
1499 let manifest = write_wal_segment(&mut out, 0, &refs).unwrap();
1500 assert_eq!(manifest.from_lsn, 10);
1501 assert_eq!(manifest.to_lsn, 12);
1502 assert_eq!(manifest.record_count, 3);
1503
1504 let mut reader = std::io::Cursor::new(&out);
1505 let (got, decoded) = read_wal_segment(&mut reader).unwrap().unwrap();
1506 assert_eq!(got, manifest);
1507 assert_eq!(decoded.len(), 3);
1508 let (_, _, decoded_batch) = decode_ops_record(&decoded[0]).unwrap();
1509 assert_eq!(decoded_batch.unwrap().commit_key, batch.commit_key);
1510 }
1511
1512 #[test]
1513 fn wal_segment_detects_record_corruption() {
1514 let records: Vec<(u64, Vec<u8>)> = vec![
1515 (10, encode_ops_record(0, 10, &[], None)),
1516 (11, encode_ops_record(0, 11, &[], None)),
1517 ];
1518 let refs: Vec<(u64, &[u8])> = records
1519 .iter()
1520 .map(|(lsn, b)| (*lsn, b.as_slice()))
1521 .collect();
1522 let mut out = Vec::new();
1523 write_wal_segment(&mut out, 0, &refs).unwrap();
1524 let mid = out.len() / 2;
1525 out[mid] ^= 0x20;
1526 let mut reader = std::io::Cursor::new(&out);
1527 assert!(read_wal_segment(&mut reader).is_err());
1528 }
1529
1530 #[test]
1531 fn checkpoint_round_trips_with_frame_checksum() {
1532 let checkpoint = Checkpoint {
1533 shard_id: 2,
1534 durable_lsn: 99,
1535 gc_watermark: 88,
1536 published_at_ms: 1_700_000_000_000,
1537 };
1538 let encoded = encode_checkpoint(&checkpoint);
1539 let decoded = decode_checkpoint(&encoded).unwrap();
1540 assert_eq!(decoded, checkpoint);
1541
1542 let mut torn = encoded;
1544 let last = torn.len() - 1;
1545 torn[last] ^= 0xFF;
1546 assert!(decode_checkpoint(&torn).is_err());
1547
1548 let short = encode_wal_record(2, 99, &[0u8; 4]);
1550 assert!(decode_checkpoint(&short).is_err());
1551 }
1552
1553 #[test]
1554 fn snapshot_round_trips_and_validates() {
1555 let tenants = vec![
1556 (
1557 b"tenant-a".to_vec(),
1558 b"record line 1\nrecord line 2\n".to_vec(),
1559 ),
1560 (b"tenant-b".to_vec(), b"record line 3\n".to_vec()),
1561 ];
1562 let mut out = Vec::new();
1563 let manifest = write_snapshot(&mut out, 3, 10, 20, &tenants).unwrap();
1564 assert_eq!(manifest.magic, SNAPSHOT_MAGIC);
1565 assert_eq!(manifest.tenant_count, 2);
1566 assert_eq!(manifest.from_lsn, 10);
1567 assert_eq!(manifest.to_lsn, 20);
1568
1569 let mut reader = std::io::Cursor::new(&out);
1570 let (got_manifest, chunks) = read_snapshot(&mut reader).unwrap();
1571 assert_eq!(got_manifest, manifest);
1572 assert_eq!(chunks.len(), 2);
1573 assert_eq!(chunks[0].tenant, b"tenant-a");
1574 assert_eq!(chunks[0].jsonl, b"record line 1\nrecord line 2\n");
1575 assert_eq!(chunks[1].tenant, b"tenant-b");
1576 }
1577
1578 #[test]
1579 fn snapshot_rejects_corruption_and_truncation() {
1580 let tenants = vec![(b"tenant-a".to_vec(), b"payload to be protected".to_vec())];
1581 let mut out = Vec::new();
1582 write_snapshot(&mut out, 0, 1, 5, &tenants).unwrap();
1583
1584 let mut corrupt = out.clone();
1586 let mid = corrupt.len() / 2;
1587 corrupt[mid] ^= 0xFF;
1588 let mut reader = std::io::Cursor::new(&corrupt);
1589 assert!(
1590 read_snapshot(&mut reader).is_err(),
1591 "corrupted snapshot must be rejected"
1592 );
1593
1594 let truncated = &out[..out.len() - 6];
1596 let mut reader = std::io::Cursor::new(truncated);
1597 assert!(
1598 read_snapshot(&mut reader).is_err(),
1599 "truncated snapshot must be rejected"
1600 );
1601
1602 let mut wal_segment = Vec::new();
1604 let refs: Vec<(u64, &[u8])> = vec![(1, &b"x"[..])];
1605 write_wal_segment(&mut wal_segment, 0, &refs).unwrap();
1606 let mut reader = std::io::Cursor::new(&wal_segment);
1607 assert!(read_snapshot(&mut reader).is_err());
1608 }
1609
1610 #[test]
1611 fn oversized_manifest_counts_fail_before_allocation() {
1612 let snapshot_manifest = SnapshotManifest {
1613 magic: SNAPSHOT_MAGIC.into(),
1614 format_version: WAL_FORMAT_VERSION,
1615 shard_id: 0,
1616 from_lsn: 0,
1617 to_lsn: 0,
1618 tenant_count: u64::MAX,
1619 tenants_crc: 0,
1620 };
1621 let mut snapshot = Vec::new();
1622 write_framed_chunk(&mut snapshot, &encode_manifest(&snapshot_manifest)).unwrap();
1623 assert!(read_snapshot(&mut std::io::Cursor::new(snapshot)).is_err());
1624
1625 let segment_manifest = WalSegmentManifest {
1626 magic: SEGMENT_MAGIC.into(),
1627 format_version: WAL_FORMAT_VERSION,
1628 shard_id: 0,
1629 from_lsn: 0,
1630 to_lsn: 0,
1631 record_count: u64::MAX,
1632 records_crc: 0,
1633 };
1634 let mut segment = Vec::new();
1635 write_framed_chunk(&mut segment, &encode_manifest(&segment_manifest)).unwrap();
1636 assert!(read_wal_segment(&mut std::io::Cursor::new(segment)).is_err());
1637 }
1638}