db/storage: reclaim the log once a checkpoint covers it
The point of a lagging checkpoint: a record whose effect the data file already holds is redundant, so the log can go back to just its header. Without this the log only grows and every open pays for every write ever made. Ordering, which is the whole safety argument: publish the watermark, *then* truncate. The other way round, a crash between them leaves the records gone from the log and absent from any image. A failed truncation is a warning rather than an error -- it costs space and replay time, and loses nothing, so it must not fail a checkpoint that already succeeded. Also wires checkpointing up, which nothing did before. `note_checkpoint` arms it when the log passes a threshold, and the write epilogue and the TTL monitor both claim it -- outside any collection lock, for the same reason compaction runs there: it takes the log lock. The threshold is separate from the compaction one on purpose: compaction is about the garbage share of the data, a checkpoint is about how much replay an open would otherwise do. -- Two things the tests taught me. The first version measured the log before the checkpoint and found 16 bytes -- just the header. Appends buffer in the log's open block and only a commit seals and writes it, so there was nothing on disk to shrink. The test commits first now, and says why. And the "no valid watermark" warning fired for every young database, which is its normal state before the first checkpoint. It now distinguishes a watermark that was *written and cannot be read* from one that was never written -- warning about the ordinary case is how people learn to ignore the warning that matters. Mutation-checked, red: skipping the truncation. Not covered, and the test says so: moving the truncation before the publish, whose failure mode is a crash landing between the two. That needs process-level crash injection, which an in-process test cannot express.
This commit is contained in:
111
src/db.zig
111
src/db.zig
@@ -231,6 +231,14 @@ pub const Engine = struct {
|
||||
/// rewrite is worth doing — see `note_compact`.
|
||||
live_docs: u64 = 0,
|
||||
dead_docs: u64 = 0,
|
||||
/// Set when the log has grown enough since the last checkpoint to be worth
|
||||
/// reclaiming. Read by the write epilogue and the TTL monitor, both of which
|
||||
/// run without holding a collection lock.
|
||||
checkpoint_pending: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
|
||||
/// Log bytes that trigger a checkpoint. Distinct from the compaction
|
||||
/// threshold: compaction is about the *garbage share* of the data, a
|
||||
/// checkpoint is about how much replay an open would otherwise have to do.
|
||||
checkpoint_threshold: u64 = 32 * 1024 * 1024,
|
||||
/// Whether replay must maintain index entries as it goes.
|
||||
///
|
||||
/// A full replay does not: it puts documents in place and lets
|
||||
@@ -729,6 +737,7 @@ pub const Engine = struct {
|
||||
// is dead. Leaving it promised would grow the file on every write.
|
||||
self.pager.release_reservation();
|
||||
self.note_compact();
|
||||
self.note_checkpoint();
|
||||
}
|
||||
|
||||
/// Remove a document by its `_id` value. Returns true if it existed.
|
||||
@@ -1057,6 +1066,18 @@ pub const Engine = struct {
|
||||
/// wanted. It runs in the command epilogue, after the collection lock is
|
||||
/// released — never inline, since compact takes the collection locks
|
||||
/// itself and would deadlock against the caller's.
|
||||
/// Arm a checkpoint when the log has grown past the threshold. Cheap enough
|
||||
/// to call on every write: one relaxed load and a compare.
|
||||
fn note_checkpoint(self: *Engine) void {
|
||||
if (self.log.log_bytes < self.checkpoint_threshold) return;
|
||||
self.checkpoint_pending.store(true, .release);
|
||||
}
|
||||
|
||||
/// Claim a pending checkpoint, if there is one.
|
||||
pub fn take_checkpoint(self: *Engine) bool {
|
||||
return self.checkpoint_pending.swap(false, .acq_rel);
|
||||
}
|
||||
|
||||
fn note_compact(self: *Engine) void {
|
||||
// The threshold counts data volume (uncompressed record bytes), not
|
||||
// the on-disk size: a compressed log would otherwise stay under any
|
||||
@@ -1597,6 +1618,17 @@ pub const Engine = struct {
|
||||
return err;
|
||||
};
|
||||
self.pager.release_reservation();
|
||||
// The watermark is durable, so every record it covers is now
|
||||
// redundant. Strictly after the publish: the other order loses data
|
||||
// if a crash lands between them.
|
||||
self.log.truncate_to_header() catch |err| {
|
||||
// A failed truncation wastes space and costs replay time on the
|
||||
// next open; it does not lose anything, because the records are
|
||||
// still there and still above no watermark. Not worth failing
|
||||
// the checkpoint that already succeeded.
|
||||
std.debug.print("multiforadb: WARNING: log truncation failed: {s}\n", .{@errorName(err)});
|
||||
};
|
||||
self.committed_seq = snapshot_seq;
|
||||
self.log_lock.unlock(self.io);
|
||||
return;
|
||||
}
|
||||
@@ -2508,6 +2540,85 @@ test "a checkpoint lets the next open skip the log it covers" {
|
||||
}
|
||||
}
|
||||
|
||||
test "a checkpoint reclaims the log and the data survives" {
|
||||
// The payoff of a lagging checkpoint: once the data file holds the effect of
|
||||
// a record, the record is redundant and the log can be reclaimed. Without
|
||||
// this the log only ever grows and every open pays for every write ever made.
|
||||
//
|
||||
// Mutation check, red: skipping the truncation.
|
||||
//
|
||||
// Not covered: moving the truncation *before* the publish. That is still
|
||||
// correct in the absence of a crash -- the publish follows immediately -- and
|
||||
// the hazard is precisely a crash landing between the two, with the records
|
||||
// gone from the log and not yet in any image. Catching it needs process-level
|
||||
// crash injection, which the milestone's gates cover; an in-process test
|
||||
// cannot express "stop here and die". The order stays because it is the
|
||||
// whole reason a lagging checkpoint is safe.
|
||||
var threaded: std.Io.Threaded = .init_single_threaded;
|
||||
defer threaded.deinit();
|
||||
var env = test_env(&threaded);
|
||||
const io = env.io;
|
||||
const gpa = testing.allocator;
|
||||
|
||||
var tmp = try TmpLog.init(gpa);
|
||||
defer tmp.deinit(gpa);
|
||||
const data_path = try std.fmt.allocPrint(gpa, "{s}.data", .{tmp.path});
|
||||
defer gpa.free(data_path);
|
||||
defer std.Io.Dir.cwd().deleteFile(io, data_path) catch {};
|
||||
|
||||
var log_after_checkpoint: u64 = 0;
|
||||
{
|
||||
var engine = try Engine.open(gpa, io, tmp.path);
|
||||
defer engine.deinit();
|
||||
try engine.lock();
|
||||
var i: i32 = 0;
|
||||
while (i < 200) : (i += 1) {
|
||||
var d = try make_user(gpa, i, "a@x.io");
|
||||
defer d.deinit();
|
||||
try engine.insert("app", "users", &d, &env.gen);
|
||||
}
|
||||
engine.unlock();
|
||||
|
||||
// Commit first, so the records are actually on disk: appends buffer in
|
||||
// the log's open block, and only a commit seals and writes it. Measuring
|
||||
// before that reads a file that is still just its header.
|
||||
try engine.commit();
|
||||
const before = try engine.log.file.length(io);
|
||||
try testing.expect(before > storage.file_header_len);
|
||||
|
||||
try engine.checkpoint();
|
||||
log_after_checkpoint = try engine.log.file.length(io);
|
||||
// The log is back to just its header.
|
||||
try testing.expect(log_after_checkpoint < before);
|
||||
try testing.expectEqual(@as(u64, storage.file_header_len), log_after_checkpoint);
|
||||
// And writing still works afterwards, at a sequence above the watermark.
|
||||
try engine.lock();
|
||||
var d = try make_user(gpa, 999, "z@x.io");
|
||||
defer d.deinit();
|
||||
try engine.insert("app", "users", &d, &env.gen);
|
||||
engine.unlock();
|
||||
try testing.expect(engine.seq > engine.pager.loaded.seq);
|
||||
}
|
||||
|
||||
// Everything is still there after a reopen: 200 from the image, 1 from the
|
||||
// log records written after the truncation.
|
||||
{
|
||||
var engine = try Engine.open(gpa, io, tmp.path);
|
||||
defer engine.deinit();
|
||||
try engine.lock();
|
||||
defer engine.unlock();
|
||||
const coll = engine.get_collection("app", "users").?;
|
||||
try testing.expectEqual(@as(usize, 201), coll.docs.count());
|
||||
try testing.expectEqual(@as(usize, 201), coll.id_index.count());
|
||||
const id_key = try bson.serialize_value(gpa, .{ .int32 = 999 });
|
||||
defer gpa.free(id_key);
|
||||
try testing.expect(engine.get_doc("app", "users", id_key) != null);
|
||||
const first_key = try bson.serialize_value(gpa, .{ .int32 = 0 });
|
||||
defer gpa.free(first_key);
|
||||
try testing.expect(engine.get_doc("app", "users", first_key) != null);
|
||||
}
|
||||
}
|
||||
|
||||
test "index drop survives reopen" {
|
||||
var threaded: std.Io.Threaded = .init_single_threaded;
|
||||
defer threaded.deinit();
|
||||
|
||||
Reference in New Issue
Block a user