//! The DST sweep that drives the real `LogStore` (logstore.zig) through
//! `FaultyFile`, asserting the four crash-recovery invariants
//! (storage-track-plan.md §B). `run` is exercised by `zig build dst`, not
//! a `zig build test` unit — the sweep IS the gate.
//!
//! The model tracks WHAT WAS WRITTEN (hash, bytes, and the store's own
//! `size()` right after each successful append) rather than re-deriving
//! frame-layout byte offsets itself: LogStore owns that arithmetic, and an
//! independent oracle should observe outcomes through the public
//! hash-addressed API, not reimplement wire format math that could share
//! the same bug.
const std = @import("std");
const storage = @import("../storage.zig");
const faulty_file = @import("faulty_file.zig");
const logstore = @import("../logstore.zig");
const hash_len = 32; // sha256 digest
const Frame = struct {
hash: [hash_len]u8,
bytes: []u8, // owned
/// `store.size()` immediately before this put's frame was appended.
frame_start: u64,
/// `store.size()` immediately after -- the structural boundary a
/// clean recovery of this frame implies. `frame_end - frame_start`
/// includes trailing zero-pad; the pad is excluded from both crcs
/// (logstore.zig's wire format), so it's inert and never read back --
/// a corrupt offset landing there is harmless, not silent corruption.
frame_end: u64,
};
/// Ground-truth bookkeeping: every content-bearing put in append order,
/// how many of them are known-durable, and every intern-only (phantom)
/// hash, which must never appear on disk.
const Model = struct {
frames: std.ArrayList(Frame) = .empty,
acked_count: usize = 0,
phantoms: std.ArrayList([hash_len]u8) = .empty,
fn deinit(self: *Model, gpa: std.mem.Allocator) void {
for (self.frames.items) |f| gpa.free(f.bytes);
self.frames.deinit(gpa);
self.phantoms.deinit(gpa);
self.* = undefined;
}
fn recordPut(self: *Model, gpa: std.mem.Allocator, hash: [hash_len]u8, bytes: []const u8, frame_start: u64, frame_end: u64) error{OutOfMemory}!void {
const owned = try gpa.dupe(u8, bytes);
errdefer gpa.free(owned);
try self.frames.append(gpa, .{ .hash = hash, .bytes = owned, .frame_start = frame_start, .frame_end = frame_end });
}
fn recordPhantom(self: *Model, gpa: std.mem.Allocator, hash: [hash_len]u8) error{OutOfMemory}!void {
try self.phantoms.append(gpa, hash);
}
/// Drops the phantom at `idx` once it has gained content (order among
/// phantoms is immaterial, so a swap-remove is exact and O(1)).
fn removePhantomAt(self: *Model, idx: usize) void {
std.debug.assert(idx < self.phantoms.items.len);
_ = self.phantoms.swapRemove(idx);
}
/// Acked frames only grow, and only past a frame end the disk has
/// actually promised durable.
fn recomputeAcked(self: *Model, durable_len: u64) void {
while (self.acked_count < self.frames.items.len and
self.frames.items[self.acked_count].frame_end <= durable_len)
{
self.acked_count += 1;
}
}
/// Drops every frame the log didn't actually keep, matching what a
/// fresh recovery just proved survived.
fn truncateTo(self: *Model, gpa: std.mem.Allocator, survived: usize) void {
std.debug.assert(survived <= self.frames.items.len);
for (self.frames.items[survived..]) |f| gpa.free(f.bytes);
self.frames.shrinkRetainingCapacity(survived);
self.acked_count = @min(self.acked_count, survived);
}
};
pub const SweepError = error{
OutOfMemory,
Io,
/// Invariant 1: recovery lost a frame the model had proof was durable.
MissingAcknowledged,
/// Invariant 1/2: a frame beyond the determined survived boundary is
/// nonetheless present -- not a clean prefix -- or a phantom-only
/// hash was found durable (phantoms must never touch disk).
PhantomFrame,
/// Invariant 2: the post-recovery structural size disagrees with the
/// model's own record of where the last surviving frame ended.
IndexMismatch,
/// Invariant 1/4: a surviving frame's content or hash disagrees with
/// what was written.
HashMismatch,
/// Invariant 4: a corrupt offset lies inside the recovered span --
/// validated data that isn't actually trustworthy.
SilentCorruption,
};
fn sha256(payload: []const u8) [hash_len]u8 {
var out: [hash_len]u8 = undefined;
std.crypto.hash.sha2.Sha256.hash(payload, &out, .{});
return out;
}
fn putOnce(gpa: std.mem.Allocator, store: *logstore.LogStore, model: *Model, random: std.Random) SweepError!void {
var buf: [64]u8 = undefined;
const n = 1 + random.uintLessThan(usize, buf.len - 1);
random.bytes(buf[0..n]);
const payload = buf[0..n];
std.debug.assert(payload.len <= logstore.max_payload / 1000); // sweep payloads stay well under the 1 GiB bound
const hash = sha256(payload);
const before = store.size();
if (store.put(&hash, payload)) |_| {
const after = store.size();
// LogStore.put is idempotent for an already-content-bearing hash:
// it appends nothing and returns the existing rid (self.end
// unchanged). The sweep's small payload space (1..63 random
// bytes) makes that collision real within a single run, so only
// a put that actually landed a frame grows the model -- a
// same-hash repeat is already covered by the frame recorded the
// first time, and recording it again would fabricate a second
// model frame the log never wrote.
if (after != before) {
try model.recordPut(gpa, hash, payload, before, after);
}
} else |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.Storage => {}, // torn/short write: LogStore already rolled back
}
}
fn internOnce(gpa: std.mem.Allocator, store: *logstore.LogStore, model: *Model, random: std.Random) SweepError!void {
var seed_bytes: [16]u8 = undefined;
random.bytes(&seed_bytes);
const hash = sha256(&seed_bytes);
_ = try store.intern(&hash);
try model.recordPhantom(gpa, hash);
}
/// Content arrives for a hash the store already knows only as a phantom: the
/// core ArtifactStore lifecycle (intern a hash, put its bytes later). Picks a
/// live phantom so `put` takes the phantom-upgrade branch, then folds the
/// upgraded frame into the durability model exactly like any other put.
fn upgradeOnce(gpa: std.mem.Allocator, store: *logstore.LogStore, model: *Model, random: std.Random) SweepError!void {
std.debug.assert(model.phantoms.items.len > 0); // caller-guarded supply
const idx = random.uintLessThan(usize, model.phantoms.items.len);
const hash = model.phantoms.items[idx]; // live phantom -> hits put's upgrade branch
var buf: [64]u8 = undefined;
const n = 1 + random.uintLessThan(usize, buf.len - 1);
random.bytes(buf[0..n]);
const payload = buf[0..n];
std.debug.assert(payload.len <= logstore.max_payload / 1000);
// Content-addressing is the caller's contract; the sweep only requires that
// content() round-trips whatever bytes were put under this rid, so the
// payload need not hash to `hash`.
const before = store.size();
if (store.put(&hash, payload)) |_| {
const after = store.size();
std.debug.assert(after != before); // a phantom upgrade always appends its first frame
try model.recordPut(gpa, hash, payload, before, after);
model.removePhantomAt(idx);
} else |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.Storage => {}, // torn write rolled back: entry stays a phantom in store and model
}
}
/// Determines the survived prefix (recovery only ever drops a suffix),
/// then checks all four invariants against `model` and advances it to
/// match reality.
fn checkInvariants(
gpa: std.mem.Allocator,
faulty: *faulty_file.FaultyFile,
model: *Model,
store: *logstore.LogStore,
guaranteed: usize,
) SweepError!void {
const total = model.frames.items.len;
var survived: usize = 0;
while (survived < total and store.lookup(&model.frames.items[survived].hash) != null) : (survived += 1) {}
if (survived < guaranteed) return error.MissingAcknowledged;
var i: usize = survived;
while (i < total) : (i += 1) {
if (store.lookup(&model.frames.items[i].hash) != null) return error.PhantomFrame;
}
i = 0;
while (i < survived) : (i += 1) {
const f = &model.frames.items[i];
const rid = store.lookup(&f.hash) orelse return error.MissingAcknowledged;
if (!std.mem.eql(u8, store.hashOf(rid), &f.hash)) return error.HashMismatch;
const bytes = store.content(gpa, rid) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
error.Storage => return error.Io,
error.Corrupt => return error.HashMismatch,
} orelse return error.HashMismatch;
defer gpa.free(bytes);
if (!std.mem.eql(u8, bytes, f.bytes)) return error.HashMismatch;
}
for (model.phantoms.items) |h| {
if (store.lookup(&h) != null) return error.PhantomFrame;
}
// A reopen wiped every in-memory phantom (phantoms never touch disk, §C),
// so the live phantom set resets each crash.
model.phantoms.clearRetainingCapacity();
const want_size: u64 = if (survived == 0) logstore.header_len else model.frames.items[survived - 1].frame_end;
if (store.size() != want_size) return error.IndexMismatch;
// No corrupt offset may lie within a surviving frame's MEANINGFUL span
// (its crc-covered bytes, 22+hash_len+payload_len from frame_start) --
// that would mean validated data isn't actually trustworthy. Trailing
// zero-pad up to the 8-byte boundary is explicitly excluded from both
// crcs (logstore.zig) and never read back, so corruption landing only
// in pad is inert, not silent corruption.
var j: usize = 0;
while (j < survived) : (j += 1) {
const f = &model.frames.items[j];
const meaningful_end = f.frame_start + 22 + @as(u64, f.hash.len) + f.bytes.len;
for (faulty.corrupt.items) |off| {
if (off >= f.frame_start and off < meaningful_end) return error.SilentCorruption;
}
}
model.truncateTo(gpa, survived);
}
/// A corrupt offset is only ever the payload-derived bitflip fault
/// (storage-track-plan.md §B) -- nothing else in the simulator flips
/// bits. Repair by truncating below the earliest one: a header-region hit
/// (< header_len) makes the whole log untrustworthy (nothing downstream
/// of a bad header can be validated), so that wipes to empty; a
/// frame-region hit keeps every frame that ended before it.
fn repairAfterCorrupt(gpa: std.mem.Allocator, faulty: *faulty_file.FaultyFile, model: *Model) SweepError!void {
std.debug.assert(faulty.corrupt.items.len > 0);
var min_off: u64 = faulty.corrupt.items[0];
for (faulty.corrupt.items) |o| {
if (o < min_off) min_off = o;
}
if (min_off < logstore.header_len) {
faulty.storageFile().truncate(0) catch return error.Io;
model.truncateTo(gpa, 0);
return;
}
var safe: usize = 0;
while (safe < model.frames.items.len and model.frames.items[safe].frame_end <= min_off) : (safe += 1) {}
const cut: u64 = if (safe == 0) logstore.header_len else model.frames.items[safe - 1].frame_end;
faulty.storageFile().truncate(cut) catch return error.Io;
model.truncateTo(gpa, safe);
}
/// Simulates power loss, reopens, and checks the four invariants,
/// repairing (and rechecking) once if the open itself reports corruption.
/// Returns the freshly opened store, which the caller now owns.
fn crashCheckOnce(gpa: std.mem.Allocator, faulty: *faulty_file.FaultyFile, model: *Model) SweepError!logstore.LogStore {
faulty.crashRestart();
model.recomputeAcked(faulty.durable_len);
var opened = logstore.LogStore.open(gpa, faulty.storageFile(), 0);
if (opened) |*store| {
checkInvariants(gpa, faulty, model, store, model.acked_count) catch |e| {
store.deinit(gpa);
return e;
};
return store.*;
} else |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.Storage => return error.Io,
error.Corrupt => {},
}
std.debug.assert(faulty.corrupt.items.len > 0);
try repairAfterCorrupt(gpa, faulty, model);
var store2 = logstore.LogStore.open(gpa, faulty.storageFile(), 0) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
else => return error.Io,
};
checkInvariants(gpa, faulty, model, &store2, model.acked_count) catch |e| {
store2.deinit(gpa);
return e;
};
return store2;
}
/// The two-phase sweep (mirrors dst.zig's repostate phases): phase 1
/// drives random puts/interns/syncs/crashes with faults live, checking
/// the four recovery invariants after every crash; phase 2 heals the
/// disk and proves one final clean crash+recover converges on the full
/// model.
pub fn run(gpa: std.mem.Allocator, seed: u64, steps: usize) SweepError!void {
var prng = std.Random.DefaultPrng.init(seed);
const random = prng.random();
var faulty = faulty_file.FaultyFile.init(gpa, random);
defer faulty.deinit();
var model: Model = .{};
defer model.deinit(gpa);
// The very first open runs against a clean disk (cfg is still the
// all-zero, fault-free default) -- the four invariants are about
// *recovering* from a crash, not about whether the log can even be
// created under a lossy first write, and LogStore.open has no
// retry/tolerance path for that (nor should it: a fresh empty file
// is never "acknowledged" data to lose). Faults engage only once the
// store exists.
var store = logstore.LogStore.open(gpa, faulty.storageFile(), 0) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
else => return error.Io,
};
faulty.cfg = .{
.short_write_pct = 8,
.lost_tail_sync_pct = 8,
.crash_cut_at_durable_pct = 15,
.crash_rot_pct = 6,
.sector_round = 512,
.sector_round_pct = 25,
};
const crash_every: usize = 41;
var step: usize = 0;
while (step < steps) : (step += 1) {
switch (random.uintLessThan(u8, 4)) {
0, 1 => putOnce(gpa, &store, &model, random) catch |e| {
store.deinit(gpa);
return e;
},
2 => internOnce(gpa, &store, &model, random) catch |e| {
store.deinit(gpa);
return e;
},
// Upgrade a live phantom when one exists, else keep the phantom
// supply flowing so the branch has fuel next time.
3 => if (model.phantoms.items.len > 0)
upgradeOnce(gpa, &store, &model, random) catch |e| {
store.deinit(gpa);
return e;
}
else
internOnce(gpa, &store, &model, random) catch |e| {
store.deinit(gpa);
return e;
},
else => unreachable, // uintLessThan(u8, 4) yields 0..3
}
if (random.boolean()) {
store.commit() catch {};
model.recomputeAcked(faulty.durable_len);
}
if (step % crash_every == crash_every - 1) {
store.deinit(gpa);
store = crashCheckOnce(gpa, &faulty, &model) catch |e| return e;
}
}
store.deinit(gpa);
// Phase 2 (liveness): faults healed, everything becomes durable, and a
// harmless crash (nothing left unsynced) must recover the full model.
faulty.cfg = .{};
{
var healed = logstore.LogStore.open(gpa, faulty.storageFile(), 0) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
else => return error.Io,
};
healed.commit() catch {};
healed.deinit(gpa);
}
model.recomputeAcked(faulty.durable_len);
std.debug.assert(model.acked_count == model.frames.items.len);
var final_store = try crashCheckOnce(gpa, &faulty, &model);
final_store.deinit(gpa);
std.debug.assert(model.acked_count == model.frames.items.len);
}