db/storage: close three data-loss paths in commit and compaction

Follow-up hardening on the group-commit work from ecd28d9 and c8d547f. The
signal -> broadcast fix and the append-drained wakeup under commit_lock were
correct but incomplete; each of the three defects below could lose or corrupt
data that had already been acknowledged.

- log_append's cleanup defer took commit_lock with `lock(...) catch {}` and
  then unlocked unconditionally. Mutex.lock is Cancelable!void and
  Mutex.unlock treats an already-unlocked mutex as `unreachable`, so a
  cancellation there (client disconnect, shutdown) released a mutex the fiber
  never held: a panic in ReleaseSafe and silent memory corruption in the
  default ReleaseFast build. A cleanup path must not be a cancellation point,
  so it uses lockUncancelable.

- Engine.commit waited with `catch return`, which returns *success* from
  !void. A failed wait therefore told the caller its write was on disk and the
  dispatch epilogue replied ok without a seal or an fsync -- the same failure
  class c8d547f fixed, reached through the error path instead of the happy
  one. The follower wait now propagates; the leader's drain is uncancelable,
  since once `committing` is set every other writer is parked behind it and
  the drain is bounded anyway.

- Two compactions could run at once. They share one `<log>.tmp` path and each
  ends in a rename onto the log, so one truncates and rewrites the file the
  other is about to publish, and then that one renames whatever it finds over
  the live log. compact now claims an atomic `compacting` slot and a second
  caller returns; the guard sits on the resource rather than in take_compact,
  so direct callers (tests included) are covered too. compact_pending became
  atomic while we were there: note_compact sets it under a *collection* lock
  and the epilogue read it under none.

Also in compaction: the tmp file is opened with a new Log.create that
truncates. Log.open keeps an existing file's bytes and only rewinds end_pos to
the header, so a longer tmp left by a crashed or retried rewrite kept its
tail -- and those trailing blocks are intact and hash-correct, so replay
applied them as live records once the rename published the file, resurrecting
deleted documents. The `deleteFile ... catch {}` that used to stand in for
this is gone, and with it a swallowed error the invariant rested on. The
retry loop is bounded at 8 attempts (each one rewrites the whole log before
the seq check can reject it, so an unbounded retry livelocks under sustained
writes); giving up re-arms the request instead of failing the write.

A compaction failure no longer fails the write whose epilogue triggered it:
the write is durable by then, so the error is reported and the request
re-armed rather than turned into an error the client retries.

Assertions: db.zig, commands.zig and storage.zig had none, which is why every
defect in this series was found by a stress run rather than at the moment of
corruption. src/assert.zig adds an assert that survives ReleaseFast --
std.debug.assert lowers to `unreachable`, which in this project's default
build is not a skipped check but a promise to the optimizer, exactly the wrong
lowering for a durability invariant that might be false. Eleven of them now
cover the commit watermark, the in-flight append count, the compaction
snapshot, and the live/dead counters (whose u64 subtraction would otherwise
underflow into a live count that suppresses compaction forever).

cmd_find asserts that a missing collection implies no matches instead of
silently emitting an empty page for a query that did match.

Dead state removed: Log.defer_sync was never read (only written once by
compact), yet its doc comment instructed callers to follow a defer_sync
protocol that no longer exists and has no effect if followed. Engine's
begin_batch/end_batch were both `_ = self`, so three call sites announced a
batch boundary that wasn't there. Both are gone and the comments now describe
the real contract: appends never sync, Log.sync is the only commit point.

Tests: Log.create's truncation is pinned by a storage test that replays after
reusing a path, and the compaction guard by a db test that drives the flag
directly -- both confirmed to fail without their fix. The threaded test added
alongside them is a smoke test only, and says so: both races have windows too
narrow to hit reliably (compact_snapshot_coll holds each collection's write
lock while snapshotting, so two compactions serialize there and an insert
cannot re-arm compact_pending meanwhile), and it passes with the guard
removed.

Includes an unrelated fix that was already in the working tree: slab_append
held a pointer into slab.items across an append to that same list, which
could dangle after a realloc and corrupt the new segment's start offset.

Verified: unit suite in ReleaseFast/ReleaseSafe/Debug; e2e, e2e2 concurrent,
e2e3, e2e4, e2e5, e2e6 (72/72) and the kill -9 crash pair, with no assertion
firing anywhere.
This commit is contained in:
2026-08-03 11:55:52 +03:00
parent c8d547fef5
commit 720540860a
5 changed files with 499 additions and 93 deletions

View File

