Phase 4d: WAL replay engine
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Successful in 10s
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:
co-authored by
Claude Opus 4.7
parent
bd0b4972b9
commit
e929a6f32f
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user