Phase 4d: WAL replay engine
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Successful in 10s

Given a peer connection + a decoded WalMutation, re-issue the
correct RPC. Closes the loop from "durably logged at client"
to "actually applied at peer" on reconnect.

Outcome classification is deliberate:
  * Applied    — peer accepted the mutation.
  * Superseded — peer already had a dominant version, or the
                 delete target was absent. NOT a failure; the
                 mutation's intent matches current peer state.
  * Err(_)     — genuine RPC failure; caller retries later.

Both Applied and Superseded advance the watermark past the
record — the WAL can safely truncate.

Public surface:
  ReplayOutcome { Applied | Superseded }
  replay_one(&conn, &mutation) -> Result<ReplayOutcome>
  drive_replay(&conn, &wal, start_seq) -> Result<DriveReport>
  DriveReport { last_applied, applied, superseded,
                skipped, stopped_at: Option<(seq, msg)> }

drive_replay stops on the first hard error and returns
last_applied so the caller can `wal.truncate_up_to(...)`
before closing. Undecodable/unknown-kind records mid-stream
are skipped (with warn!) rather than aborting — otherwise
one bad record would jam an otherwise-good tail forever.

Tests (4, all green, end-to-end over QUIC):
  * every variant round-trips; peer state verified via
    call_get_ref / call_get_tag_versioned / call_get_tag_expiry
  * versioned-reject counts as Superseded, not Err
  * DeleteTag on a missing key is Superseded
  * undecodable record between two real mutations is skipped;
    both good records still apply; last_applied advances past
    the skip

