Artifact c8caf93bb63c99e0be959eaa9f439c328a4d39920dc93f084ef766306db3ba4a:
- File
src/storage.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: 21067) [annotate] [blame] [check-ins using]
//! The narrow file capability the storage engine reads and writes //! through. Five operations, one error, nothing else — not a wrapper over //! all of `std.Io`. Kept internal (not re-exported from root.zig) until a //! real consumer (LogStore) needs it; its unit tests reach `zig build //! test` through the dst module, which imports this file directly. //! //! There is no RFC section behind this file: it is native storage-engine //! design, not a port. The oracle is this capability's own crash-recovery //! invariants, exercised by the DST sweep in sim/storage_sweep.zig: every //! acknowledged put stays readable and hash-verifies; a torn tail //! truncates to exactly the last complete frame; the rebuilt index //! matches the write-time model; and corruption always surfaces as an //! error, never as silently wrong bytes. const std = @import("std"); /// The device failed. Corruption is deliberately NOT a `StorageError`: a /// corrupt read returns wrong bytes successfully (short of hardware I/O /// failure), and the frame layer above is what turns "wrong bytes" into a /// decision — the crash-recovery invariant that corruption is always an /// error, never silent data. One variant is the whole set — every other /// outcome (short read, no-op truncate, EOF) is defined as ordinary data, /// not an error. pub const StorageError = error{Io}; /// A capability, not a resource: `context` is caller-owned and outlives /// every call made through the vtable. pub const StorageFile = struct { context: *anyopaque, vtable: *const VTable, pub const VTable = struct { append: *const fn (ctx: *anyopaque, bytes: []const u8) StorageError!void, /// Never errors on short read — returns the count actually read, /// `0..buf.len`, with 0 meaning "at or past EOF": the error is /// defined out of existence for EOF rather than threaded through /// every caller. pread: *const fn (ctx: *anyopaque, offset: u64, buf: []u8) StorageError!usize, sync: *const fn (ctx: *anyopaque) StorageError!void, size: *const fn (ctx: *anyopaque) StorageError!u64, /// A no-op (same length) or a repeat truncate is legal, never an /// error. truncate: *const fn (ctx: *anyopaque, len: u64) StorageError!void, /// Re-read the file's true length as the append watermark. Called /// by `LogStore.open` once the caller holds the store's writer lock /// (`cli.openStoreAppend`), which is what makes the length final: an /// append lands at the file's real end only if that end was read /// after every other writer was excluded. A length recorded earlier (a /// `stat` taken to build this binding) can be stale, and appending at /// it writes over another writer's frames with valid ones no checksum /// can tell from a clean write. A binding with a single writer by /// construction (an in-memory store) makes this a no-op. refresh_end: *const fn (ctx: *anyopaque) StorageError!void, }; pub fn append(self: StorageFile, bytes: []const u8) StorageError!void { return self.vtable.append(self.context, bytes); } pub fn pread(self: StorageFile, offset: u64, buf: []u8) StorageError!usize { return self.vtable.pread(self.context, offset, buf); } pub fn sync(self: StorageFile) StorageError!void { return self.vtable.sync(self.context); } pub fn size(self: StorageFile) StorageError!u64 { return self.vtable.size(self.context); } pub fn truncate(self: StorageFile, len: u64) StorageError!void { return self.vtable.truncate(self.context, len); } pub fn refreshEnd(self: StorageFile) StorageError!void { return self.vtable.refresh_end(self.context); } }; /// Production binding: a real file reached only through the injected /// `std.Io` (design-seams rule — no ambient filesystem access). `end` /// tracks the append watermark so `append` never needs a `size` round trip. pub const FileStorage = struct { io: std.Io, file: std.Io.File, end: u64, /// Optional block cache in front of `pread`, attached by `useReadCache`. /// Null by default, so every construction site that does not opt in behaves /// exactly as it did before this existed — which is why it is a field rather /// than an `init` parameter (there are ~200 of those sites, and almost none /// of them are on a read-heavy path). read_cache: ?*ReadCache = null, /// Precondition: `initial_size` equals `file`'s current length. Not /// verified here — doing so would mean a stat syscall inside a plain /// constructor, and TigerStyle assertions must not carry I/O side /// effects. A caller that gets this wrong sees the mismatch surface /// downstream, as a wrong `size()`/`pread` result. pub fn init(io: std.Io, file: std.Io.File, initial_size: u64) FileStorage { return .{ .io = io, .file = file, .end = initial_size }; } pub fn storageFile(self: *FileStorage) StorageFile { return .{ .context = self, .vtable = &vtable }; } /// Serve reads through `cache`. The cache is caller-owned and must outlive /// this storage — the same ownership shape `logstore.PlaintextCache` uses, /// and for the same reason: whoever can prove the keys still mean what they /// meant owns it. pub fn useReadCache(self: *FileStorage, cache: *ReadCache) void { self.read_cache = cache; } const vtable: StorageFile.VTable = .{ .append = appendImpl, .pread = preadImpl, .sync = syncImpl, .size = sizeImpl, .truncate = truncateImpl, .refresh_end = refreshEndImpl, }; /// The file's length now, which the caller's writer lock holds still. fn refreshEndImpl(ctx: *anyopaque) StorageError!void { const self: *FileStorage = @ptrCast(@alignCast(ctx)); const st = self.file.stat(self.io) catch return error.Io; // A block read before the lock may have been the file's short last // block, since grown by another writer: start the cache clean. if (self.read_cache) |c| c.invalidate(); self.end = st.size; } fn appendImpl(ctx: *anyopaque, bytes: []const u8) StorageError!void { const self: *FileStorage = @ptrCast(@alignCast(ctx)); self.file.writePositionalAll(self.io, bytes, self.end) catch return error.Io; self.end += bytes.len; } fn preadImpl(ctx: *anyopaque, offset: u64, buf: []u8) StorageError!usize { const self: *FileStorage = @ptrCast(@alignCast(ctx)); if (self.read_cache) |c| return c.pread(self.io, self.file, offset, buf); return self.file.readPositionalAll(self.io, buf, offset) catch error.Io; } fn syncImpl(ctx: *anyopaque) StorageError!void { const self: *FileStorage = @ptrCast(@alignCast(ctx)); self.file.sync(self.io) catch return error.Io; } fn sizeImpl(ctx: *anyopaque) StorageError!u64 { const self: *FileStorage = @ptrCast(@alignCast(ctx)); return self.end; } fn truncateImpl(ctx: *anyopaque, len: u64) StorageError!void { const self: *FileStorage = @ptrCast(@alignCast(ctx)); // Truncation is the ONE event that can make a cached block stale: it is // the only way a byte offset below the append watermark comes to name // different content. `logstore.clearBaseMemo` exists for exactly this // hazard at a higher layer; this is the same invalidation at the bottom. if (self.read_cache) |c| c.invalidate(); self.file.setLength(self.io, len) catch return error.Io; // setLength makes the file exactly `len` bytes — it both shrinks and // extends — so the append watermark is `len`, not `@min(end, len)`, // which would leave `end` stale (and the next append overwriting the // extended tail) whenever `len` grows the file. self.end = len; } }; test "storage §B: append then pread round-trips through the prod binding" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const io = std.testing.io; var raw = try tmp.dir.createFile(io, "storage-test.bin", .{ .read = true }); defer raw.close(io); var fs = FileStorage.init(io, raw, 0); const file = fs.storageFile(); try file.append("hello"); var buf: [5]u8 = undefined; const n = try file.pread(0, &buf); try std.testing.expectEqual(@as(usize, 5), n); try std.testing.expectEqualStrings("hello", &buf); } test "storage §B: size tracks appended length and truncate cuts the tail exactly" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const io = std.testing.io; var raw = try tmp.dir.createFile(io, "storage-test.bin", .{ .read = true }); defer raw.close(io); var fs = FileStorage.init(io, raw, 0); const file = fs.storageFile(); try file.append("0123456789"); try std.testing.expectEqual(@as(u64, 10), try file.size()); try file.truncate(4); try std.testing.expectEqual(@as(u64, 4), try file.size()); var buf: [4]u8 = undefined; const n = try file.pread(0, &buf); try std.testing.expectEqual(@as(usize, 4), n); try std.testing.expectEqualStrings("0123", &buf); } test "storage §B: sync on a healthy file returns without error" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const io = std.testing.io; var raw = try tmp.dir.createFile(io, "storage-test.bin", .{ .read = true }); defer raw.close(io); var fs = FileStorage.init(io, raw, 0); const file = fs.storageFile(); try file.append("payload"); try file.sync(); } test "storage §B: pread at/after EOF returns a short count, not an error" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const io = std.testing.io; var raw = try tmp.dir.createFile(io, "storage-test.bin", .{ .read = true }); defer raw.close(io); var fs = FileStorage.init(io, raw, 0); const file = fs.storageFile(); try file.append("ab"); var buf: [4]u8 = undefined; const n = try file.pread(2, &buf); // exactly at EOF try std.testing.expectEqual(@as(usize, 0), n); const n2 = try file.pread(10, &buf); // well past EOF try std.testing.expectEqual(@as(usize, 0), n2); } /// A bounded block cache in front of `FileStorage.pread`. /// /// The store is APPEND-ONLY, and that is what makes this sound. A block is /// retained only when the read that filled it returned a FULL block — which /// proves the file already held every one of those bytes. Appends only ever /// write at or beyond the current end, so they cannot alter a byte a full block /// already covers, and therefore need no invalidation at all. Only `truncate` /// can make an offset name different content, and it invalidates everything. /// /// A short read is never cached. That is the same rule stated from the other /// side: a partial block is one that reached end-of-file, so the bytes past it /// are exactly the ones a later append will write. /// /// Deleting this cache can only change speed, never any answer. pub const ReadCache = struct { /// Block granularity. Large enough that one miss amortises a syscall across /// many small nearby reads (a frame header, a payload, its CRC trailer), /// small enough that a miss does not read far more than it will use. pub const block_len: usize = 64 << 10; /// Default resident blocks: 4 MiB. The store's reads are offset-clustered — /// a delta chain walks frames written near each other — so a small window /// captures most of the locality there is. pub const default_blocks: usize = 64; const Block = struct { base: u64 = 0, valid: bool = false, last_used: u64 = 0, data: []u8, }; gpa: std.mem.Allocator, blocks: []Block, tick: u64 = 0, /// Observability only; never gates a decision. hits: u64 = 0, misses: u64 = 0, /// Reads served straight from the file because they were at least a block /// wide. Caching those would evict many small entries to hold bytes the /// caller already has, and a large read amortises its own syscall. bypassed: u64 = 0, pub fn init(gpa: std.mem.Allocator, n_blocks: usize) error{OutOfMemory}!ReadCache { std.debug.assert(n_blocks >= 1); const blocks = try gpa.alloc(Block, n_blocks); errdefer gpa.free(blocks); var made: usize = 0; errdefer for (blocks[0..made]) |b| gpa.free(b.data); while (made < n_blocks) : (made += 1) { blocks[made] = .{ .data = try gpa.alloc(u8, block_len) }; } return .{ .gpa = gpa, .blocks = blocks }; } pub fn deinit(self: *ReadCache) void { for (self.blocks) |b| self.gpa.free(b.data); self.gpa.free(self.blocks); self.* = undefined; } /// Drop every retained block. Only `truncate` needs this. pub fn invalidate(self: *ReadCache) void { for (self.blocks) |*b| b.valid = false; } fn find(self: *ReadCache, base: u64) ?*Block { for (self.blocks) |*b| if (b.valid and b.base == base) return b; return null; } /// The least-recently-used block, or any still-unused one. fn victim(self: *ReadCache) *Block { var pick: *Block = &self.blocks[0]; for (self.blocks) |*b| { if (!b.valid) return b; if (b.last_used < pick.last_used) pick = b; } return pick; } fn touch(self: *ReadCache, b: *Block) void { self.tick += 1; b.last_used = self.tick; } /// `readPositionalAll` semantics: returns the number of bytes read, which is /// short only at end of file. pub fn pread(self: *ReadCache, io: std.Io, file: std.Io.File, offset: u64, buf: []u8) StorageError!usize { if (buf.len >= block_len) { self.bypassed += 1; return file.readPositionalAll(io, buf, offset) catch error.Io; } var done: usize = 0; while (done < buf.len) { const want = offset + done; const base = want - (want % block_len); const off_in: usize = @intCast(want - base); if (self.find(base)) |b| { self.hits += 1; self.touch(b); const n = @min(buf.len - done, block_len - off_in); @memcpy(buf[done..][0..n], b.data[off_in..][0..n]); done += n; continue; } self.misses += 1; const slot = self.victim(); slot.valid = false; // not retained until proven whole const got = file.readPositionalAll(io, slot.data, base) catch return error.Io; if (got < block_len) { // End of file inside this block: serve what exists and stop. // Never retained — these are the bytes a later append rewrites. if (got <= off_in) return done; const n = @min(buf.len - done, got - off_in); @memcpy(buf[done..][0..n], slot.data[off_in..][0..n]); return done + n; } slot.base = base; slot.valid = true; self.touch(slot); const n = @min(buf.len - done, block_len - off_in); @memcpy(buf[done..][0..n], slot.data[off_in..][0..n]); done += n; } return done; } }; // --- ReadCache ------------------------------------------------------------- // The cache may only ever change SPEED. Each test below pins one of the three // facts that make that true, so a regression shows up as a wrong ANSWER in a // test rather than as corrupt content in a repository. /// A file of `len` bytes whose byte at index i is `i % 251` — distinct enough /// that a block served from the wrong base is caught by value, not just length. fn seedPattern(io: std.Io, dir: std.Io.Dir, name: []const u8, len: usize) !std.Io.File { var f = try dir.createFile(io, name, .{ .read = true }); const bytes = try std.testing.allocator.alloc(u8, len); defer std.testing.allocator.free(bytes); for (bytes, 0..) |*b, i| b.* = @intCast(i % 251); try f.writePositionalAll(io, bytes, 0); return f; } test "storage: a cached read returns the same bytes as an uncached one, across block seams" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const io = std.testing.io; const len = ReadCache.block_len * 3 + 777; var raw = try seedPattern(io, tmp.dir, "rc.bin", len); defer raw.close(io); var plain = FileStorage.init(io, raw, len); var cached = FileStorage.init(io, raw, len); var rc = try ReadCache.init(std.testing.allocator, 4); defer rc.deinit(); cached.useReadCache(&rc); // Offsets chosen to straddle block boundaries in both directions, which is // where an off-by-one in the base arithmetic would show up. const offsets = [_]u64{ 0, 1, ReadCache.block_len - 1, ReadCache.block_len, ReadCache.block_len + 5, 2 * ReadCache.block_len - 3, len - 10 }; for (offsets) |off| { var a: [64]u8 = undefined; var b: [64]u8 = undefined; const na = try plain.storageFile().pread(off, &a); const nb = try cached.storageFile().pread(off, &b); try std.testing.expectEqual(na, nb); try std.testing.expectEqualSlices(u8, a[0..na], b[0..nb]); } // Repeating them must still agree — this is the pass that actually hits. try std.testing.expect(rc.hits > 0); for (offsets) |off| { var a: [64]u8 = undefined; var b: [64]u8 = undefined; const na = try plain.storageFile().pread(off, &a); const nb = try cached.storageFile().pread(off, &b); try std.testing.expectEqualSlices(u8, a[0..na], b[0..nb]); try std.testing.expectEqual(na, nb); } } test "storage: appending after a cached read does not serve stale bytes" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const io = std.testing.io; // Shorter than one block, so the first read is a SHORT read — precisely the // case that must not be retained, because the bytes just past it are the // ones the append is about to write. var raw = try seedPattern(io, tmp.dir, "rc.bin", 100); defer raw.close(io); var fs = FileStorage.init(io, raw, 100); var rc = try ReadCache.init(std.testing.allocator, 4); defer rc.deinit(); fs.useReadCache(&rc); const file = fs.storageFile(); var before: [100]u8 = undefined; try std.testing.expectEqual(@as(usize, 100), try file.pread(0, &before)); try file.append("APPENDED"); // The appended bytes must be visible, not masked by a retained partial block. var after: [8]u8 = undefined; try std.testing.expectEqual(@as(usize, 8), try file.pread(100, &after)); try std.testing.expectEqualStrings("APPENDED", &after); } test "storage: truncation invalidates the cache rather than renaming old bytes" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const io = std.testing.io; const len = ReadCache.block_len * 2; var raw = try seedPattern(io, tmp.dir, "rc.bin", len); defer raw.close(io); var fs = FileStorage.init(io, raw, len); var rc = try ReadCache.init(std.testing.allocator, 4); defer rc.deinit(); fs.useReadCache(&rc); const file = fs.storageFile(); // Warm a full block, so offset 0 is genuinely resident. var warm: [32]u8 = undefined; _ = try file.pread(0, &warm); // Truncate away everything and write DIFFERENT content at the same offsets: // the hazard `logstore.clearBaseMemo` guards at a higher layer, where an old // offset comes to name a new frame. try file.truncate(0); try file.append("XXXXXXXXXXXXXXXX"); var got: [16]u8 = undefined; try std.testing.expectEqual(@as(usize, 16), try file.pread(0, &got)); try std.testing.expectEqualStrings("XXXXXXXXXXXXXXXX", &got); } test "storage: a read at least one block wide bypasses the cache" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const io = std.testing.io; const len = ReadCache.block_len * 2; var raw = try seedPattern(io, tmp.dir, "rc.bin", len); defer raw.close(io); var fs = FileStorage.init(io, raw, len); var rc = try ReadCache.init(std.testing.allocator, 4); defer rc.deinit(); fs.useReadCache(&rc); const big = try std.testing.allocator.alloc(u8, ReadCache.block_len); defer std.testing.allocator.free(big); _ = try fs.storageFile().pread(0, big); // Retaining it would evict many small entries to hold bytes the caller // already has, and a read this size amortises its own syscall. try std.testing.expectEqual(@as(u64, 1), rc.bypassed); try std.testing.expectEqual(@as(u64, 0), rc.misses); for (big, 0..) |b, i| try std.testing.expectEqual(@as(u8, @intCast(i % 251)), b); }