Skip to content
zit Artifact [5b53ce4b3c]
Login

Artifact [5b53ce4b3c]

Artifact 5b53ce4b3c8013aced9e2453ebc0c896065eb6030e68f700849e381b9622f22e:


//! Deterministic storage fault simulator (storage-track-plan.md §B). One
//! `StorageFile` implementation whose every fault is a draw from the run's
//! `std.Random` — the seed is still the whole universe, this just gives the
//! seed a device to lie through.
//!
//! Native design, no RFC section: storage-track-plan.md §B plus the four
//! crash-recovery invariants are the oracle, checked by
//! sim/storage_sweep.zig's DST sweep.

const std = @import("std");
const storage = @import("zit_storage");

/// Per-fault trigger probabilities, each a percent in `0..=100` drawn
/// against fresh bits from the run PRNG. All zero (the default) is a
/// perfectly healthy disk — DST phase 2 "heals" by resetting to this.
pub const FaultCfg = struct {
    /// append() persists a strict prefix (`k < n`) and returns error.Io.
    short_write_pct: u8 = 0,
    /// sync() reports success without moving `durable_len` — the next
    /// crash then drops everything it claimed to have saved.
    lost_tail_sync_pct: u8 = 0,
    /// On crashRestart, force the cut exactly at `durable_len` (tear the
    /// whole unsynced tail) instead of a random point within it.
    crash_cut_at_durable_pct: u8 = 0,
    /// On crashRestart, additionally seed a few bytes before the cut as
    /// corrupt (payload-derived bitflip, never self-heals).
    crash_rot_pct: u8 = 0,
    /// When the cut isn't forced to `durable_len`, sometimes round it down
    /// to a sector boundary (torn mid-sector writes are the common case on
    /// real disks).
    sector_round: u64 = 512,
    sector_round_pct: u8 = 0,
};

