Phase 4d: typed WalMutation frames
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Successful in 10s

Adds the encoding layer that turns the raw WAL (opaque bytes)
into a typed queue of client-mode mutations, ready for Phase 4e
to wire actual RPCs through.

Frame (self-describing, forward-compatible):

  version : u8  = 0x01
  kind    : u8  = one of the Kind discriminants
  body    : [u8]  kind-specific

Body encodings mirror the existing on-wire shapes so a future
replay path can splice a WAL record straight into an RPC payload.

Variants (Kinds 0x01–0x06):
  PutRef, PutRefVersioned, PutTag, PutTagVersioned,
  DeleteTag, SetTagExpiry

Blob-put mutations are deliberately NOT modeled — blob data is
too large to keep in the WAL. The roaming-client design stages
blobs on local disk and records a reference to them once the
local BlobPutStream completes.

Public helpers:
  append_mutation(&mut wal, &m) -> Result<seq>
  replay_mutations(&wal, start_seq)
      -> Vec<(seq, Result<WalMutation, WalMutationError>)>

Unknown-kind records surface as `Err(UnknownKind(byte))`, not
a panic — forward-compat when a newer writer wrote a record
this reader doesn't understand. Malformed records also surface
as Err so the caller can decide (log-and-skip vs abort replay).

Tests (9, all green): kind-byte stability, roundtrip every
variant, rejects empty/short/bad-version/unknown-kind,
malformed bodies (wrong length, over-declared key_len, trailing
garbage on DeleteTag), non-UTF-8 keys, append+replay through a
real on-disk WAL, and replay-survives-unknown-kind mid-stream.

