blob: 39b12c84544070d501de810ed2d2f3f2b8b9567b [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
use crate::executor::{TimerHandle, yield_once};
use clock::Clock;
use iggy_binary_protocol::PrepareHeader;
use iggy_common::{IggyTimestamp, variadic};
use journal::superblock::{SuperblockContents, SuperblockStore};
use journal::{Journal, JournalHandle, Storage};
use metadata::MuxStateMachine;
use metadata::stm::stream::Streams;
use metadata::stm::user::Users;
use server_common::{Message, iobuf::Owned};
use std::cell::{Cell, RefCell, UnsafeCell};
use std::collections::HashMap;
use std::ops::RangeInclusive;
/// Fixed synthetic epoch for [`SimClock`]: 2026-01-01T00:00:00Z in micros.
///
/// Virtual time starts at zero; without a base every primary-stamped
/// `created_at` would decode as 1970 and expiry math would sit on the
/// epoch edge. A constant base keeps timestamps realistic while staying
/// a pure function of the seed.
pub const SIM_EPOCH_MICROS: u64 = 1_767_225_600_000_000;
/// Deterministic [`Clock`] over the executor's virtual timeline, injected
/// into every consensus group so prepare timestamps replay with the seed.
#[derive(Debug)]
pub struct SimClock {
timer: TimerHandle,
}
impl SimClock {
#[must_use]
pub const fn new(timer: TimerHandle) -> Self {
Self { timer }
}
}
impl Clock for SimClock {
type Realtime = IggyTimestamp;
fn realtime(&self) -> IggyTimestamp {
IggyTimestamp::from(SIM_EPOCH_MICROS + self.timer.now().as_nanos() / 1_000)
}
}
/// In-memory storage backend for testing/simulation
#[derive(Debug, Default)]
pub struct MemStorage {
data: RefCell<Vec<u8>>,
}
#[allow(clippy::future_not_send)]
impl Storage for MemStorage {
type Buffer = Vec<u8>;
async fn write_at(&self, offset: usize, buf: Self::Buffer) -> std::io::Result<usize> {
let len = buf.len();
let mut data = self.data.borrow_mut();
let end = offset + len;
if end > data.len() {
data.resize(end, 0);
}
data[offset..end].copy_from_slice(&buf);
Ok(len)
}
async fn read_at(
&self,
offset: usize,
mut buffer: Self::Buffer,
) -> std::io::Result<Self::Buffer> {
let data = self.data.borrow();
let end = offset + buffer.len();
if offset < data.len() && end <= data.len() {
buffer[..].copy_from_slice(&data[offset..end]);
}
Ok(buffer)
}
}
// TODO: Replace with actual Journal, the only thing that we will need to change is the `Storage` impl for an in-memory one.
/// Generic in-memory journal implementation for testing/simulation
pub struct SimJournal<S: Storage> {
storage: S,
headers: UnsafeCell<HashMap<u64, PrepareHeader>>,
offsets: UnsafeCell<HashMap<u64, usize>>,
write_offset: Cell<usize>,
/// Highest op appended, `None` when empty. Tracked so a restart reads the
/// retained head in O(1) without scanning `headers` (see
/// [`SimJournal::last_op`]).
last_op: Cell<Option<u64>>,
/// Snapshot watermark. A real value here is what makes `RangeEvicted`
/// reachable; see the `Journal::snapshot_op` impl.
snapshot_op: Cell<u64>,
/// Slots this journal pretends to have, or `None` for unbounded.
///
/// `SnapshotCoordinator::should_checkpoint` gates on `remaining_capacity`, so
/// unbounded means no checkpoint ever. A test wanting one sets a small count.
slot_count: Cell<Option<usize>>,
/// Debug-only single-accessor tripwire. `entry` / `append` hold a
/// [`JournalAccessGuard`] across their whole body, including the storage
/// `.await`, so if a suspending storage tier ever let a second task touch
/// the journal while one is parked mid-read the assert fires before the
/// aliasing `UnsafeCell` access could become UB.
#[cfg(debug_assertions)]
accessing: Cell<bool>,
}
impl<S: Storage + Default> Default for SimJournal<S> {
fn default() -> Self {
Self {
storage: S::default(),
headers: UnsafeCell::new(HashMap::new()),
offsets: UnsafeCell::new(HashMap::new()),
write_offset: Cell::new(0),
last_op: Cell::new(None),
snapshot_op: Cell::new(0),
slot_count: Cell::new(None),
#[cfg(debug_assertions)]
accessing: Cell::new(false),
}
}
}
#[allow(clippy::missing_fields_in_debug)]
impl<S: Storage> std::fmt::Debug for SimJournal<S> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SimJournal")
.field("storage", &"<Storage>")
.field("headers", &"<UnsafeCell>")
.field("offsets", &"<UnsafeCell>")
.field("write_offset", &self.write_offset.get())
.finish()
}
}
/// Debug-only RAII guard enforcing [`SimJournal`]'s single-accessor
/// invariant. Trips if a journal op runs while another is already in flight
/// (the precondition for the `UnsafeCell` borrows in `entry` / `append` to
/// alias). Decrements on drop so an early `?` return or an unwind clears it.
#[cfg(debug_assertions)]
struct JournalAccessGuard<'a>(&'a Cell<bool>);
#[cfg(debug_assertions)]
impl<'a> JournalAccessGuard<'a> {
fn new(flag: &'a Cell<bool>) -> Self {
assert!(
!flag.replace(true),
"SimJournal accessed re-entrantly: a borrow spans an .await while \
another task touches the journal. Sound only while MemStorage \
never suspends and journal access stays pump-serialized; a \
suspending storage tier would make the UnsafeCell access aliasing UB."
);
Self(flag)
}
}
#[cfg(debug_assertions)]
impl Drop for JournalAccessGuard<'_> {
fn drop(&mut self) {
self.0.set(false);
}
}
#[allow(clippy::future_not_send)]
impl<S: Storage<Buffer = Vec<u8>>> Journal for SimJournal<S> {
type Header = PrepareHeader;
type Entry = Message<PrepareHeader>;
type HeaderRef<'a>
= &'a PrepareHeader
where
Self: 'a;
fn last_op(&self) -> Option<u64> {
self.last_op.get()
}
/// Slots left before a checkpoint is forced, mirroring
/// `PrepareJournal::remaining_capacity`: the ring holds `slot_count`, everything
/// at or below the watermark is reclaimable, so `last_op - snapshot_op` is
/// occupied. `None` while unbounded, which `should_checkpoint` reads as never.
fn remaining_capacity(&self) -> Option<usize> {
let slot_count = self.slot_count.get()?;
let Some(last) = self.last_op.get() else {
return Some(slot_count);
};
let snapshot = self.snapshot_op.get();
if last <= snapshot {
return Some(slot_count);
}
let used = usize::try_from(last - snapshot).unwrap_or(usize::MAX);
Some(slot_count.saturating_sub(used))
}
/// Drop the suffix, so a simulated backup whose entries disagree with a started
/// view reconciles the way a real one does. Mirrors
/// `PrepareJournal::truncate_from`, whose watermark stays put; here it never moves.
async fn truncate_from(&self, from_op: u64) -> std::io::Result<usize> {
if from_op == 0 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"truncate_from: ops are 1-based, so 0 would discard the whole journal",
));
}
#[cfg(debug_assertions)]
let _guard = JournalAccessGuard::new(&self.accessing);
let headers = unsafe { &mut *self.headers.get() };
let offsets = unsafe { &mut *self.offsets.get() };
let doomed: Vec<u64> = headers
.keys()
.copied()
.filter(|op| *op >= from_op)
.collect();
for op in &doomed {
headers.remove(op);
offsets.remove(op);
}
self.last_op.set(headers.keys().copied().max());
Ok(doomed.len())
}
/// The snapshot watermark: entries at or below it are evictable.
///
/// Load-bearing even though this journal retains every entry. The repair server
/// floors what it serves at `snapshot_op + 1` and announces the skipped prefix
/// as `RangeEvicted`, the only route into state transfer, so a constant 0 left
/// every state-transfer frame unreachable however the harness was driven.
fn snapshot_op(&self) -> u64 {
self.snapshot_op.get()
}
/// Advance the watermark. Production only moves it forward, on a checkpoint or a
/// transfer install; a retreating floor would re-offer ops the serving side has
/// told a peer are gone.
///
/// # Panics
/// If `op` is below the current watermark, as `PrepareJournal` asserts. Maxing
/// silently would leave the simulator the one place a retreat survives.
fn set_snapshot_op(&self, op: u64) {
let current = self.snapshot_op.get();
assert!(
op >= current,
"snapshot_op must be monotonically increasing: {current} -> {op}"
);
self.snapshot_op.set(op);
}
// TODO(hubcio): validate that the caller's checksum matches the stored
// header - currently this looks up by op only, ignoring the checksum.
// A real journal implementation must reject mismatches.
async fn entry(&self, header: &Self::Header) -> Option<Self::Entry> {
// Single-accessor invariant: the `UnsafeCell` shared borrows below are
// sound only because journal access is single-task (the pump serializes
// it; the off-pump poll path reads an owned PollPlan, never this cell)
// and `MemStorage::read_at` never actually suspends, so no borrow spans
// a real await. Do NOT extend a `headers` / `offsets` borrow past the
// `.await` (e.g. to validate the checksum against the stored header per
// the TODO above): once storage can suspend, a concurrent `append`
// taking `&mut` makes that aliasing UB. The guard makes both
// preconditions loud rather than silently miscompiling.
#[cfg(debug_assertions)]
let _access = JournalAccessGuard::new(&self.accessing);
let headers = unsafe { &*self.headers.get() };
let offsets = unsafe { &*self.offsets.get() };
let header = headers.get(&header.op)?;
let offset = *offsets.get(&header.op)?;
let buffer = self
.storage
.read_at(offset, vec![0; header.size as usize])
.await
.ok()?;
let message = Message::try_from(Owned::<4096>::copy_from_slice(&buffer))
.expect("prepare buffer must contain a valid prepare message");
Some(message)
}
fn previous_header(&self, header: &Self::Header) -> Option<Self::HeaderRef<'_>> {
if header.op == 0 {
return None;
}
unsafe { &*self.headers.get() }.get(&(header.op - 1))
}
async fn append(&self, entry: Self::Entry) -> std::io::Result<()> {
// Held across the write `.await`; see `entry` for the single-accessor
// invariant this tripwire guards.
#[cfg(debug_assertions)]
let _access = JournalAccessGuard::new(&self.accessing);
let header = *entry.header();
let message_bytes = entry.into_frozen();
let offset = self.write_offset.get();
let bytes_written = self
.storage
.write_at(offset, message_bytes.as_slice().to_vec())
.await?;
unsafe { &mut *self.headers.get() }.insert(header.op, header);
unsafe { &mut *self.offsets.get() }.insert(header.op, offset);
self.write_offset.set(offset + bytes_written);
let head = self.last_op.get().map_or(header.op, |op| op.max(header.op));
self.last_op.set(Some(head));
Ok(())
}
fn header(&self, idx: usize) -> Option<Self::HeaderRef<'_>> {
let headers = unsafe { &*self.headers.get() };
headers.get(&(idx as u64))
}
/// Reclaim the prefix a checkpoint superseded, advancing the watermark to the
/// end of the drained range.
///
/// Required, not inherited: the trait's default drains nothing, so a simulated
/// checkpoint left the whole WAL in place, a peer's repair found every op it
/// asked for, and arming the coordinator alone still produced no `RangeEvicted`.
///
/// The watermark moves last, as in `PrepareJournal::drain`: advancing it before
/// the entries are gone would make live entries look evictable.
async fn drain(&self, ops: RangeInclusive<u64>) -> std::io::Result<Vec<Self::Entry>> {
#[cfg(debug_assertions)]
let _guard = JournalAccessGuard::new(&self.accessing);
let end_op = *ops.end();
let doomed: Vec<u64> = {
let headers = unsafe { &*self.headers.get() };
let mut doomed: Vec<u64> = headers
.keys()
.copied()
.filter(|op| ops.contains(op))
.collect();
// Sorted: the trait promises op order, and hash order would make a
// replay of this drain diverge.
doomed.sort_unstable();
doomed
};
let mut drained = Vec::with_capacity(doomed.len());
for op in doomed {
// Read before removing, through `Storage` rather than the
// `MemStorage`-only sync path, so this stays generic. The borrow does
// NOT span the read, unlike `entry`'s: the block ends it and yields only
// `Copy` data, so the `.await` holds no reference into the `UnsafeCell`.
// Deliberate, since `drain` invalidates every outstanding
// `header`/`previous_header` reference too.
let located = {
let headers = unsafe { &*self.headers.get() };
let offsets = unsafe { &*self.offsets.get() };
headers
.get(&op)
.and_then(|header| offsets.get(&op).map(|offset| (header.size, *offset)))
};
// Propagated, not swallowed. Dropping the entry loses a WAL record while
// reporting a successful drain; `PrepareJournal` returns the error and
// poisons itself, and a harness surviving what production refuses to
// cannot find the bug this path exists to catch.
if let Some((size, offset)) = located {
let buffer = self.storage.read_at(offset, vec![0; size as usize]).await?;
let message =
Message::try_from(Owned::<4096>::copy_from_slice(&buffer)).map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("drain: op {op} does not decode as a prepare"),
)
})?;
drained.push(message);
}
let headers = unsafe { &mut *self.headers.get() };
let offsets = unsafe { &mut *self.offsets.get() };
headers.remove(&op);
offsets.remove(&op);
}
if end_op > self.snapshot_op.get() {
self.snapshot_op.set(end_op);
}
Ok(drained)
}
}
impl JournalHandle for SimJournal<MemStorage> {
type Target = Self;
fn handle(&self) -> &Self::Target {
self
}
}
impl SimJournal<MemStorage> {
/// Highest op appended, `None` when empty. Survives a restart because the
/// harness retains the whole journal behind an `Rc`.
#[must_use]
pub const fn last_op(&self) -> Option<u64> {
self.last_op.get()
}
/// Bound this journal to `slots`, so running low forces a checkpoint. Unbounded
/// by default (see `slot_count`), and then nothing produces the snapshot a state
/// transfer serves.
pub fn set_slot_count(&self, slots: usize) {
self.slot_count.set(Some(slots));
}
/// Forget one op, leaving a hole exactly where a lost prepare would.
///
/// Tests only. The alternative is choreographing `Prepare`, `Commit` and
/// `RepairPrepare` drops on a directed link until a replica falls behind, which
/// is fragile to tune; the scenarios are about what a replica does with a hole,
/// not how it got one.
///
/// `last_op` is deliberately left alone: a hole below the head must not look like
/// a shorter log, since that is the state a view change has to survive.
pub fn forget_op(&self, op: u64) -> bool {
#[cfg(debug_assertions)]
let _guard = JournalAccessGuard::new(&self.accessing);
let headers = unsafe { &mut *self.headers.get() };
let offsets = unsafe { &mut *self.offsets.get() };
offsets.remove(&op);
headers.remove(&op).is_some()
}
/// The committed watermark to restore after a restart, mirroring
/// `metadata::recover`. On a solo cluster every appended op commits the instant
/// it is durable, so the head IS the commit point; otherwise the highest
/// `commit` any journaled prepare stamped, a lower bound, since a prepare
/// records the primary's commit point at send time, so the true point may be one
/// op higher and re-commits on rejoin.
///
/// Floored at the snapshot watermark and clamped at the first gap above it, as
/// `metadata::recover` folds from `snapshot_floor` and stops at `chain_break_op`.
/// The fold only sees surviving headers, so a backup missing one prepare would
/// otherwise claim a commit point ABOVE the hole, telling the cluster there is
/// nothing to repair.
#[must_use]
pub fn recovery_commit_watermark(&self, solo: bool) -> u64 {
let floor = self.snapshot_op.get();
let headers = unsafe { &*self.headers.get() };
let claimed = if solo {
self.last_op.get().unwrap_or(0).max(floor)
} else {
headers
.values()
.map(|header| header.commit)
.fold(floor, u64::max)
};
let mut watermark = floor;
while watermark < claimed && headers.contains_key(&(watermark + 1)) {
watermark += 1;
}
watermark
}
/// The head prepare's header, `None` when empty. Restores the last-prepare
/// checksum (hash-chain continuity) and the prepare-timestamp floor at restart,
/// mirroring `restore_metadata_consensus`.
#[must_use]
pub fn last_header(&self) -> Option<PrepareHeader> {
let head = self.last_op.get()?;
let headers = unsafe { &*self.headers.get() };
headers.get(&head).copied()
}
/// Read the entry at `op` synchronously, for off-executor WAL replay at restart.
/// Mirrors [`Journal::entry`] without the never-suspending `MemStorage` await, so
/// it needs no `JournalAccessGuard`: a synchronous read offers no suspension
/// point for another task to interleave on.
///
/// # Panics
/// If the bytes at `op` do not decode as a prepare message, meaning the retained
/// WAL is corrupt, which is a harness bug since the sim has no torn writes.
#[must_use]
pub fn entry_sync(&self, op: u64) -> Option<Message<PrepareHeader>> {
let headers = unsafe { &*self.headers.get() };
let offsets = unsafe { &*self.offsets.get() };
let header = headers.get(&op)?;
let offset = *offsets.get(&op)?;
let data = self.storage.data.borrow();
let end = offset.checked_add(header.size as usize)?;
let buffer = data.get(offset..end)?.to_vec();
let message = Message::try_from(Owned::<4096>::copy_from_slice(&buffer))
.expect("prepare buffer must contain a valid prepare message");
Some(message)
}
}
#[derive(Debug, Default)]
pub struct SimSnapshot {}
/// In-memory superblock for the simulator.
///
/// Held by the harness behind an `Rc` so its bytes survive a replica being dropped
/// and rebuilt across a restart. RAM has no torn writes, so it holds only the latest
/// payload, with none of the framing or checksum `PingPongSuperblock` adds.
#[derive(Debug, Default)]
pub struct SimSuperblock {
latest: RefCell<Option<Vec<u8>>>,
/// Fault injection: when set, every [`SuperblockStore::write`] errors and
/// persists nothing, so `persist_superblock_if_needed` returns `false` and the
/// shard withholds the view-scoped send. Proves the split-brain gate, that a
/// replica never sends in a view it has not durably recorded.
fail_writes: Cell<bool>,
/// Fault injection: when set, every [`SuperblockStore::write`] suspends once
/// before completing. A real superblock persist is an fsync-wide suspension
/// point; the default in-memory write completes on first poll, so nothing can
/// interleave with a persist and every schedule-sensitive bug behind one is
/// invisible to the simulator. The yield restores the window.
yield_writes: Cell<bool>,
}
impl SimSuperblock {
/// Latest persisted payload, read synchronously. The harness reads this off the
/// executor at restart, so no `block_on` is needed.
#[must_use]
pub fn read_latest_sync(&self) -> Option<Vec<u8>> {
self.latest.borrow().clone()
}
/// Make every subsequent write fail, a persistent write fault, so the durability
/// gate withholds view-scoped sends. See `Self::fail_writes`.
pub fn set_fail_writes(&self) {
self.fail_writes.set(true);
}
/// Make every subsequent write suspend once before completing, so tasks that
/// are ready at persist time interleave with it. See `Self::yield_writes`.
pub fn set_yield_writes(&self) {
self.yield_writes.set(true);
}
}
#[allow(clippy::future_not_send)]
impl SuperblockStore for SimSuperblock {
async fn write(&self, payload: &[u8]) -> std::io::Result<()> {
if self.fail_writes.get() {
return Err(std::io::Error::other("sim superblock write fault"));
}
if self.yield_writes.get() {
yield_once().await;
}
*self.latest.borrow_mut() = Some(payload.to_vec());
Ok(())
}
async fn read_latest(&self) -> std::io::Result<SuperblockContents> {
// RAM has no torn writes and the sim never injects an unreadable record, so
// the payload is either present or was never written.
Ok(self
.latest
.borrow()
.as_ref()
.map_or(SuperblockContents::Empty, |payload| {
SuperblockContents::Present(payload.clone())
}))
}
}
/// Type alias for simulator state machine
pub type SimMuxStateMachine = MuxStateMachine<variadic!(Users, Streams)>;