/// One `StorageFile` implementation over an in-memory byte buffer, standing
/// in for a real disk that can tear writes, lie about fsync, and flip bits.
pub const FaultyFile = struct {
    gpa: std.mem.Allocator,
    random: std.Random,
    bytes: std.ArrayList(u8),
    /// Watermark: bytes in `[0, durable_len)` survived the last true fsync.
    /// Monotonic non-decreasing for the life of the file — sync only moves
    /// it forward, and a crash cut is always chosen `>= durable_len`.
    durable_len: u64,
    /// Sorted, deduped offsets that read back flipped. Membership is
    /// permanent once set — the corresponding fault never self-heals.
    corrupt: std.ArrayList(u64),
    cfg: FaultCfg,

    /// Explicit upper bound on the simulated file (storage-track-plan.md
    /// §B): keeps the sweep's growth bounded and provable, not merely
    /// convenient.
    pub const max_bytes: u64 = 64 << 20;

    pub fn init(gpa: std.mem.Allocator, random: std.Random) FaultyFile {
        return .{
            .gpa = gpa,
            .random = random,
            .bytes = .empty,
            .durable_len = 0,
            .corrupt = .empty,
            .cfg = .{},
        };
    }

    pub fn deinit(self: *FaultyFile) void {
        self.bytes.deinit(self.gpa);
        self.corrupt.deinit(self.gpa);
        self.* = undefined;
    }

    pub fn storageFile(self: *FaultyFile) storage.StorageFile {
        return .{ .context = self, .vtable = &vtable };
    }

    /// Simulate power loss and reopen: pick a cut in `[durable_len, len]`
    /// (weighted toward exactly `durable_len`, sometimes sector-rounded),
    /// drop everything past it, and maybe rot a few bytes before it.
    pub fn crashRestart(self: *FaultyFile) void {
        std.debug.assert(self.durable_len <= self.bytes.items.len);
        const unsynced = self.bytes.items.len - self.durable_len;
        var cut: u64 = self.durable_len;
        if (unsynced > 0 and !drawPct(self.random, self.cfg.crash_cut_at_durable_pct)) {
            cut += self.random.uintAtMost(u64, unsynced);
            if (cut > self.durable_len and self.cfg.sector_round != 0 and
                drawPct(self.random, self.cfg.sector_round_pct))
            {
                const rounded = (cut / self.cfg.sector_round) * self.cfg.sector_round;
                cut = @max(rounded, self.durable_len);
            }
        }
        std.debug.assert(cut >= self.durable_len and cut <= self.bytes.items.len);

        if (cut > 0 and drawPct(self.random, self.cfg.crash_rot_pct)) {
            const budget = @min(cut, 4);
            const count = 1 + self.random.uintLessThan(u64, budget);
            var i: u64 = 0;
            while (i < count) : (i += 1) self.addCorrupt(self.random.uintLessThan(u64, cut));
        }

        self.bytes.shrinkRetainingCapacity(@intCast(cut));
        self.durable_len = cut;
        self.pruneCorruptAbove(cut);
        std.debug.assert(self.durable_len <= self.bytes.items.len);
        std.debug.assert(self.bytes.items.len <= max_bytes);
    }

    fn addCorrupt(self: *FaultyFile, off: u64) void {
        std.debug.assert(off < self.bytes.items.len);
        for (self.corrupt.items) |existing| {
            if (existing == off) return;
        }
        // Best-effort: missing one injected fault under real allocation
        // pressure only means slightly less fault coverage this round,
        // never a correctness gap in the store under test.
        self.corrupt.append(self.gpa, off) catch return;
        std.mem.sort(u64, self.corrupt.items, {}, std.sort.asc(u64));
    }

    fn pruneCorruptAbove(self: *FaultyFile, cut: u64) void {
        var write: usize = 0;
        for (self.corrupt.items) |off| {
            if (off < cut) {
                self.corrupt.items[write] = off;
                write += 1;
            }
        }
        self.corrupt.shrinkRetainingCapacity(write);
    }

    const vtable: storage.StorageFile.VTable = .{
        .append = appendImpl,
        .pread = preadImpl,
        .sync = syncImpl,
        .size = sizeImpl,
        .truncate = truncateImpl,
    };

    fn appendImpl(ctx: *anyopaque, data: []const u8) storage.StorageError!void {
        const self: *FaultyFile = @ptrCast(@alignCast(ctx));
        std.debug.assert(self.bytes.items.len + data.len <= max_bytes);
        if (data.len > 0 and drawPct(self.random, self.cfg.short_write_pct)) {
            // k < n by construction: a "short write" that persisted every
            // requested byte would not be short.
            const k = self.random.uintLessThan(usize, data.len);
            self.bytes.appendSlice(self.gpa, data[0..k]) catch return error.Io;
            return error.Io;
        }
        self.bytes.appendSlice(self.gpa, data) catch return error.Io;
    }

    fn preadImpl(ctx: *anyopaque, offset: u64, buf: []u8) storage.StorageError!usize {
        const self: *FaultyFile = @ptrCast(@alignCast(ctx));
        std.debug.assert(offset <= self.bytes.items.len);
        const avail = self.bytes.items.len - offset;
        const n = @min(avail, buf.len);
        if (n == 0) return 0;
        const start: usize = @intCast(offset);
        @memcpy(buf[0..n], self.bytes.items[start..][0..n]);
        for (self.corrupt.items) |off| {
            if (off >= offset and off < offset + n) {
                buf[@intCast(off - offset)] ^= corruptionMask(off);
            }
        }
        return n;
    }

    fn syncImpl(ctx: *anyopaque) storage.StorageError!void {
        const self: *FaultyFile = @ptrCast(@alignCast(ctx));
        if (drawPct(self.random, self.cfg.lost_tail_sync_pct)) return; // lies: durable_len unmoved
        self.durable_len = self.bytes.items.len;
    }

    fn sizeImpl(ctx: *anyopaque) storage.StorageError!u64 {
        const self: *FaultyFile = @ptrCast(@alignCast(ctx));
        return self.bytes.items.len;
    }

    fn truncateImpl(ctx: *anyopaque, len: u64) storage.StorageError!void {
        const self: *FaultyFile = @ptrCast(@alignCast(ctx));
        std.debug.assert(len <= max_bytes);
        const len_usize: usize = @intCast(len);
        if (len_usize <= self.bytes.items.len) {
            self.bytes.shrinkRetainingCapacity(len_usize);
        } else {
            const old = self.bytes.items.len;
            self.bytes.resize(self.gpa, len_usize) catch return error.Io;
            @memset(self.bytes.items[old..], 0);
        }
        self.pruneCorruptAbove(len);
        self.durable_len = @min(self.durable_len, len);
    }
};

fn drawPct(random: std.Random, pct: u8) bool {
    std.debug.assert(pct <= 100);
    if (pct == 0) return false;
    if (pct == 100) return true;
    return random.uintLessThan(u8, 100) < pct;
}