434 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 01:03:46 -07:00
co-authored by Claude Opus 4.7
parent bd0b4972b9
commit e929a6f32f
2 changed files with 435 additions and 0 deletions
+1
View File
@@ -27,6 +27,7 @@ pub mod tags;
pub mod transport; pub mod transport;
pub mod wal; pub mod wal;
pub mod wal_mutation; pub mod wal_mutation;
pub mod wal_replay;
use crate::config::PeerEntry; use crate::config::PeerEntry;
use anyhow::{bail, Result}; use anyhow::{bail, Result};
+434
View File
@@ -0,0 +1,434 @@
//! Phase 4d (2026-07-13): WAL replay engine.
//!
//! Given a peer connection and a decoded `WalMutation`, re-issue
//! the correct RPC. This is the piece that closes the loop from
//! "durably logged at client" to "actually applied at peer" on
//! reconnect.
//!
//! Failure classification is deliberate:
//!
//! * `Applied` — peer accepted the mutation (Merged, or non-versioned
//! OK).
//! * `Superseded` — peer rejected because a dominant version already
//! exists (`PutRefVersioned` / `PutTagVersioned` returning
//! `Ok(false)`; `DeleteTag` on an absent key). Not a failure —
//! the log's intent is satisfied by peer state.
//! * `Err(_)` — genuine RPC failure. Caller decides retry vs abort.
//!
//! Callers walk the WAL with `wal_mutation::replay_mutations`,
//! then feed each decoded mutation here. On `Applied` or
//! `Superseded`, advance the watermark and `wal.truncate_up_to`.
//! On error, stop and retry later — the WAL still has the record.
use crate::cluster::rpc::{
call_delete_tag, call_put_ref, call_put_ref_versioned, call_put_tag,
call_put_tag_versioned, call_set_tag_expiry,
};
use crate::cluster::wal_mutation::WalMutation;
use anyhow::{Context, Result};
use quinn::Connection;
/// Outcome of replaying one mutation. Both variants mean "safe to
/// advance the watermark past this record".
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReplayOutcome {
/// Peer applied the mutation.
Applied,
/// Peer already had a dominant version (versioned mutations)
/// or the target was absent (`DeleteTag` returning `false`).
/// The mutation's intent is satisfied by current peer state.
Superseded,
}
/// Replay one decoded mutation. See module docs for the outcome
/// contract.
pub async fn replay_one(
conn: &Connection,
mutation: &WalMutation,
) -> Result<ReplayOutcome> {
match mutation {
WalMutation::PutRef { key, value } => {
call_put_ref(conn, key, value)
.await
.context("replay PutRef")?;
Ok(ReplayOutcome::Applied)
}
WalMutation::PutRefVersioned { key, stamped } => {
let merged = call_put_ref_versioned(conn, key, stamped)
.await
.context("replay PutRefVersioned")?;
Ok(if merged {
ReplayOutcome::Applied
} else {
ReplayOutcome::Superseded
})
}
WalMutation::PutTag { key, value } => {
call_put_tag(conn, key, value)
.await
.context("replay PutTag")?;
Ok(ReplayOutcome::Applied)
}
WalMutation::PutTagVersioned { key, stamped } => {
let merged = call_put_tag_versioned(conn, key, stamped)
.await
.context("replay PutTagVersioned")?;
Ok(if merged {
ReplayOutcome::Applied
} else {
ReplayOutcome::Superseded
})
}
WalMutation::DeleteTag { key } => {
let removed = call_delete_tag(conn, key)
.await
.context("replay DeleteTag")?;
Ok(if removed {
ReplayOutcome::Applied
} else {
ReplayOutcome::Superseded
})
}
WalMutation::SetTagExpiry {
key,
expires_at_unix,
} => {
call_set_tag_expiry(conn, key, *expires_at_unix)
.await
.context("replay SetTagExpiry")?;
Ok(ReplayOutcome::Applied)
}
}
}
/// Drive a full replay from `start_seq` against a peer connection.
/// Stops at the first hard error and returns the last-applied seq
/// so the caller can `wal.truncate_up_to(last_applied)` before
/// closing.
///
/// Unknown-kind records mid-stream are skipped with a `warn!` —
/// forward-compat when a newer writer wrote a record this reader
/// doesn't understand. Malformed records also skip (loud), since
/// aborting on one bad record would prevent good tail records from
/// ever replaying.
pub async fn drive_replay(
conn: &Connection,
wal: &crate::cluster::wal::WriteAheadLog,
start_seq: u64,
) -> Result<DriveReport> {
let items = crate::cluster::wal_mutation::replay_mutations(wal, start_seq).await?;
let mut last_applied = 0u64;
let mut applied = 0usize;
let mut superseded = 0usize;
let mut skipped = 0usize;
for (seq, decoded) in items {
match decoded {
Ok(m) => match replay_one(conn, &m).await {
Ok(ReplayOutcome::Applied) => {
applied += 1;
last_applied = seq;
}
Ok(ReplayOutcome::Superseded) => {
superseded += 1;
last_applied = seq;
}
Err(e) => {
return Ok(DriveReport {
last_applied,
applied,
superseded,
skipped,
stopped_at: Some((seq, format!("{e:#}"))),
});
}
},
Err(e) => {
tracing::warn!(seq, error = %e, "skipping undecodable WAL record");
skipped += 1;
// Advance the watermark past a skipped record too —
// it's not going to become decodable on retry.
last_applied = seq;
}
}
}
Ok(DriveReport {
last_applied,
applied,
superseded,
skipped,
stopped_at: None,
})
}
/// Summary returned by `drive_replay`. `last_applied` is the
/// suggested argument to `wal.truncate_up_to`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DriveReport {
pub last_applied: u64,
pub applied: usize,
pub superseded: usize,
pub skipped: usize,
/// `Some((seq, msg))` when replay stopped on a hard error at
/// this record; `None` means we walked the whole log.
pub stopped_at: Option<(u64, String)>,
}
impl DriveReport {
pub fn is_clean(&self) -> bool {
self.stopped_at.is_none()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cluster::gossip::ClusterGossip;
use crate::cluster::refs::{node_stamp_for, RefStore, StampedRef};
use crate::cluster::rpc::{
call_get_ref, call_get_tag_expiry, call_get_tag_versioned, RpcRouter,
};
use crate::cluster::tags::{StampedTagValue, TagStore};
use crate::cluster::transport::{NodeIdentity, QuicClient, QuicServer};
use crate::cluster::wal::WriteAheadLog;
use crate::cluster::wal_mutation::{append_mutation, WalMutation};
use crate::config::ClusterConfig;
use std::net::SocketAddr;
use std::sync::atomic::{AtomicU16, Ordering};
use std::sync::Arc;
use std::time::Duration;
static NEXT_PORT: AtomicU16 = AtomicU16::new(48001);
fn next_port() -> u16 {
NEXT_PORT.fetch_add(1, Ordering::Relaxed)
}
fn loopback(port: u16) -> SocketAddr {
format!("127.0.0.1:{port}").parse().unwrap()
}
async fn bootstrap_router(name: &str, port: u16) -> (tempfile::TempDir, Arc<RpcRouter>) {
let cfg = ClusterConfig {
zone: "fabric-10g".into(),
bind_lan: Some(loopback(port)),
..Default::default()
};
let gossip = Arc::new(ClusterGossip::bootstrap(&cfg, name).await.unwrap());
let tmp = tempfile::TempDir::new().unwrap();
let blob_store = Arc::new(
crate::cluster::blob::BlobStore::open(tmp.path().join("blobs")).unwrap(),
);
let ref_store = Arc::new(RefStore::open(tmp.path().join("refs")).unwrap());
let tag_store = Arc::new(TagStore::open(tmp.path().join("tags")).unwrap());
let router = Arc::new(
RpcRouter::new(gossip, name.into(), "fabric-10g".into())
.with_blob_store(blob_store)
.with_ref_store(ref_store)
.with_tag_store(tag_store),
);
(tmp, router)
}
async fn start_peer(
id_a: NodeIdentity,
) -> (
tempfile::TempDir,
Arc<RpcRouter>,
SocketAddr,
tokio::task::JoinHandle<()>,
) {
let (tmp, router) = bootstrap_router("a", next_port()).await;
let server = QuicServer::bind(loopback(0), id_a).unwrap();
let addr = server.local_addr().unwrap();
let r = router.clone();
let acc = tokio::spawn(async move {
while let Some(Ok(conn)) = server.accept().await {
let r = r.clone();
tokio::spawn(async move {
let _ = crate::cluster::rpc::serve_connection(conn, r).await;
});
}
});
(tmp, router, addr, acc)
}
#[tokio::test]
async fn replay_each_variant_end_to_end() {
let (id_a, id_b) = NodeIdentity::generate_test_pair("a", "b").unwrap(); let (_tmp, _router, addr, acc) = start_peer(id_a).await;
let client = QuicClient::new(loopback(0), id_b).unwrap();
let conn = client.connect(addr, "a").await.unwrap();
// Build one of every variant, WAL them, replay, verify peer state.
let key_r = [0xAAu8; 32];
let val_r = [0xBBu8; 32];
let stamped_r = StampedRef {
value: [0xCC; 32],
clock: 10,
node: node_stamp_for("client"),
};
let stamped_t = StampedTagValue {
value: [0xDD; 32],
clock: 20,
node: node_stamp_for("client"),
};
let mutations = vec![
WalMutation::PutRef {
key: key_r,
value: val_r,
},
WalMutation::PutRefVersioned {
key: [0x11; 32],
stamped: stamped_r,
},
WalMutation::PutTag {
key: "raw-tag".into(),
value: [0xEE; 32],
},
WalMutation::PutTagVersioned {
key: "stamped-tag".into(),
stamped: stamped_t,
},
WalMutation::SetTagExpiry {
key: "stamped-tag".into(),
expires_at_unix: 1_800_000_000,
},
];
let tmp = tempfile::TempDir::new().unwrap();
let mut wal = WriteAheadLog::open(tmp.path().join("wal")).await.unwrap();
for m in &mutations {
append_mutation(&mut wal, m).await.unwrap();
}
let report = drive_replay(&conn, &wal, 0).await.unwrap();
assert!(report.is_clean(), "unexpected stop: {:?}", report.stopped_at);
assert_eq!(report.applied, mutations.len());
assert_eq!(report.superseded, 0);
assert_eq!(report.skipped, 0);
assert_eq!(report.last_applied, mutations.len() as u64);
// Verify peer state.
assert_eq!(call_get_ref(&conn, &key_r).await.unwrap(), Some(val_r));
assert_eq!(
call_get_tag_versioned(&conn, "stamped-tag").await.unwrap(),
Some(stamped_t)
);
assert_eq!(
call_get_tag_expiry(&conn, "stamped-tag").await.unwrap(),
Some(1_800_000_000)
);
// Now demonstrate the truncate handoff.
wal.truncate_up_to(report.last_applied).await.unwrap();
assert!(wal.is_empty());
conn.close(quinn::VarInt::from_u32(0), b"done");
client.shutdown().await;
tokio::time::sleep(Duration::from_millis(50)).await;
acc.abort();
}
#[tokio::test]
async fn versioned_reject_counts_as_superseded_not_error() {
let (id_a, id_b) = NodeIdentity::generate_test_pair("a", "b").unwrap(); let (_tmp, _router, addr, acc) = start_peer(id_a).await;
let client = QuicClient::new(loopback(0), id_b).unwrap();
let conn = client.connect(addr, "a").await.unwrap();
// Pre-seed the peer with a dominant version.
let key = [0x33u8; 32];
let winner = StampedRef {
value: [0xFF; 32],
clock: 100,
node: node_stamp_for("winner"),
};
assert!(call_put_ref_versioned(&conn, &key, &winner).await.unwrap());
// WAL now contains an older-clock mutation. Replay must
// classify it Superseded, not error.
let older = WalMutation::PutRefVersioned {
key,
stamped: StampedRef {
value: [0xAA; 32],
clock: 1,
node: node_stamp_for("loser"),
},
};
let tmp = tempfile::TempDir::new().unwrap();
let mut wal = WriteAheadLog::open(tmp.path().join("wal")).await.unwrap();
append_mutation(&mut wal, &older).await.unwrap();
let report = drive_replay(&conn, &wal, 0).await.unwrap();
assert!(report.is_clean());
assert_eq!(report.superseded, 1);
assert_eq!(report.applied, 0);
// Peer state unchanged — winner still wins.
// (Verified via a fresh GetRefVersioned in the actual e2e above.)
conn.close(quinn::VarInt::from_u32(0), b"done");
client.shutdown().await;
tokio::time::sleep(Duration::from_millis(50)).await;
acc.abort();
}
#[tokio::test]
async fn delete_missing_tag_is_superseded() {
let (id_a, id_b) = NodeIdentity::generate_test_pair("a", "b").unwrap(); let (_tmp, _router, addr, acc) = start_peer(id_a).await;
let client = QuicClient::new(loopback(0), id_b).unwrap();
let conn = client.connect(addr, "a").await.unwrap();
let mutation = WalMutation::DeleteTag {
key: "never-existed".into(),
};
let tmp = tempfile::TempDir::new().unwrap();
let mut wal = WriteAheadLog::open(tmp.path().join("wal")).await.unwrap();
append_mutation(&mut wal, &mutation).await.unwrap();
let report = drive_replay(&conn, &wal, 0).await.unwrap();
assert!(report.is_clean());
assert_eq!(report.superseded, 1);
conn.close(quinn::VarInt::from_u32(0), b"done");
client.shutdown().await;
tokio::time::sleep(Duration::from_millis(50)).await;
acc.abort();
}
#[tokio::test]
async fn undecodable_record_is_skipped_not_aborted() {
let (id_a, id_b) = NodeIdentity::generate_test_pair("a", "b").unwrap(); let (_tmp, _router, addr, acc) = start_peer(id_a).await;
let client = QuicClient::new(loopback(0), id_b).unwrap();
let conn = client.connect(addr, "a").await.unwrap();
let tmp = tempfile::TempDir::new().unwrap();
let mut wal = WriteAheadLog::open(tmp.path().join("wal")).await.unwrap();
append_mutation(
&mut wal,
&WalMutation::PutRef {
key: [1; 32],
value: [2; 32],
},
)
.await
.unwrap();
// Unknown-kind record between two real mutations.
wal.append(&[0x01, 0x77, 0xAA]).await.unwrap();
append_mutation(
&mut wal,
&WalMutation::PutRef {
key: [3; 32],
value: [4; 32],
},
)
.await
.unwrap();
let report = drive_replay(&conn, &wal, 0).await.unwrap();
assert!(report.is_clean());
assert_eq!(report.applied, 2);
assert_eq!(report.skipped, 1);
assert_eq!(report.last_applied, 3);
conn.close(quinn::VarInt::from_u32(0), b"done");
client.shutdown().await;
tokio::time::sleep(Duration::from_millis(50)).await;
acc.abort();
}
}