storage/db: XxHash3 record integrity, garbage-ratio compaction
Two streams of work land together: they are interleaved in storage.zig
and db.zig and only build as a unit.
Already in the working tree before this session:
- ReleaseFast as the default zig build (Debug was 10-200x slower)
- group commit: one fsync per write command instead of per document
- plan_id returned a pointer to a stack temporary; ReleaseFast read
garbage and silently broke findOne({_id: ObjectId})
- perf suite: big.js, compare.js, compare-run.sh, e2e6.js
Phase 1 performance work:
Record integrity hash CRC32 -> XxHash3. std.hash.Crc32 is the
table-driven byte-at-a-time Crc32IsoHdlc, measured at 408 MB/s against
XxHash3's 31 GB/s: 38us versus 0.5us on a 16 KiB document, which was
about two thirds of the entire bulk-insert cost. The record header
grows from u32 crc to u64 hash (header_len 20 -> 24), a breaking
format change. Bulk insert 260 -> 700 MB/s, reopen 1.1 -> 0.5s.
Compaction fsynced once per live document, because Log.open leaves
defer_sync false and compact never set it. It now issues one sync for
the whole rewrite, before the rename that publishes it.
Compaction triggers on the share of the log that is garbage
(live_docs/dead_docs, maintained at evict_doc, the single point where
a document dies) rather than on bytes appended. A fixed byte count is
wrong in both directions: a 1 GB bulk load holds no garbage at all yet
would compact ~64 times under the 16 MiB default, rewriting 1 GB each
time, while a small collection rewritten in place accumulates garbage
indefinitely without ever reaching the count. Pure inserts now never
compact, and the file stays near 1.25x the live data. Bulk load at the
default threshold: 41.6 -> 702.7 MB/s.
remove() never called maybe_compact, so a delete-heavy workload grew
the log without bound.
e2e6's compaction check required the file to bloat past 30 MiB before
being reclaimed, which encoded the old policy and failed on strictly
better behaviour (ends at 15.8 MB against ~12 MB live, was ~30 MB). It
now asserts the file ends near the live size and peaked well above it,
which does not depend on when the trigger fires. Sampling interval 50
-> 10ms: the operations now finish inside the old window.
Verified: 70 unit tests under both ReleaseFast and ReleaseSafe, e2e
29/16/17/3/2 checks, the crash-a/kill -9/crash-b pair, and e2e6 72/72
across three consecutive runs.
This commit is contained in:
@@ -1,13 +1,19 @@
|
||||
//! Append-only record log. Each record is:
|
||||
//! [0..4) u32 len — total record bytes
|
||||
//! [4..8) u32 crc32 over bytes [8..len)
|
||||
//! [8..16) u64 seq
|
||||
//! [16] u8 type
|
||||
//! [17..20) reserved
|
||||
//! [20..) db\0 coll\0 bson doc
|
||||
//! [0..4) u32 len — total record bytes
|
||||
//! [4..12) u64 hash over bytes [12..len)
|
||||
//! [12..20) u64 seq
|
||||
//! [20] u8 type
|
||||
//! [21..24) reserved
|
||||
//! [24..) db\0 coll\0 bson doc
|
||||
//! All integers little-endian. Reads and writes are positional, so the fd
|
||||
//! offset never matters. A torn tail record (crash mid-append) is detected
|
||||
//! during replay and skipped.
|
||||
//!
|
||||
//! The integrity hash is XxHash3, not CRC32. Both are checked for the same
|
||||
//! thing — did these bytes survive intact — but std.hash.Crc32 is the
|
||||
//! table-driven byte-at-a-time Crc32IsoHdlc, measured here at 408 MB/s
|
||||
//! against XxHash3's 31 GB/s. On a 16 KiB document that is 38 us versus
|
||||
//! 0.5 us, which was roughly two thirds of the entire bulk-insert cost.
|
||||
|
||||
const std = @import("std");
|
||||
const bson = @import("bson.zig");
|
||||
@@ -17,7 +23,12 @@ pub const record_type_delete: u8 = 2;
|
||||
pub const record_type_index_create: u8 = 3;
|
||||
pub const record_type_index_drop: u8 = 4;
|
||||
|
||||
pub const header_len: usize = 20; // len + crc + seq + type + reserved
|
||||
pub const header_len: usize = 24; // len + hash + seq + type + reserved
|
||||
|
||||
/// Integrity hash over a record's bytes after the length and hash fields.
|
||||
fn record_hash(bytes: []const u8) u64 {
|
||||
return std.hash.XxHash3.hash(0, bytes);
|
||||
}
|
||||
|
||||
/// Largest record payload we will accept during replay. Matches the
|
||||
/// announced maxBsonObjectSize with room for names and header.
|
||||
@@ -31,7 +42,7 @@ pub const Record = struct {
|
||||
};
|
||||
|
||||
pub const Error = error{
|
||||
InvalidLog, // corrupt interior record (bad CRC or impossible length)
|
||||
InvalidLog, // corrupt interior record (bad hash or impossible length)
|
||||
};
|
||||
|
||||
/// Callback receives transient slices and a heap-allocated, freshly parsed
|
||||
@@ -48,6 +59,11 @@ pub const Log = struct {
|
||||
// Reused record-framing buffer. Appends are single-writer (the engine's
|
||||
// exclusive lock), so one buffer avoids a realloc cycle per record.
|
||||
scratch: std.ArrayListUnmanaged(u8),
|
||||
/// Group commit: while set, appends skip the per-record 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,
|
||||
|
||||
pub fn open(gpa: std.mem.Allocator, io: std.Io, path: []const u8) !Log {
|
||||
// Resolve to an absolute path so compaction can rename the file
|
||||
@@ -73,6 +89,7 @@ pub const Log = struct {
|
||||
.end_pos = 0,
|
||||
.log_bytes = 0,
|
||||
.scratch = .empty,
|
||||
.defer_sync = false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -117,13 +134,16 @@ pub const Log = struct {
|
||||
};
|
||||
const payload_read = self.file.readPositionalAll(self.io, payload, pos + 4) catch return error.InvalidLog;
|
||||
if (payload_read < payload_len) return; // torn tail — crash during append
|
||||
const crc_stored: u32 = std.mem.readInt(u32, payload[0..4], .little);
|
||||
const crc_actual = std.hash.Crc32.hash(payload[4..payload_len]);
|
||||
if (crc_stored != crc_actual) return error.InvalidLog;
|
||||
// Offsets here are relative to `payload`, which starts at the
|
||||
// record's byte 4 — so payload[0..8) is the record's hash field,
|
||||
// and header_len - 4 is where db\0coll\0 begins.
|
||||
const hash_stored: u64 = std.mem.readInt(u64, payload[0..8], .little);
|
||||
const hash_actual = record_hash(payload[8..payload_len]);
|
||||
if (hash_stored != hash_actual) return error.InvalidLog;
|
||||
|
||||
var idx: usize = 16; // after crc + seq + type + reserved
|
||||
const seq: u64 = std.mem.readInt(u64, payload[4..12], .little);
|
||||
const rtype = payload[12];
|
||||
var idx: usize = header_len - 4;
|
||||
const seq: u64 = std.mem.readInt(u64, payload[8..16], .little);
|
||||
const rtype = payload[16];
|
||||
const db = read_cstring(payload, &idx) orelse return error.InvalidLog;
|
||||
const coll = read_cstring(payload, &idx) orelse return error.InvalidLog;
|
||||
if (idx > payload_len) return error.InvalidLog;
|
||||
@@ -173,8 +193,8 @@ pub const Log = struct {
|
||||
const buf = &self.scratch;
|
||||
buf.clearRetainingCapacity();
|
||||
try buf.appendNTimes(self.gpa, 0, header_len);
|
||||
std.mem.writeInt(u64, buf.items[8..16], seq, .little);
|
||||
buf.items[16] = rtype;
|
||||
std.mem.writeInt(u64, buf.items[12..20], seq, .little);
|
||||
buf.items[20] = rtype;
|
||||
try buf.appendSlice(self.gpa, db);
|
||||
try buf.append(self.gpa, 0);
|
||||
try buf.appendSlice(self.gpa, coll);
|
||||
@@ -183,10 +203,16 @@ pub const Log = struct {
|
||||
if (buf.items.len > std.math.maxInt(u32)) return error.LogTooLarge;
|
||||
const total: u32 = @intCast(buf.items.len);
|
||||
std.mem.writeInt(u32, buf.items[0..4], total, .little);
|
||||
std.mem.writeInt(u32, buf.items[4..8], std.hash.Crc32.hash(buf.items[8..]), .little);
|
||||
std.mem.writeInt(u64, buf.items[4..12], record_hash(buf.items[12..]), .little);
|
||||
try self.file.writePositionalAll(self.io, buf.items, self.end_pos);
|
||||
self.end_pos += buf.items.len;
|
||||
self.log_bytes += buf.items.len;
|
||||
if (!self.defer_sync) try self.file.sync(self.io);
|
||||
}
|
||||
|
||||
/// One fsync for the whole deferred batch. Callers must have set
|
||||
/// defer_sync, appended, and cleared defer_sync again before the reply.
|
||||
pub fn sync(self: *Log) !void {
|
||||
try self.file.sync(self.io);
|
||||
}
|
||||
|
||||
@@ -318,13 +344,16 @@ test "reject corrupt interior record" {
|
||||
try log.append_upsert("db", "c", &doc_bytes, 1);
|
||||
log.close();
|
||||
|
||||
// Corrupt the file: flip a byte in the middle of the record.
|
||||
// Corrupt the file: flip a byte in the middle of the record. The record
|
||||
// is header_len ++ "db\0" ++ "c\0" ++ doc, so this lands on the first
|
||||
// doc byte — inside the hashed range, and past every framing field.
|
||||
const corrupt_at = header_len + 5;
|
||||
const dir = std.Io.Dir.cwd();
|
||||
var f = try dir.openFile(io, path, .{ .mode = .read_write });
|
||||
var buf: [64]u8 = undefined;
|
||||
const n = try f.readPositionalAll(io, &buf, 0);
|
||||
_ = n;
|
||||
buf[25] ^= 0xFF;
|
||||
buf[corrupt_at] ^= 0xFF;
|
||||
try f.writePositionalAll(io, buf[0..64], 0);
|
||||
f.close(io);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user