No new deps — hand-rolled error type in-tree (no thiserror).
515 lines, well under the 1300 ceiling.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-14 00:55:24 -07:00
co-authored by Claude Opus 4.7
parent 0cf00e5954
commit bd0b4972b9
2 changed files with 516 additions and 0 deletions
+515
View File
@@ -0,0 +1,515 @@
//! Phase 4d (2026-07-13): typed mutation records for the WAL.
//!
//! The WAL itself (`cluster::wal`) treats payloads as opaque bytes.
//! Callers need a stable, self-describing encoding so a replay can
//! decide which RPC to re-issue on reconnect. This module owns that
//! encoding.
//!
//! Frame:
//! ```text
//! version : u8 = 0x01 today
//! kind : u8 — one of `Kind`
//! body : [u8] — kind-specific
//! ```
//!
//! Body encodings deliberately mirror the existing on-wire shapes
//! (`refs::StampedRef::to_bytes`, `tags::encode_stamped_record`,
//! ...) so a future consumer can splice a WAL record straight into
//! an RPC payload with a memcpy.
//!
//! Blob-put mutations are NOT modeled here. Blob data is too large
//! to keep in the WAL; the roaming-client design stages blobs on
//! local disk and records a reference to them once the local
//! `BlobPutStream` completes. Ref/tag updates *are* the ordered,
//! small mutations the WAL was built for.
use crate::cluster::refs::{RefKey, RefValue, StampedRef};
use crate::cluster::tags::StampedTagValue;
use anyhow::{bail, Context, Result};
/// Frame version. Bumps only on incompatible changes; adding a new
/// `Kind` is a compatible change (old readers surface it as
/// [`WalMutationError::UnknownKind`]).
pub const FRAME_VERSION: u8 = 0x01;
/// One-byte discriminator for the variants below. Values are the
/// authoritative wire order — never renumber.
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
PutRef = 0x01,
PutRefVersioned = 0x02,
PutTag = 0x03,
PutTagVersioned = 0x04,
DeleteTag = 0x05,
SetTagExpiry = 0x06,
}
impl Kind {
pub fn from_byte(b: u8) -> Option<Self> {
match b {
0x01 => Some(Kind::PutRef),
0x02 => Some(Kind::PutRefVersioned),
0x03 => Some(Kind::PutTag),
0x04 => Some(Kind::PutTagVersioned),
0x05 => Some(Kind::DeleteTag),
0x06 => Some(Kind::SetTagExpiry),
_ => None,
}
}
pub fn as_byte(self) -> u8 {
self as u8
}
}
/// A single client-mode mutation ready to durably record and later
/// replay. `PartialEq` + `Clone` are on the variants so tests + the
/// replay path can compare records without ceremony.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WalMutation {
PutRef { key: RefKey, value: RefValue },
PutRefVersioned { key: RefKey, stamped: StampedRef },
PutTag { key: String, value: [u8; 32] },
PutTagVersioned { key: String, stamped: StampedTagValue },
DeleteTag { key: String },
SetTagExpiry { key: String, expires_at_unix: u64 },
}
/// Distinct decode failures so callers can classify (unknown-kind
/// records get logged & skipped; malformed ones abort replay).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WalMutationError {
Empty,
BadVersion(u8, u8),
UnknownKind(u8),
Malformed(String),
}
impl std::fmt::Display for WalMutationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
WalMutationError::Empty => write!(f, "WAL mutation frame is empty"),
WalMutationError::BadVersion(got, want) => write!(
f,
"WAL mutation frame version {got:#04x} is not supported (expected {want:#04x})"
),
WalMutationError::UnknownKind(k) => {
write!(f, "WAL mutation frame kind {k:#04x} is unknown")
}
WalMutationError::Malformed(msg) => {
write!(f, "WAL mutation body is malformed: {msg}")
}
}
}
}
impl std::error::Error for WalMutationError {}
impl WalMutation {
pub fn kind(&self) -> Kind {
match self {
WalMutation::PutRef { .. } => Kind::PutRef,
WalMutation::PutRefVersioned { .. } => Kind::PutRefVersioned,
WalMutation::PutTag { .. } => Kind::PutTag,
WalMutation::PutTagVersioned { .. } => Kind::PutTagVersioned,
WalMutation::DeleteTag { .. } => Kind::DeleteTag,
WalMutation::SetTagExpiry { .. } => Kind::SetTagExpiry,
}
}
pub fn encode(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(64);
out.push(FRAME_VERSION);
out.push(self.kind().as_byte());
match self {
WalMutation::PutRef { key, value } => {
out.extend_from_slice(key);
out.extend_from_slice(value);
}
WalMutation::PutRefVersioned { key, stamped } => {
out.extend_from_slice(key);
out.extend_from_slice(&stamped.to_bytes());
}
WalMutation::PutTag { key, value } => {
write_len_prefixed_key(&mut out, key);
out.extend_from_slice(value);
}
WalMutation::PutTagVersioned { key, stamped } => {
write_len_prefixed_key(&mut out, key);
out.extend_from_slice(&stamped.to_bytes());
}
WalMutation::DeleteTag { key } => {
write_len_prefixed_key(&mut out, key);
}
WalMutation::SetTagExpiry {
key,
expires_at_unix,
} => {
write_len_prefixed_key(&mut out, key);
out.extend_from_slice(&expires_at_unix.to_le_bytes());
}
}
out
}
pub fn decode(bytes: &[u8]) -> Result<Self, WalMutationError> {
if bytes.len() < 2 {
return Err(WalMutationError::Empty);
}
if bytes[0] != FRAME_VERSION {
return Err(WalMutationError::BadVersion(bytes[0], FRAME_VERSION));
}
let kind = Kind::from_byte(bytes[1])
.ok_or(WalMutationError::UnknownKind(bytes[1]))?;
let body = &bytes[2..];
match kind {
Kind::PutRef => {
let (key, value) = split_pair::<32, 32>(body, "PutRef")?;
Ok(WalMutation::PutRef { key, value })
}
Kind::PutRefVersioned => {
if body.len() != 32 + StampedRef::ENCODED_LEN {
return Err(malformed(format!(
"PutRefVersioned body is {} bytes, need {}",
body.len(),
32 + StampedRef::ENCODED_LEN
)));
}
let mut key = [0u8; 32];
key.copy_from_slice(&body[..32]);
let stamped = StampedRef::from_bytes(&body[32..])
.map_err(|e| malformed(format!("PutRefVersioned stamped: {e}")))?;
Ok(WalMutation::PutRefVersioned { key, stamped })
}
Kind::PutTag => {
let (key, rest) = read_len_prefixed_key(body, "PutTag")?;
if rest.len() != 32 {
return Err(malformed(format!(
"PutTag value is {} bytes, need 32",
rest.len()
)));
}
let mut value = [0u8; 32];
value.copy_from_slice(rest);
Ok(WalMutation::PutTag { key, value })
}
Kind::PutTagVersioned => {
let (key, rest) = read_len_prefixed_key(body, "PutTagVersioned")?;
if rest.len() != StampedTagValue::ENCODED_LEN {
return Err(malformed(format!(
"PutTagVersioned stamped is {} bytes, need {}",
rest.len(),
StampedTagValue::ENCODED_LEN
)));
}
let stamped = StampedTagValue::from_bytes(rest)
.map_err(|e| malformed(format!("PutTagVersioned stamped: {e}")))?;
Ok(WalMutation::PutTagVersioned { key, stamped })
}
Kind::DeleteTag => {
let (key, rest) = read_len_prefixed_key(body, "DeleteTag")?;
if !rest.is_empty() {
return Err(malformed(format!(
"DeleteTag has trailing {} bytes",
rest.len()
)));
}
Ok(WalMutation::DeleteTag { key })
}
Kind::SetTagExpiry => {
let (key, rest) = read_len_prefixed_key(body, "SetTagExpiry")?;
if rest.len() != 8 {
return Err(malformed(format!(
"SetTagExpiry expires_at is {} bytes, need 8",
rest.len()
)));
}
let expires_at_unix = u64::from_le_bytes(rest.try_into().unwrap());
Ok(WalMutation::SetTagExpiry {
key,
expires_at_unix,
})
}
}
}
}
fn write_len_prefixed_key(out: &mut Vec<u8>, key: &str) {
let bytes = key.as_bytes();
out.extend_from_slice(&(bytes.len() as u16).to_le_bytes());
out.extend_from_slice(bytes);
}
fn read_len_prefixed_key<'a>(
body: &'a [u8],
ctx: &'static str,
) -> Result<(String, &'a [u8]), WalMutationError> {
if body.len() < 2 {
return Err(malformed(format!("{ctx}: missing key length prefix")));
}
let key_len = u16::from_le_bytes([body[0], body[1]]) as usize;
let start: usize = 2;
let end = start
.checked_add(key_len)
.ok_or_else(|| malformed(format!("{ctx}: key length overflow")))?;
if body.len() < end {
return Err(malformed(format!(
"{ctx}: declared key_len={key_len} but body has {} bytes after prefix",
body.len() - 2
)));
}
let key = std::str::from_utf8(&body[start..end])
.map_err(|e| malformed(format!("{ctx}: key is not valid UTF-8: {e}")))?
.to_string();
Ok((key, &body[end..]))
}
fn split_pair<const A: usize, const B: usize>(
body: &[u8],
ctx: &'static str,
) -> Result<([u8; A], [u8; B]), WalMutationError> {
if body.len() != A + B {
return Err(malformed(format!(
"{ctx} body is {} bytes, need {}",
body.len(),
A + B
)));
}
let mut a = [0u8; A];
let mut b = [0u8; B];
a.copy_from_slice(&body[..A]);
b.copy_from_slice(&body[A..]);
Ok((a, b))
}
fn malformed(msg: String) -> WalMutationError {
WalMutationError::Malformed(msg)
}
/// Convenience: encode + append to a WAL, returning the assigned
/// seq. Kept out of `WriteAheadLog` itself so the WAL primitive
/// stays payload-agnostic.
pub async fn append_mutation(
wal: &mut crate::cluster::wal::WriteAheadLog,
mutation: &WalMutation,
) -> anyhow::Result<u64> {
let bytes = mutation.encode();
wal.append(&bytes)
.await
.with_context(|| format!("appending {:?} to WAL", mutation.kind()))
}
/// Convenience: replay every WAL record from `start_seq`, decoding
/// each to a `WalMutation`. Unknown-kind records are surfaced so
/// callers can log-and-skip (forward-compat) rather than aborting
/// the whole replay.
pub async fn replay_mutations(
wal: &crate::cluster::wal::WriteAheadLog,
start_seq: u64,
) -> anyhow::Result<Vec<(u64, Result<WalMutation, WalMutationError>)>> {
let recs = wal.iter_from(start_seq).await?;
Ok(recs
.into_iter()
.map(|r| (r.seq, WalMutation::decode(&r.payload)))
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cluster::refs::node_stamp_for;
fn sample_stamped_ref() -> StampedRef {
StampedRef {
value: [0x11; 32],
clock: 42,
node: node_stamp_for("test-node"),
}
}
fn sample_stamped_tag() -> StampedTagValue {
StampedTagValue {
value: [0x22; 32],
clock: 99,
node: node_stamp_for("other-node"),
}
}
fn all_variants() -> Vec<WalMutation> {
vec![
WalMutation::PutRef {
key: [0xAA; 32],
value: [0xBB; 32],
},
WalMutation::PutRefVersioned {
key: [0xCC; 32],
stamped: sample_stamped_ref(),
},
WalMutation::PutTag {
key: "clawverse:main".into(),
value: [0xDD; 32],
},
WalMutation::PutTagVersioned {
key: "clawverse:main:latest".into(),
stamped: sample_stamped_tag(),
},
WalMutation::DeleteTag {
key: "clawverse:main:pr-1".into(),
},
WalMutation::SetTagExpiry {
key: "clawverse:main:latest".into(),
expires_at_unix: 1_800_000_000,
},
]
}
#[test]
fn kind_bytes_stable_and_round_trip() {
assert_eq!(Kind::PutRef.as_byte(), 0x01);
assert_eq!(Kind::PutRefVersioned.as_byte(), 0x02);
assert_eq!(Kind::PutTag.as_byte(), 0x03);
assert_eq!(Kind::PutTagVersioned.as_byte(), 0x04);
assert_eq!(Kind::DeleteTag.as_byte(), 0x05);
assert_eq!(Kind::SetTagExpiry.as_byte(), 0x06);
for m in all_variants() {
assert_eq!(Kind::from_byte(m.kind().as_byte()), Some(m.kind()));
}
}
#[test]
fn round_trip_every_variant() {
for m in all_variants() {
let bytes = m.encode();
assert_eq!(bytes[0], FRAME_VERSION);
assert_eq!(bytes[1], m.kind().as_byte());
let back = WalMutation::decode(&bytes).unwrap();
assert_eq!(back, m);
}
}
#[test]
fn decode_rejects_empty_and_short() {
assert!(matches!(
WalMutation::decode(&[]),
Err(WalMutationError::Empty)
));
assert!(matches!(
WalMutation::decode(&[FRAME_VERSION]),
Err(WalMutationError::Empty)
));
}
#[test]
fn decode_rejects_bad_version() {
let bad = vec![0xFF, Kind::PutRef as u8];
assert!(matches!(
WalMutation::decode(&bad),
Err(WalMutationError::BadVersion(0xFF, FRAME_VERSION))
));
}
#[test]
fn decode_flags_unknown_kind() {
let bad = vec![FRAME_VERSION, 0xEE];
assert!(matches!(
WalMutation::decode(&bad),
Err(WalMutationError::UnknownKind(0xEE))
));
}
#[test]
fn decode_flags_malformed_bodies() {
// PutRef body is exactly 64 bytes; supply 60.
let mut short = vec![FRAME_VERSION, Kind::PutRef as u8];
short.extend(std::iter::repeat(0u8).take(60));
assert!(matches!(
WalMutation::decode(&short),
Err(WalMutationError::Malformed(_))
));
// PutTag with declared key_len larger than actual body.
let mut bad_tag = vec![FRAME_VERSION, Kind::PutTag as u8];
bad_tag.extend_from_slice(&100u16.to_le_bytes()); // key_len=100
bad_tag.extend_from_slice(b"abc"); // only 3 bytes follow
assert!(matches!(
WalMutation::decode(&bad_tag),
Err(WalMutationError::Malformed(_))
));
// DeleteTag with trailing garbage.
let mut bad_del = vec![FRAME_VERSION, Kind::DeleteTag as u8];
bad_del.extend_from_slice(&3u16.to_le_bytes());
bad_del.extend_from_slice(b"abcXX"); // 3-byte key + 2-byte tail
assert!(matches!(
WalMutation::decode(&bad_del),
Err(WalMutationError::Malformed(_))
));
}
#[test]
fn decode_flags_non_utf8_key() {
let mut bytes = vec![FRAME_VERSION, Kind::DeleteTag as u8];
bytes.extend_from_slice(&3u16.to_le_bytes());
bytes.extend_from_slice(&[0xFF, 0xFE, 0xFD]); // invalid UTF-8
assert!(matches!(
WalMutation::decode(&bytes),
Err(WalMutationError::Malformed(_))
));
}
#[tokio::test]
async fn append_and_replay_round_trip() {
let tmp = tempfile::TempDir::new().unwrap();
let mut wal = crate::cluster::wal::WriteAheadLog::open(tmp.path().join("wal"))
.await
.unwrap();
let mutations = all_variants();
for m in &mutations {
let seq = append_mutation(&mut wal, m).await.unwrap();
assert!(seq >= 1);
}
let replayed = replay_mutations(&wal, 0).await.unwrap();
assert_eq!(replayed.len(), mutations.len());
for ((_, decoded), original) in replayed.iter().zip(mutations.iter()) {
assert_eq!(decoded.as_ref().unwrap(), original);
}
}
#[tokio::test]
async fn replay_survives_unknown_kind_records() {
let tmp = tempfile::TempDir::new().unwrap();
let mut wal = crate::cluster::wal::WriteAheadLog::open(tmp.path().join("wal"))
.await
.unwrap();
// Real mutation first.
append_mutation(
&mut wal,
&WalMutation::DeleteTag {
key: "k".into(),
},
)
.await
.unwrap();
// Then a future-kind record the current binary doesn't
// understand. Replay should surface it as Err, not panic.
wal.append(&[FRAME_VERSION, 0x77, 0x01, 0x02]).await.unwrap();
// Real mutation last.
append_mutation(
&mut wal,
&WalMutation::DeleteTag {
key: "k2".into(),
},
)
.await
.unwrap();
let replayed = replay_mutations(&wal, 0).await.unwrap();
assert_eq!(replayed.len(), 3);
assert!(replayed[0].1.is_ok());
assert!(matches!(
replayed[1].1,
Err(WalMutationError::UnknownKind(0x77))
));
assert!(replayed[2].1.is_ok());
}
}