@@ -116,13 +116,34 @@ pub const Log = struct {
compressed: std.ArrayListUnmanaged(u8),
/// LZ4 hash table (positions of recent 4-byte sequences).
lz4_table: []u32,
/// Group commit: while set, appends skip the per-block fsync and the
/// caller issues one sync for the whole batch (see Engine.begin_batch /
/// end_batch). Every acknowledged write is still fsynced before the
/// reply, so the crash guarantees are unchanged.
defer_sync: bool = false,
/// Whether an existing file's contents are kept or discarded.
///
/// `.keep` is the database's own log: its bytes are the database, and
/// `replay` reads them. `.truncate` is for a file being written from
/// scratch (compaction's tmp), where leftover bytes from an earlier,
/// longer file would survive past the new content as intact blocks and be
/// replayed as live records.
const OpenMode = enum { keep, truncate };
/// Open the log at `path`, keeping whatever is already there for `replay`.
pub fn open(gpa: std.mem.Allocator, io: std.Io, path: []const u8) !Log {
return open_mode(gpa, io, path, .keep);
}
/// Open `path` as a brand-new empty log, discarding anything already there.
///
/// Compaction's tmp file must start empty. `open` keeps an existing file's
/// bytes and only rewinds end_pos to the header, so a longer previous tmp
/// (a retried or crashed compaction) would leave valid, hash-correct
/// blocks past the new content -- which `replay` applies as live records
/// once the rename publishes the file as the database, resurrecting
/// documents that were deleted.
pub fn create(gpa: std.mem.Allocator, io: std.Io, path: []const u8) !Log {
return open_mode(gpa, io, path, .truncate);
}
fn open_mode(gpa: std.mem.Allocator, io: std.Io, path: []const u8, mode: OpenMode) !Log {
// Resolve to an absolute path so compaction can rename the file
// without depending on the caller's working directory.
const abs_path = blk: {
@@ -134,9 +155,14 @@ pub const Log = struct {
errdefer gpa.free(abs_path);
const dir = std.Io.Dir.cwd();
const file: std.Io.File = dir.openFile(io, abs_path, .{ .mode = .read_write }) catch |err| switch (err) {
error.FileNotFound => try dir.createFile(io, abs_path, .{ .read = true }),
else => return err,
const file: std.Io.File = switch (mode) {
// createFile truncates by default, so this both creates a missing
// file and empties an existing one -- the whole point of .truncate.
.truncate => try dir.createFile(io, abs_path, .{ .read = true, .truncate = true }),
.keep => dir.openFile(io, abs_path, .{ .mode = .read_write }) catch |err| switch (err) {
error.FileNotFound => try dir.createFile(io, abs_path, .{ .read = true }),
else => return err,
},
};
var self: Log = .{
@@ -151,7 +177,6 @@ pub const Log = struct {
.block = .empty,
.compressed = .empty,
.lz4_table = undefined,
.defer_sync = false,
};
errdefer {
self.scratch.deinit(gpa);
@@ -376,9 +401,11 @@ pub const Log = struct {
self.log_bytes += total;
}
/// One fsync for the whole deferred batch, sealing the current block
/// first. Callers must have set defer_sync, appended, and cleared
/// defer_sync again before the reply.
/// The log's only commit point: seal the open block, then one fsync.
///
/// Appends never sync (see `append_record`), so nothing is durable until
/// this returns -- which is why Engine.commit calls it exactly once per
/// write command, coalescing every writer in flight into a single fsync.
pub fn sync(self: *Log) !void {
try self.seal_block();
try self.file.sync(self.io);
@@ -817,3 +844,74 @@ test "torn tail truncates cleanly and appends overwrite it" {
try log3.replay(@ptrCast(&ctx), Ctx.apply);
try testing.expectEqualSlices(u8, &[_]u8{ 1, 3 }, seen.items);
}
test "Log.create discards a leftover file; Log.open keeps it" {
// Compaction reuses one tmp path, so a crashed or retried rewrite can leave
// a *longer* file there. `open` only rewinds end_pos to the header, so the
// predecessor's trailing blocks would survive past the new content -- and
// they are intact and hash-correct, so replay applies them as live records
// once the rename publishes the file. `create` is what prevents that.
var threaded: std.Io.Threaded = .init_single_threaded;
defer threaded.deinit();
const io = threaded.io();
const gpa = testing.allocator;
var tmp = try TmpLog.init(gpa);
defer tmp.deinit(gpa);
const path = tmp.path;
const doc_bytes = [_]u8{ 0x0E, 0, 0, 0, 0x10, '_', 'i', 'd', 0, 42, 0, 0, 0, 0 };
// Stand in for the abandoned rewrite: three records, synced, then closed.
{
var old = try Log.open(gpa, io, path);
defer old.close();
try old.append_upsert("db", "c", &doc_bytes, 1);
try old.append_upsert("db", "c", &doc_bytes, 2);
try old.append_upsert("db", "c", &doc_bytes, 3);
try old.sync();
try testing.expect(try old.file.length(io) > file_header_len);
}
const Ctx = struct {
seen: *std.ArrayListUnmanaged(u8),
gpa: std.mem.Allocator,
fn apply(ctx: *anyopaque, record: Record, doc: *bson.Document) anyerror!void {
const self: *@This() = @ptrCast(@alignCast(ctx));
try self.seen.append(self.gpa, @intCast(record.seq));
doc.deinit();
self.gpa.destroy(doc);
}
};
var seen: std.ArrayListUnmanaged(u8) = .empty;
defer seen.deinit(gpa);
var ctx = Ctx{ .seen = &seen, .gpa = gpa };
// `open` keeps the leftover bytes: this is the hazard being guarded against.
{
var kept = try Log.open(gpa, io, path);
defer kept.close();
try testing.expect(try kept.file.length(io) > file_header_len);
try kept.replay(@ptrCast(&ctx), Ctx.apply);
try testing.expectEqualSlices(u8, &[_]u8{ 1, 2, 3 }, seen.items);
}
// `create` leaves an empty log: nothing past the header, on disk or in the
// append position, so no stale record can be replayed.
{
var fresh = try Log.create(gpa, io, path);
defer fresh.close();
try testing.expectEqual(@as(u64, file_header_len), try fresh.file.length(io));
try testing.expectEqual(@as(u64, file_header_len), fresh.end_pos);
// One short record where three used to be: replay must see only it,
// proving the old tail is gone rather than merely skipped.
try fresh.append_upsert("db", "c", &doc_bytes, 9);
try fresh.sync();
try testing.expectEqual(try fresh.file.length(io), fresh.end_pos);
}
seen.clearRetainingCapacity();
var reopened = try Log.open(gpa, io, path);
defer reopened.close();
try reopened.replay(@ptrCast(&ctx), Ctx.apply);
try testing.expectEqualSlices(u8, &[_]u8{9}, seen.items);
}