Skip to main content

rushdb_storage/
wal.rs

1//! Record framing for the log-first storage substrate. This **is** the
2//! write-ahead log: [`crate::log_store`] appends
3//! these framed records to segment files, and that durable append is the
4//! commit's ack boundary (not the buffered redb materialization). See the
5//! manual's `docs/manual/architecture/storage-engine.md`.
6//!
7//! Every record (an expanded op-set, optionally paired with its originating
8//! [`CommitBatch`] — [`encode_ops_record`]) is wrapped in a fixed
9//! header with magic, format version, shard id, LSN, payload length, and a
10//! CRC32C-style checksum. This lets replay/export/CDC distinguish valid old
11//! data, valid new data, partial new data, and corrupted data instead of
12//! hoping the filesystem write was atomic.
13//!
14//! Greenfield: only the framed format is read or written. There is no legacy
15//! unframed format to migrate. An in-memory store (no WAL file) instead keeps
16//! a row-based command journal — the one remaining user of the bare
17//! `__log__/<lsn>` row format.
18
19use crate::Op;
20use crc32fast::Hasher as Crc32;
21use rushdb_types::command::CommitBatch;
22use std::io;
23
24/// Magic bytes identifying a `RushDB` logical WAL record.
25const WAL_MAGIC: [u8; 8] = *b"RDBWAL01";
26
27/// Current logical WAL framing format version.
28pub const WAL_FORMAT_VERSION: u32 = 2;
29
30/// Header size: magic(8) + version(4) + `shard_id(4)` + lsn(8) + length(4) + crc(4).
31const WAL_HEADER_LEN: usize = 8 + 4 + 4 + 8 + 4 + 4;
32
33/// A decoded logical WAL record header.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub struct WalHeader {
36    /// Physical shard that produced the record.
37    pub shard_id: u32,
38    /// Log sequence number (commit index) of the record.
39    pub lsn: u64,
40    /// Length of the payload that follows the header.
41    pub payload_len: u32,
42}
43
44/// Error returned when a logical WAL record fails to decode or verify.
45#[derive(Debug, thiserror::Error)]
46pub enum WalDecodeError {
47    /// The record is shorter than its header or declared payload.
48    #[error("short WAL record: expected {expected} bytes, got {actual}")]
49    Truncated {
50        /// Total length required by the header/payload.
51        expected: usize,
52        /// Actual bytes available.
53        actual: usize,
54    },
55    /// The record does not begin with the expected magic.
56    #[error("invalid WAL magic")]
57    BadMagic,
58    /// The record uses a framing version this build does not support.
59    #[error("unsupported WAL format version {0}")]
60    UnsupportedVersion(u32),
61    /// The record's CRC does not match its contents.
62    #[error("WAL record checksum mismatch")]
63    ChecksumMismatch,
64    /// The declared payload exceeds the 64 MiB record ceiling.
65    #[error("payload length {0} exceeds the 64 MiB record ceiling")]
66    OversizedPayload(u32),
67}
68
69/// Encode one logical record into a framed, checksummed byte vector.
70///
71/// # Panics
72/// Panics if `payload` is larger than `u32::MAX` bytes, which the 64 MiB
73/// record ceiling makes impossible in practice.
74#[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    // Checksum covers everything after the length field (length field itself is
84    // integrity-checked by the outer redb row checksum; include header+payload
85    // for a RustDB-level end-to-end guard).
86    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
98/// Decode and verify a framed record; returns the header and payload.
99///
100/// # Errors
101/// Returns [`WalDecodeError`] when the record is truncated, has bad magic, an
102/// unsupported version, an oversized payload, or a checksum mismatch.
103///
104/// # Panics
105/// Panics only if an internal fixed-width slice conversion fails after its
106/// length has been bounds-checked, which cannot happen.
107pub 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/// Wall-clock now in milliseconds since the Unix epoch (used for retention
153/// lease deadlines and checkpoint publication timestamps).
154#[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/// Encode the `LAST_APPLIED` watermark row (a framed record with an empty
162/// payload carries the LSN in the header).
163#[must_use]
164pub fn encode_last_applied(shard_id: u32, lsn: u64) -> Vec<u8> {
165    encode_wal_record(shard_id, lsn, &[])
166}
167
168/// Decode the `LAST_APPLIED` watermark row.
169///
170/// # Errors
171/// Returns a human-readable description if the frame is invalid or its
172/// checksum does not verify.
173pub 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/// Encode an expanded op-set as a compact binary WAL payload: an op count
179/// followed by `(tag, key, [value])` entries. This is the log-first WAL's
180/// record content (decision B): the log stores state transitions, so recovery
181/// applies them directly and needs no expander.
182///
183/// # Panics
184/// Panics if an op-set has more than `u32::MAX` ops or a key/value exceeds
185/// `u32::MAX` bytes, which the 64 MiB record ceiling makes impossible.
186#[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
193/// Append the op-set encoding of `ops` to `out`.
194fn 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/// Exact byte length [`encode_op_set`] would produce for `ops`, computed
203/// without allocating — used for cheap pre-commit size checks (deciding
204/// whether a commit needs chaining, or exceeds the configured transaction
205/// ceiling entirely) without building the buffer just to measure it.
206#[must_use]
207pub fn op_set_encoded_len(ops: &[Op]) -> usize {
208    let mut len = 4; // count prefix
209    for op in ops {
210        len += 1; // tag
211        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
244/// Encode one op's tag + length-prefixed fields — the per-op body
245/// [`encode_op_set`]'s loop emits once per op.
246fn 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
269/// Decode one op's tag + length-prefixed fields, advancing `cur` past it —
270/// the per-op body [`decode_op_set`]'s loop consumes once per op.
271fn 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
284/// Decode an op-set encoded by [`encode_op_set`].
285///
286/// # Errors
287/// Returns a human-readable description if the payload is truncated, carries
288/// an unknown op tag, or has trailing bytes.
289pub 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
302/// Record-kind tag distinguishing a bare op-set from one paired with its
303/// originating command. A one-byte prefix inside the framed
304/// payload, so a WAL (or an export stream) can carry a mix of both kinds and
305/// decode each unambiguously — low-level/raw op application (tests, the
306/// full-replay shadow-map path) never had a `CommitBatch` to begin with and
307/// keeps using `None`.
308const OPS_RECORD_KIND_BARE: u8 = 0;
309const OPS_RECORD_KIND_WITH_COMMAND: u8 = 1;
310/// A non-final fragment of a chained commit (a large
311/// transaction): this LSN's commit did not fit in one 64 MiB framed record, so
312/// its *logical body* — the same `[op_set_len][op_set][command?]` layout
313/// [`encode_ops_record`]'s `WITH_COMMAND` kind uses — is split across
314/// several records sharing one LSN, at arbitrary raw byte boundaries (never
315/// aligned to op or command boundaries, since nothing is decoded until every
316/// fragment is reassembled). Reconstructed only once the matching
317/// [`OPS_RECORD_KIND_CHAIN_FINAL`] is seen (see [`ChainAssembler`]).
318const OPS_RECORD_KIND_CHAIN_CONTINUE: u8 = 2;
319/// The final fragment of a chained commit: like `CHAIN_CONTINUE`, but marks
320/// the chain complete — this is the point at which the concatenation of
321/// every fragment's bytes becomes a decodable logical body.
322const OPS_RECORD_KIND_CHAIN_FINAL: u8 = 3;
323
324/// Encode an expanded op-set as a framed WAL record at `(shard_id, lsn)`,
325/// optionally paired with the serialized `CommitBatch` that produced it
326/// (carrying the command in the WAL record is what makes the
327/// command journal — `commit_key` per LSN — fully derivable from the WAL,
328/// closing the `__log__` redb row that used to be the only source of it).
329/// `command_payload` is the batch pre-serialized by the caller (already
330/// computed once for the outcome-payload row), not re-serialized here.
331///
332/// # Panics
333/// Panics if the op-set encodes to more than `u32::MAX` bytes, which the
334/// 64 MiB record ceiling makes impossible.
335#[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    // Encoded once, into one exactly-sized buffer: the header is written
343    // with a placeholder CRC, the body is appended in place, and the length
344    // and checksum are patched at the end (SD-7.3).
345    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]); // CRC, patched below
355    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
377/// Decode a framed op-set record, verifying the frame checksum. The returned
378/// header carries the routing shard and LSN; the `CommitBatch` is `Some` only
379/// for a record encoded with one (every real client commit through the
380/// normal publish path — see [`encode_ops_record`]).
381///
382/// # Errors
383/// Returns a human-readable description if the frame is invalid, the record
384/// kind is unrecognized, or the payload is not a valid op-set/commit batch.
385///
386/// # Panics
387/// Panics only if an internal fixed-width slice conversion fails after its
388/// length has been bounds-checked, which cannot happen.
389pub 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/// Split `ops` (optionally paired with `command_payload`) into one or more
413/// framed WAL records at `(shard_id, lsn)`, each a complete, independently
414/// checksummed frame whose total length never exceeds `max_record_bytes`.
415/// All returned records share one LSN; only the last is tagged
416/// [`OPS_RECORD_KIND_CHAIN_FINAL`] (or, when everything already fits in one
417/// record — overwhelmingly the common case — the single returned record is
418/// byte-identical to [`encode_ops_record`]'s ordinary `BARE`/`WITH_COMMAND`
419/// output). When chaining is needed, the logical body (op-set plus optional
420/// command, laid out exactly like `WITH_COMMAND`'s own format) is cut at
421/// arbitrary raw byte boundaries — never aligned to op or command
422/// boundaries — which is what lets a command payload of any size be chained
423/// too, not just the op-set: nothing is decoded fragment-by-fragment, only
424/// once every fragment of the chain is reassembled (see [`ChainAssembler`]).
425///
426/// # Panics
427/// Panics if `max_record_bytes` is too small to fit even the frame header
428/// and kind byte — a caller-side configuration error, not a data-dependent
429/// runtime condition.
430#[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    // The logical body to chain: identical layout to `WITH_COMMAND`'s own
448    // body (op_set_len-prefixed op-set, then command bytes — possibly
449    // empty, when there is no command). Reusing this exact layout means
450    // reassembly can reuse `WITH_COMMAND`'s own parsing, not a new format.
451    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/// Reconstructs a (possibly chained) commit from consecutive framed WAL
489/// records, fed one at a time in physical log order. The overwhelmingly
490/// common case — a `BARE`/`WITH_COMMAND` record — reconstructs immediately.
491/// A chained commit's `CHAIN_CONTINUE` fragments are buffered until the
492/// matching `CHAIN_FINAL` is fed, at which point the whole op-set is
493/// returned — in full, exactly once, never partially. This is the one
494/// reconstruction implementation crash recovery and replication apply both
495/// use, so there is never a second, potentially drifting copy of this logic.
496#[derive(Default)]
497pub struct ChainAssembler {
498    open: Option<OpenChain>,
499}
500
501struct OpenChain {
502    lsn: u64,
503    shard_id: u32,
504    /// Byte offset (in whatever stream the caller is scanning) where this
505    /// chain's first fragment began — what recovery must truncate back to,
506    /// and what retention must never truncate through, if the chain never
507    /// completes.
508    start_offset: u64,
509    /// Concatenated raw logical-body bytes from every `CHAIN_CONTINUE` fed
510    /// so far, in order.
511    fragments: Vec<u8>,
512}
513
514/// Result of feeding one record to a [`ChainAssembler`].
515pub enum ChainFeed {
516    /// This LSN's commit's *framing* is fully reconstructed: `header` plus
517    /// the logical body (`[op_set_len:4][op_set][command bytes, possibly
518    /// empty]` — exactly `WITH_COMMAND`'s own body shape), ready to decode
519    /// via [`decode_logical_body`]. Decoding is a separate, explicit step
520    /// — deliberately never done inside [`ChainAssembler::feed`] — so a
521    /// *content* corruption (e.g. an unparsable embedded `CommitBatch`,
522    /// `metadata_retirement_rejects_a_corrupt_wal_command_envelope`'s exact
523    /// case) is never mistaken for a torn write and silently truncated by a
524    /// caller that only wants structural chain-completeness (`read_segment`'s
525    /// recovery pass checks `is_open()`/`open_start_offset()` and never
526    /// calls `decode_logical_body` at all).
527    Complete(WalHeader, Vec<u8>),
528    /// More fragments are still expected for this LSN; nothing to decode yet.
529    Pending,
530}
531
532/// Decode a [`ChainFeed::Complete`] logical body into its op-set and
533/// optional command. Kept separate from [`ChainAssembler::feed`] (see
534/// [`ChainFeed::Complete`]'s doc) so framing/chain-structure completeness
535/// and content validity are never conflated.
536///
537/// # Errors
538/// Returns a human-readable description if the body is truncated, the
539/// op-set carries an unknown tag, or a non-empty command fails to parse as
540/// a `CommitBatch`.
541///
542/// # Panics
543/// Panics only if an internal fixed-width slice conversion fails after its
544/// length has been bounds-checked, which cannot happen.
545pub 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    /// Feed one decoded, checksum-verified framed record, at `offset` in
571    /// whatever byte stream the caller is scanning (the segment file for
572    /// recovery, the replication page stream for a follower). Purely
573    /// structural: never decodes the op-set or command (see
574    /// [`ChainFeed::Complete`]'s doc for why).
575    ///
576    /// # Errors
577    /// Returns an error if a `CHAIN_CONTINUE`/`CHAIN_FINAL` arrives for a
578    /// different LSN than the currently-open chain, if a `BARE`/
579    /// `WITH_COMMAND` record arrives while a chain is open, or if the frame
580    /// itself fails to decode (bad magic/version/checksum) — all of which
581    /// indicate a corrupt or reordered log, never a legitimate write
582    /// pattern.
583    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                // Normalize to the `WITH_COMMAND`-shaped logical body (an
595                // explicit op_set_len prefix, empty trailing command) so
596                // every `Complete` result shares one decodable layout
597                // regardless of which kind produced it.
598                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                // `rest` is already exactly the target logical-body shape.
613                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    /// True while a chain is open (fragments buffered, final not yet seen).
655    #[must_use]
656    pub fn is_open(&self) -> bool {
657        self.open.is_some()
658    }
659
660    /// The LSN of the currently-open chain, if any.
661    #[must_use]
662    pub fn open_lsn(&self) -> Option<u64> {
663        self.open.as_ref().map(|o| o.lsn)
664    }
665
666    /// The byte offset where the currently-open chain's first fragment
667    /// began, if any — what a caller reaching end-of-stream with an open
668    /// chain must truncate back to (discarding the whole incomplete chain,
669    /// never partially applying it).
670    #[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/// A published durability checkpoint: the durable LSN (header), the GC floor,
677/// and the wall-clock time the checkpoint was published. The record's frame
678/// checksum makes a torn/partial checkpoint fail verification on load.
679#[derive(Clone, Debug, PartialEq, Eq)]
680pub struct Checkpoint {
681    /// Physical shard this checkpoint belongs to.
682    pub shard_id: u32,
683    /// Durable LSN covered by this checkpoint.
684    pub durable_lsn: u64,
685    /// GC floor that must remain readable.
686    pub gc_watermark: u64,
687    /// Wall-clock time the checkpoint was published, in ms since epoch.
688    pub published_at_ms: u64,
689}
690
691/// Encode a checkpoint as a framed WAL record (LSN carried in the header,
692/// floor + timestamp in the payload).
693#[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
701/// Decode a checkpoint from a framed WAL record.
702///
703/// # Errors
704/// Returns a human-readable description if the frame is invalid or its
705/// payload is not exactly the expected 16 bytes.
706pub 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
723/// Framed-stream writer for export/backup: emits `{len}{crc}{bytes}` chunks so
724/// a consumer can detect truncation/corruption without reading the whole
725/// artifact. (P17 foundations.)
726///
727/// # Errors
728/// Returns an I/O error if the chunk length exceeds `u32::MAX` or writing to
729/// the sink fails.
730pub 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
744/// Read one framed chunk written by [`write_framed_chunk`].
745///
746/// # Errors
747/// Returns an I/O error if reading fails, the chunk exceeds the 64 MiB
748/// ceiling, or its checksum does not match. A clean end of stream yields
749/// `Ok(None)`.
750pub 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/// Manifest describing one exported WAL segment range. Written first in a
781/// segment stream so a consumer can validate the range before reading records.
782#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
783pub struct WalSegmentManifest {
784    /// Segment magic identifying the artifact type.
785    pub magic: String,
786    /// Framing format version.
787    pub format_version: u32,
788    /// Physical shard the segment was exported from.
789    pub shard_id: u32,
790    /// First LSN included in the segment.
791    pub from_lsn: u64,
792    /// Last LSN included in the segment.
793    pub to_lsn: u64,
794    /// Number of framed records that follow.
795    pub record_count: u64,
796    /// CRC32 over the concatenated framed records that follow.
797    pub records_crc: u32,
798}
799
800/// Magic identifying a WAL segment manifest.
801pub const SEGMENT_MAGIC: &str = "RDBWALSEG";
802
803/// Framed snapshot export magic: distinct from the WAL segment magic so a
804/// restore can reject a mismatched artifact type immediately.
805pub const SNAPSHOT_MAGIC: &str = "RDBWALSNAP";
806
807/// Manifest header for a framed snapshot stream (W6, D9/P17). Written as the
808/// first framed chunk; consumers validate `magic`/`format_version` before
809/// reading tenant chunks. `tenants_crc` covers the concatenated tenant chunks,
810/// so a torn/corrupted stream is rejected at restore time.
811#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
812pub struct SnapshotManifest {
813    /// Snapshot magic identifying the artifact type.
814    pub magic: String,
815    /// Framing format version.
816    pub format_version: u32,
817    /// Physical shard the snapshot was exported from.
818    pub shard_id: u32,
819    /// First LSN included in the snapshot.
820    pub from_lsn: u64,
821    /// Last LSN included in the snapshot.
822    pub to_lsn: u64,
823    /// Number of tenant chunks that follow.
824    pub tenant_count: u64,
825    /// CRC32 over the concatenated tenant chunks that follow.
826    pub tenants_crc: u32,
827}
828
829/// One tenant's framed backup chunk: scope (`TenantId` bytes) + JSONL payload.
830#[derive(Debug, Clone, PartialEq, Eq)]
831pub struct TenantChunk {
832    /// Raw tenant id bytes this chunk belongs to.
833    pub tenant: Vec<u8>,
834    /// JSONL-encoded rows for the tenant.
835    pub jsonl: Vec<u8>,
836}
837
838/// Encode a tenant chunk: 4-byte tenant length, tenant bytes, JSONL bytes.
839/// The whole chunk is written as one framed chunk (CRC-verified on read).
840///
841/// # Panics
842/// Panics if the tenant id is larger than `u32::MAX` bytes, which cannot
843/// happen for the fixed-width tenant id in use.
844#[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
854/// Decode a tenant chunk body (the inverse of [`encode_tenant_chunk`]).
855///
856/// # Errors
857/// Returns a human-readable description if the chunk is shorter than its
858/// declared tenant length or the length overflows the body.
859///
860/// # Panics
861/// Panics only if an internal fixed-width slice conversion fails after its
862/// length has been bounds-checked, which cannot happen.
863pub 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
881/// Write one framed snapshot stream: manifest chunk, then one framed chunk per
882/// tenant (each independently checksummed; the manifest carries a CRC over all
883/// tenant chunks). Returns the manifest.
884///
885/// # Errors
886/// Returns an I/O error if writing the manifest or any tenant chunk fails.
887pub 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
924/// Read + validate only the snapshot manifest (magic/version), so a caller can
925/// stream the tenant chunks one at a time instead of materializing them all.
926///
927/// # Errors
928/// Returns [`io::ErrorKind::UnexpectedEof`] when the stream is empty, or
929/// [`io::ErrorKind::InvalidData`] when the manifest is malformed, has the
930/// wrong magic, or uses an unsupported format version.
931pub 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
965/// Read one framed snapshot stream, verifying the manifest (magic + version)
966/// and every tenant chunk CRC. Returns the manifest and decoded tenant chunks.
967/// Truncated or corrupted input is rejected: if the stream ends before
968/// `tenant_count` chunks are read, or a chunk fails its CRC, this errors.
969///
970/// # Errors
971/// Returns [`io::ErrorKind::UnexpectedEof`] for a missing manifest or a
972/// truncated chunk stream, or [`io::ErrorKind::InvalidData`] when the
973/// manifest is malformed or a chunk fails verification.
974pub 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    // Verify the aggregate CRC over all tenant chunks.
1010    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
1023/// Encode a manifest as JSON (used as the first framed chunk of a stream).
1024///
1025/// # Panics
1026/// Panics if the manifest cannot be serialized, which cannot happen for the
1027/// derived `Serialize` implementations used here.
1028pub fn encode_manifest<M: serde::Serialize>(manifest: &M) -> Vec<u8> {
1029    serde_json::to_vec(manifest).expect("serializable manifest")
1030}
1031
1032/// Write one full WAL segment: manifest chunk, then one framed chunk per
1033/// record (each chunk independently checksummed; the manifest also carries a
1034/// CRC over all records). Returns the manifest.
1035///
1036/// # Errors
1037/// Returns an I/O error if writing the manifest or any record fails.
1038pub 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
1065/// Read one WAL segment written by [`write_wal_segment`], verifying the
1066/// manifest and every record checksum. Returns `(manifest, records)`.
1067///
1068/// # Errors
1069/// Returns an I/O error if reading fails, the manifest is malformed or has the
1070/// wrong magic/version, the segment ends early, or a record CRC mismatches. A
1071/// clean end of stream yields `Ok(None)`.
1072pub 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    /// Feeds every record to a fresh [`ChainAssembler`] in order and returns
1148    /// the one `Complete` result, panicking if the chain never completes or
1149    /// completes more than once.
1150    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            // One exactly-sized allocation: nothing was re-encoded or grown.
1182            assert_eq!(record.capacity(), record.len());
1183            // Byte-identical to framing the separately encoded body.
1184            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        // A flipped byte is caught by the patched checksum.
1205        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        // Each op is ~1 KiB; a 2 KiB-per-record ceiling forces many fragments.
1232        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        // Raw byte chunking means a chunk boundary can fall anywhere,
1271        // including inside the command's own bytes — reconstruction must
1272        // still correctly separate the reassembled op-set from the
1273        // reassembled command bytes regardless of where the physical cuts
1274        // landed. `feed` itself is purely structural and must complete the
1275        // chain successfully even though the command is invalid JSON — only
1276        // the explicit `decode_logical_body` step, which a caller invokes
1277        // separately, should fail on it (this is the whole point of
1278        // splitting the two: a structural chain-completeness check, like
1279        // `log_store::read_segment`'s crash-recovery pass, must never
1280        // mistake this kind of content corruption for a torn write).
1281        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]; // deliberately not valid JSON
1285        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        // Only now, decoding explicitly, does the invalid command surface.
1305        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        // Re-frame the second record's body under a different LSN (with a
1335        // freshly computed checksum, so this is a decode-valid-but-wrong-chain
1336        // record — a reordered/corrupt log — not a checksum failure, which is
1337        // what's actually under test here).
1338        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    /// A record encoded with its originating command round-trips
1426    /// both the op-set (for materialization replay, unchanged) and the
1427    /// `CommitBatch` (for command-journal purposes — this is what makes
1428    /// `commit_key` per LSN fully derivable from the WAL, closing the
1429    /// `__log__` redb row).
1430    #[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        // Torn frame: flipping a payload byte breaks the CRC and must reject.
1543        let mut torn = encoded;
1544        let last = torn.len() - 1;
1545        torn[last] ^= 0xFF;
1546        assert!(decode_checkpoint(&torn).is_err());
1547
1548        // LSN lives in the header; a wrong body length is invalid too.
1549        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        // Corrupt a byte in the middle of the tenant JSONL chunk.
1585        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        // Truncate the stream mid-way (fewer tenant chunks than the manifest).
1595        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        // Wrong magic (a WAL segment) must be rejected.
1603        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}