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:
2026-08-02 18:20:40 +03:00
parent d90cde394c
commit 556ad7dc86
12 changed files with 1572 additions and 55 deletions

View File

@@ -805,7 +805,15 @@ fn evaluate_index(gpa: std.mem.Allocator, ix: *const Index, clauses: []const Cla
// ---------------------------------------------------------------------------
pub const IdPlan = struct {
values: []const bson.Value, // aliases the filter
/// Values to look up in the docs map. A single $eq value is a heap copy
/// owned by this plan; a $in list aliases the filter's array. Freed by
/// deinit.
values: []const bson.Value,
owned: bool,
pub fn deinit(self: *IdPlan, gpa: std.mem.Allocator) void {
if (self.owned) gpa.free(self.values);
}
};
/// Plan for the implicit _id_ index (the docs map). Only applies to a
@@ -814,7 +822,11 @@ pub const IdPlan = struct {
/// int32 1, int64 1 and double 1.0 equal (and string/symbol/code "x" equal,
/// and nested variants), but serialize_value produces different map keys —
/// a hash lookup would then miss documents a scan would match.
pub fn plan_id(filter: []const bson.Pair) ?IdPlan {
///
/// The returned plan must be deinit'd: the single-value case is heap-copied
/// so the slice never points into a temporary (ReleaseFast reuses the stack,
/// which turned a dangling anonymous-list pointer into garbage).
pub fn plan_id(gpa: std.mem.Allocator, filter: []const bson.Pair) error{OutOfMemory}!?IdPlan {
// The first usable _id clause wins; no flattening buffer is needed
// because nothing is compared across clauses. $and members are searched
// like top-level pairs, every other operator skipped — same rule as
@@ -832,29 +844,41 @@ pub fn plan_id(filter: []const bson.Pair) ?IdPlan {
.doc => |d| d,
else => continue,
};
if (plan_id(mp)) |found| return found;
if (try plan_id(gpa, mp)) |found| return found;
}
continue;
}
if (!std.mem.eql(u8, p.key, "_id")) continue;
if (id_lookup_values(p.value)) |values| return .{ .values = values };
if (try id_lookup_values(gpa, p.value)) |lookup| return .{ .values = lookup.values, .owned = lookup.owned };
}
return null;
}
const IdLookup = struct {
values: []const bson.Value,
owned: bool,
};
/// The map-lookup values for one _id clause, or null when it is not a pure
/// equality/$in of fast-path-safe values. A range is unusable here: the docs
/// map is a hash, not an ordered structure.
fn id_lookup_values(v: bson.Value) ?[]const bson.Value {
fn id_lookup_values(gpa: std.mem.Allocator, v: bson.Value) error{OutOfMemory}!?IdLookup {
var info = CompInfo{};
analyze_clause(v, &info);
if (info.lo != null or info.hi != null) return null;
if (info.eq) |e| return if (value_fast_path_safe(e)) &.{e} else null;
if (info.eq) |e| {
if (!value_fast_path_safe(e)) return null;
// Heap-copy the single value: a pointer to a stack or anonymous
// temporary would dangle once this frame returns.
const buf = try gpa.alloc(bson.Value, 1);
buf[0] = e;
return .{ .values = buf[0..1], .owned = true };
}
if (info.in_values) |list| {
for (list) |m| {
if (!value_fast_path_safe(m)) return null;
}
return list;
return .{ .values = list, .owned = false };
}
return null;
}
@@ -1108,43 +1132,60 @@ test "compound index prefix search and range on the next key" {
}
test "id fast path guards and $in" {
// plan_id may heap-copy the single value; run it through the allocator
// and free. The helper asserts on whether a plan was produced.
const gpa = testing.allocator;
const plans = struct {
fn has(pairs: []const bson.Pair) !bool {
var p = try plan_id(gpa, pairs);
defer if (p) |*pl| pl.deinit(gpa);
return p != null;
}
};
// Numbers never use the fast path (compare-equal but serialize-different).
try testing.expect(plan_id(&.{.{ .key = "_id", .value = .{ .int32 = 1 } }}) == null);
try testing.expect(!try plans.has(&.{.{ .key = "_id", .value = .{ .int32 = 1 } }}));
// Strings are skipped too: a stored symbol/code _id compare-equals a
// string query but serializes differently.
try testing.expect(plan_id(&.{.{ .key = "_id", .value = .{ .string = "x" } }}) == null);
try testing.expect(!try plans.has(&.{.{ .key = "_id", .value = .{ .string = "x" } }}));
// Truly canonical values (bool, ObjectId) do use it.
try testing.expect(plan_id(&.{.{ .key = "_id", .value = .{ .bool = true } }}) != null);
try testing.expect(try plans.has(&.{.{ .key = "_id", .value = .{ .bool = true } }}));
const oid = [_]u8{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 };
try testing.expect(plan_id(&.{.{ .key = "_id", .value = .{ .object_id = oid } }}) != null);
try testing.expect(try plans.has(&.{.{ .key = "_id", .value = .{ .object_id = oid } }}));
// $in with a number member is skipped.
const mixed = [_]bson.Pair{.{ .key = "_id", .value = .{ .doc = &.{
.{ .key = "$in", .value = .{ .array = &.{ .{ .bool = true }, .{ .int32 = 1 } } } },
} } }};
try testing.expect(plan_id(&mixed) == null);
try testing.expect(!try plans.has(&mixed));
const safe_in = [_]bson.Pair{.{ .key = "_id", .value = .{ .doc = &.{
.{ .key = "$in", .value = .{ .array = &.{ .{ .bool = true }, .{ .bool = false } } } },
} } }};
try testing.expect(plan_id(&safe_in) != null);
try testing.expect(try plans.has(&safe_in));
// $and members count as top-level.
const and_f = [_]bson.Pair{.{ .key = "$and", .value = .{ .array = &.{
.{ .doc = &.{.{ .key = "_id", .value = .{ .bool = true } }} },
} } }};
try testing.expect(plan_id(&and_f) != null);
try testing.expect(try plans.has(&and_f));
// $or is not usable for the fast path.
const or_f = [_]bson.Pair{.{ .key = "$or", .value = .{ .array = &.{
.{ .doc = &.{.{ .key = "_id", .value = .{ .bool = true } }} },
} } }};
try testing.expect(plan_id(&or_f) == null);
try testing.expect(!try plans.has(&or_f));
// A doc containing a number is not fast-path safe.
const doc_id = [_]bson.Pair{.{ .key = "_id", .value = .{ .doc = &.{.{ .key = "n", .value = .{ .int32 = 1 } }} } }};
try testing.expect(plan_id(&doc_id) == null);
try testing.expect(!try plans.has(&doc_id));
// A doc containing a string is unsafe too (string/symbol/code class).
const doc_str = [_]bson.Pair{.{ .key = "_id", .value = .{ .doc = &.{.{ .key = "n", .value = .{ .string = "x" } }} } }};
try testing.expect(plan_id(&doc_str) == null);
try testing.expect(!try plans.has(&doc_str));
// A doc of canonical values is safe.
const doc_safe = [_]bson.Pair{.{ .key = "_id", .value = .{ .doc = &.{.{ .key = "n", .value = .{ .bool = true } }} } }};
try testing.expect(plan_id(&doc_safe) != null);
try testing.expect(try plans.has(&doc_safe));
// The single-value plan owns a usable copy: the ObjectId must read back
// exactly (this is the ReleaseFast regression the fix guards — a dangling
// pointer read back as garbage).
var p = try plan_id(gpa, &.{.{ .key = "_id", .value = .{ .object_id = oid } }});
defer if (p) |*pl| pl.deinit(gpa);
try testing.expect(p != null);
try testing.expectEqualSlices(u8, &oid, &p.?.values[0].object_id);
}
test "TTL spec round-trips through write_spec and compares in spec_equal" {