/// Payload-derived, pure function of the offset: every retry corrupts
/// identically (storage-track-plan.md §B). Always nonzero, so it always
/// actually flips something.
fn corruptionMask(off: u64) u8 {
    const h = std.hash.Wyhash.hash(0, std.mem.asBytes(&off));
    return @truncate(1 | (h & 7));
}

test "faulty_file §B: short write persists a k<n prefix and returns error.Io" {
    var prng = std.Random.DefaultPrng.init(1);
    var faulty = FaultyFile.init(std.testing.allocator, prng.random());
    defer faulty.deinit();
    faulty.cfg.short_write_pct = 100;

    const file = faulty.storageFile();
    try std.testing.expectError(error.Io, file.append("12345678"));
    const size = try file.size();
    try std.testing.expect(size < 8);

    var buf: [8]u8 = undefined;
    const n = try file.pread(0, buf[0..@intCast(size)]);
    try std.testing.expectEqual(size, n);
    try std.testing.expectEqualStrings("12345678"[0..@intCast(size)], buf[0..n]);
}

test "faulty_file §B: crash-restart tears the unsynced tail within [durable_len, size]" {
    var prng = std.Random.DefaultPrng.init(2);
    var faulty = FaultyFile.init(std.testing.allocator, prng.random());
    defer faulty.deinit();
    faulty.cfg.crash_rot_pct = 0; // isolate the tear from corruption

    const file = faulty.storageFile();
    try file.append("AAAA");
    try file.sync();
    try file.append("BBBB");
    faulty.crashRestart();

    const size = try file.size();
    try std.testing.expect(size >= 4 and size <= 8);
    var buf: [4]u8 = undefined;
    _ = try file.pread(0, &buf);
    try std.testing.expectEqualStrings("AAAA", &buf);
}

test "faulty_file §B: fsync-lost-tail drops every unsynced byte on the next crash" {
    var prng = std.Random.DefaultPrng.init(3);
    var faulty = FaultyFile.init(std.testing.allocator, prng.random());
    defer faulty.deinit();

    const file = faulty.storageFile();
    try file.append("AAAA");
    try file.sync();
    try file.append("BBBB");
    faulty.cfg.lost_tail_sync_pct = 100;
    try file.sync(); // lies: claims success, durable_len stays at 4
    faulty.cfg.crash_cut_at_durable_pct = 100;
    faulty.crashRestart();

    try std.testing.expectEqual(@as(u64, 4), try file.size());
}

test "faulty_file §B: a bitflip is payload-derived and never self-heals on retry" {
    var prng = std.Random.DefaultPrng.init(4);
    var faulty = FaultyFile.init(std.testing.allocator, prng.random());
    defer faulty.deinit();

    const file = faulty.storageFile();
    try file.append("AAAAAAAA");
    try file.sync();
    faulty.cfg.crash_cut_at_durable_pct = 100; // cut lands at len 8, nothing torn
    faulty.cfg.crash_rot_pct = 100; // force at least one corrupt offset
    faulty.crashRestart();
    try std.testing.expect(faulty.corrupt.items.len > 0);
    const off = faulty.corrupt.items[0];

    var first: [1]u8 = undefined;
    var second: [1]u8 = undefined;
    _ = try file.pread(off, &first);
    _ = try file.pread(off, &second);
    try std.testing.expectEqual(first[0], second[0]);
    try std.testing.expect(first[0] != 'A');
}

test "faulty_file §B: a read fault returns altered bytes without signalling an error" {
    var prng = std.Random.DefaultPrng.init(5);
    var faulty = FaultyFile.init(std.testing.allocator, prng.random());
    defer faulty.deinit();

    const file = faulty.storageFile();
    try file.append("ZZZZZZZZ");
    try file.sync();
    faulty.cfg.crash_cut_at_durable_pct = 100;
    faulty.cfg.crash_rot_pct = 100;
    faulty.crashRestart();
    try std.testing.expect(faulty.corrupt.items.len > 0);
    const off = faulty.corrupt.items[0];

    var buf: [1]u8 = undefined;
    const n = try file.pread(off, &buf); // no error -- a read fault, not a device fault
    try std.testing.expectEqual(@as(usize, 1), n);
    try std.testing.expect(buf[0] != 'Z');
}