Artifact 33d94c54d3f0a44152c5d483d7ff624db66f0a8df61b08cfd6f7a1ea3487fa3c:
- File
src/sim/lock_sweep.zig
— part of check-in
[d6cf7bf59c]
at
2026-09-28 11:59:39
on branch trunk
— Lock the store with exclusive-create lock files instead of flock
Writers create .zit.lock with O_CREAT|O_EXCL before opening the store. A waiter names the holder and gives up after a minute; a dead holder's lock is broken; pack and shun --force hold the writer lock across their final check and rename. Release 0.3.33. (user: dmestas, size: 13574) [annotate] [blame] [check-ins using]
//! The DST sweep for the writer lock: several writers append to ONE log, //! interleaved step by step off the seed, through a simulated file and a //! simulated lock file that follow the store's locking protocol. //! //! The storage sweep drives one writer against a faulty file, which is the //! right shape for crash recovery and the wrong one for this: a lock nobody //! else contends for is never exercised, so a writer that skipped it passed //! every seed. Here the lock is part of the model. Each writer is a small //! state machine walked one action per step, in the order a real verb does //! them: //! //! take the lock `cli.openStoreAppend` creates `.zit.lock` exclusively //! stat the length the caller builds its FileStorage from //! LogStore.open re-reads the end, then scans //! put × k appends, one per step //! commit, release fsync, then the lock file is deleted //! //! so another writer can run between any two of them. A writer whose lock is //! held by a live process is simply not advanced until it is free. //! //! A writer can also die at any step while it holds the lock, as a killed //! process does. Its lock file stays behind naming it, and the next writer //! must break it, because its holder no longer exists, and never break the //! lock of one that does. The simulated file records two violations the //! moment they happen, which is exactly the damage an unserialised writer //! does and which no checksum can see afterwards: //! //! an append or truncate by a handle whose process does not hold the lock //! an append that does not land at the file's current end, i.e. one that //! writes over frames already there //! //! The writer's own view is checked too. Once it holds the lock it must see //! every artifact committed before it, and everything it puts must read back //! through its own handle, including deltas against artifacts other writers //! committed, whose basis offsets are computed from the end it believes in. //! At the end, every artifact a writer committed must be present and //! byte-identical through a fresh reader, and the log must verify. const std = @import("std"); const storage = @import("zit").storage; const logstore = @import("zit").logstore; const hash_len = 32; pub const LockSweepError = error{ OutOfMemory, Io, /// A handle appended or truncated without holding the writer lock. UnlockedWrite, /// A handle appended somewhere other than the file's end: over frames /// another writer had already written. OverwroteFrames, /// A committed artifact is missing or reads back different bytes. MissingAcknowledged, /// The shared log no longer opens or verifies. LockSweepCorrupt, /// A writer holding the lock does not see an artifact committed before it /// took the lock, or cannot read back what it just put. StaleView, }; /// The one file every writer shares, and its lock file. const Shared = struct { bytes: std.ArrayList(u8) = .empty, /// The process named in `.zit.lock`, or null when there is none. lock_holder: ?u32 = null, /// Processes that have died. A lock naming one of them is stale. dead: std.ArrayList(u32) = .empty, unlocked_writes: u32 = 0, overwrites: u32 = 0, fn deinit(self: *Shared, gpa: std.mem.Allocator) void { self.bytes.deinit(gpa); self.dead.deinit(gpa); } fn isDead(self: *const Shared, pid: u32) bool { return std.mem.indexOfScalar(u32, self.dead.items, pid) != null; } }; /// One open handle on the shared file, with `FileStorage`'s shape: an append /// watermark captured by the caller's `stat` and re-read by `refresh_end`. const Handle = struct { gpa: std.mem.Allocator, shared: *Shared, pid: u32, end: u64, fn storageFile(self: *Handle) storage.StorageFile { return .{ .context = self, .vtable = &vtable }; } const vtable: storage.StorageFile.VTable = .{ .append = appendImpl, .pread = preadImpl, .sync = syncImpl, .size = sizeImpl, .truncate = truncateImpl, .refresh_end = refreshEndImpl, }; fn appendImpl(ctx: *anyopaque, bytes: []const u8) storage.StorageError!void { const self: *Handle = @ptrCast(@alignCast(ctx)); const s = self.shared; if (s.lock_holder != self.pid) s.unlocked_writes += 1; if (self.end != s.bytes.items.len) s.overwrites += 1; // Write where this handle believes the end is, exactly as a positional // write would: over existing bytes if the belief is stale. const at: usize = @intCast(self.end); const need = at + bytes.len; if (need > s.bytes.items.len) { const old = s.bytes.items.len; s.bytes.resize(self.gpa, need) catch return error.Io; if (at > old) @memset(s.bytes.items[old..at], 0); } @memcpy(s.bytes.items[at..need], bytes); self.end += bytes.len; } fn preadImpl(ctx: *anyopaque, offset: u64, buf: []u8) storage.StorageError!usize { const self: *Handle = @ptrCast(@alignCast(ctx)); const items = self.shared.bytes.items; if (offset >= items.len) return 0; const n = @min(buf.len, items.len - @as(usize, @intCast(offset))); @memcpy(buf[0..n], items[@intCast(offset)..][0..n]); return n; } fn syncImpl(_: *anyopaque) storage.StorageError!void {} fn sizeImpl(ctx: *anyopaque) storage.StorageError!u64 { const self: *Handle = @ptrCast(@alignCast(ctx)); return self.end; } fn truncateImpl(ctx: *anyopaque, len: u64) storage.StorageError!void { const self: *Handle = @ptrCast(@alignCast(ctx)); const s = self.shared; if (s.lock_holder != self.pid) s.unlocked_writes += 1; const want: usize = @intCast(len); const old = s.bytes.items.len; s.bytes.resize(self.gpa, want) catch return error.Io; if (want > old) @memset(s.bytes.items[old..want], 0); self.end = len; } fn refreshEndImpl(ctx: *anyopaque) storage.StorageError!void { const self: *Handle = @ptrCast(@alignCast(ctx)); self.end = self.shared.bytes.items.len; } }; const Artifact = struct { hash: [hash_len]u8, bytes: []u8 }; const Writer = struct { /// The process this writer currently is. A writer that dies comes back /// as a new process, as a restarted command does. pid: u32, state: enum { idle, locked, handle_made, open } = .idle, handle: Handle = undefined, log: logstore.LogStore = undefined, to_put: u32 = 0, /// Put this session, not yet committed: not owed back until the commit. session: std.ArrayList(Artifact) = .empty, fn dropSession(self: *Writer, gpa: std.mem.Allocator) void { for (self.session.items) |a| gpa.free(a.bytes); self.session.clearRetainingCapacity(); } }; /// One step of taking the lock, as `storelock.acquire` does it: create the /// lock file if there is none; if its holder no longer exists, break it; if /// its holder is alive, wait. Returns whether the lock is now `pid`'s. fn tryLock(shared: *Shared, pid: u32) bool { if (shared.lock_holder) |holder| { if (!shared.isDead(holder)) return false; // Stale: break it. A later step for this writer takes it. shared.lock_holder = null; return false; } shared.lock_holder = pid; return true; } /// Run one seed: `writers` interleaved writers, `steps` scheduler steps. pub fn run(gpa: std.mem.Allocator, seed: u64, steps: usize) LockSweepError!void { var prng = std.Random.DefaultPrng.init(seed ^ 0x6c6f636b73776565); // "locksweep" const random = prng.random(); var shared: Shared = .{}; defer shared.deinit(gpa); var committed: std.ArrayList(Artifact) = .empty; defer { for (committed.items) |a| gpa.free(a.bytes); committed.deinit(gpa); } const n_writers: u32 = 2 + random.uintLessThan(u32, 3); // 2..4 var next_pid: u32 = 1; var writers: [4]Writer = undefined; for (writers[0..n_writers]) |*w| { w.* = .{ .pid = next_pid }; next_pid += 1; } defer for (writers[0..n_writers]) |*w| { w.dropSession(gpa); w.session.deinit(gpa); if (w.state == .open) w.log.deinit(gpa); }; var step: usize = 0; while (step < steps) : (step += 1) { const w = &writers[random.uintLessThan(u32, n_writers)]; // A writer holding the lock can die at any step. Its lock file stays, // naming a process that no longer exists; what it put but never // committed is not owed to anyone. if (w.state != .idle and random.uintLessThan(u32, 40) == 0) { if (w.state == .open) w.log.deinit(gpa); w.dropSession(gpa); try shared.dead.append(gpa, w.pid); w.pid = next_pid; next_pid += 1; w.state = .idle; continue; } switch (w.state) { .idle => { if (tryLock(&shared, w.pid)) w.state = .locked; }, .locked => { // The caller's stat, under the lock. w.handle = .{ .gpa = gpa, .shared = &shared, .pid = w.pid, .end = shared.bytes.items.len }; w.state = .handle_made; }, .handle_made => { w.log = logstore.LogStore.open(gpa, w.handle.storageFile(), 0) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, else => return error.LockSweepCorrupt, }; w.to_put = 1 + random.uintLessThan(u32, 4); w.state = .open; // Everything committed before the lock was taken is visible. for (committed.items) |a| if (w.log.lookup(&a.hash) == null) return error.StaleView; }, .open => { if (w.to_put > 0) { try putOne(gpa, random, w, committed.items); w.to_put -= 1; } else { w.log.commit() catch return error.LockSweepCorrupt; try committed.appendSlice(gpa, w.session.items); w.session.clearRetainingCapacity(); w.log.deinit(gpa); // Release: delete the lock file, if it is still ours. if (shared.lock_holder == w.pid) shared.lock_holder = null; w.state = .idle; } }, } // The protocol is checked as it is broken, not only at the end: the // first bad write is the one worth reporting. if (shared.unlocked_writes != 0) return error.UnlockedWrite; if (shared.overwrites != 0) return error.OverwroteFrames; } try checkCommitted(gpa, &shared, committed.items); } /// Put one artifact through `w`'s log and read it straight back. Half the /// time it is a small edit of an artifact another writer committed, stored as /// a delta against it: its basis offset is only right if this writer's end is. fn putOne(gpa: std.mem.Allocator, random: std.Random, w: *Writer, committed: []const Artifact) LockSweepError!void { const base: ?Artifact = if (committed.len > 0 and random.boolean()) committed[random.uintLessThan(usize, committed.len)] else null; const bytes = if (base) |b| blk: { const edited = try gpa.alloc(u8, b.bytes.len + 8); @memcpy(edited[0..b.bytes.len], b.bytes); random.bytes(edited[b.bytes.len..]); break :blk edited; } else blk: { const fresh = try gpa.alloc(u8, 1 + random.uintLessThan(usize, 200)); random.bytes(fresh); break :blk fresh; }; errdefer gpa.free(bytes); var hash: [hash_len]u8 = undefined; std.crypto.hash.sha2.Sha256.hash(bytes, &hash, .{}); const base_rid: ?logstore.Rid = if (base) |b| w.log.lookup(&b.hash) else null; const rid = w.log.putArtifact(&hash, bytes, base_rid) catch return error.LockSweepCorrupt; const got = (w.log.content(gpa, rid) catch return error.StaleView) orelse return error.StaleView; defer gpa.free(got); if (!std.mem.eql(u8, got, bytes)) return error.StaleView; try w.session.append(gpa, .{ .hash = hash, .bytes = bytes }); } /// Every committed artifact is present and byte-identical through a fresh /// reader that holds no lock, the way `zit log` or `verify` would read it. fn checkCommitted(gpa: std.mem.Allocator, shared: *Shared, committed: []const Artifact) LockSweepError!void { if (shared.bytes.items.len == 0) { if (committed.len == 0) return; return error.MissingAcknowledged; } var reader: Handle = .{ .gpa = gpa, .shared = shared, .pid = 0, .end = shared.bytes.items.len }; var log = logstore.LogStore.openReadOnly(gpa, reader.storageFile()) catch return error.LockSweepCorrupt; defer log.deinit(gpa); for (committed) |a| { const rid = log.lookup(&a.hash) orelse return error.MissingAcknowledged; const got = (log.content(gpa, rid) catch return error.LockSweepCorrupt) orelse return error.MissingAcknowledged; defer gpa.free(got); if (!std.mem.eql(u8, got, a.bytes)) return error.MissingAcknowledged; } } test "lock sweep: interleaved writers never write unlocked, never overwrite, and lose nothing" { var seed: u64 = 1; while (seed <= 25) : (seed += 1) try run(std.testing.allocator, seed, 400); }