storage: byte documents in a per-collection slab (roadmap item 4)

Documents live as canonical BSON bytes in a segmented per-collection slab
(fixed 8 MiB segments keep capacity slack under one segment); the docs map
holds flat offsets that stay valid across segment growth, and removed
documents leave garbage bytes until compaction rewrites. The per-document
ArenaAllocator and its second full Pair-tree copy are gone.

The matcher walks the stored bytes directly, skipping by length any field
the filter does not name (a new bson byte-walker: element_key, skip_value,
read_value with borrowed leaves, get_at, and a borrowed spine parse). The
byte matcher is differential-tested against the tree matcher on a corpus
and shares its operator logic. Stored documents are never materialized on
the scan path or in aggregate $match; $group reads group keys and sums
straight off the bytes. Sort, projection, findAndModify, updates and
index entry generation use a borrowed spine into the slab (or the byte
collector, which also replaced collect_values in build_entries). The
compaction threshold now counts uncompressed data volume, since a
compressed log would otherwise never trigger.

Measured (tests/e2e/results/phase5.txt): server RSS 1979 -> 539 MB (2.4x
smaller than MongoDB; phase1 baseline 2.0 GB), range-scan 22.5 -> ~12 ms
(parity, best run faster than MongoDB), proj 4.1 -> 3.4 ms, createIndex
parity. Verified: unit suite in all three modes with zero leaks, the
crash pair, e2e6, and the stress/spill programs.
This commit is contained in:
2026-08-02 22:15:07 +03:00
parent b4585106f1
commit 570900a6ef
12 changed files with 985 additions and 253 deletions

View File

@@ -10,8 +10,23 @@ const bson = @import("bson.zig");
const storage = @import("storage.zig");
const index = @import("index.zig");
/// One slab segment; slack is bounded by this (a geometric-growth array
/// would hold up to 2x its contents after doubling).
const slab_segment_size = 8 * 1024 * 1024;
pub const Collection = struct {
docs: std.StringHashMapUnmanaged(*bson.Document),
/// Documents live as canonical BSON bytes in a per-collection slab of
/// fixed segments; the map holds each document's flat slab offset.
/// Offsets stay valid forever: segments are append-only and never move,
/// so a segment's bytes are stable even when the segment list reallocates.
/// Segmenting (instead of one geometric-growth array) keeps the slab's
/// capacity slack under one segment — a single array would hold up to
/// 2x its contents after doubling. Removed documents leave garbage bytes
/// until compaction rewrites.
docs: std.StringHashMapUnmanaged(u64),
slab: std.ArrayListUnmanaged(std.ArrayListUnmanaged(u8)),
/// Flat offset where each segment begins; doc_bytes binary-searches it.
seg_starts: std.ArrayListUnmanaged(u64),
/// Secondary indexes (persisted through the log).
indexes: std.ArrayListUnmanaged(index.Index),
/// The implicit _id_ index: every document has an _id and it is not
@@ -25,7 +40,7 @@ pub const Collection = struct {
id_index: index.Index,
fn init(gpa: std.mem.Allocator) !Collection {
var self: Collection = .{ .docs = .empty, .indexes = .empty, .id_index = undefined };
var self: Collection = .{ .docs = .empty, .slab = .empty, .seg_starts = .empty, .indexes = .empty, .id_index = undefined };
const keys = [_]index.IndexKey{.{ .path = "_id", .descending = false }};
self.id_index = try index.Index.init(gpa, "_id_", &keys, false, false, null);
return self;
@@ -41,6 +56,40 @@ pub const Collection = struct {
return null;
}
/// Append `bytes` to the slab, returning its flat offset. The last
/// segment holds up to `slab_segment_size`; a full one starts the next.
fn slab_append(self: *Collection, gpa: std.mem.Allocator, bytes: []const u8) !u64 {
if (self.slab.items.len == 0) {
try self.slab.append(gpa, .empty);
try self.seg_starts.append(gpa, 0);
}
const last = &self.slab.items[self.slab.items.len - 1];
if (last.items.len + bytes.len > slab_segment_size) {
try self.slab.append(gpa, .empty);
try self.seg_starts.append(gpa, self.seg_starts.items[self.seg_starts.items.len - 1] + last.items.len);
return self.slab_append(gpa, bytes);
}
const off = self.seg_starts.items[self.seg_starts.items.len - 1] + last.items.len;
try last.appendSlice(gpa, bytes);
return off;
}
/// The canonical bytes of the document stored at `off` — a slice into a
/// segment, stable until the collection is freed or rebuilt.
pub fn doc_bytes(self: *const Collection, off: u64) []const u8 {
// Last segment start <= off (binary search over the starts).
var lo: usize = 0;
var hi: usize = self.slab.items.len;
while (lo + 1 < hi) {
const mid = lo + (hi - lo) / 2;
if (self.seg_starts.items[mid] <= off) lo = mid else hi = mid;
}
const seg = &self.slab.items[lo];
const in_seg: usize = @intCast(off - self.seg_starts.items[lo]);
const len: usize = std.mem.readInt(u32, seg.items[in_seg..][0..4], .little);
return seg.items[in_seg .. in_seg + len];
}
/// Remove and free the index with this name. Returns whether it existed.
fn remove_index(self: *Collection, gpa: std.mem.Allocator, name: []const u8) bool {
for (self.indexes.items, 0..) |ix, i| {
@@ -126,11 +175,12 @@ pub const Engine = struct {
coll.indexes.deinit(self.gpa);
var doc_it = coll.docs.iterator();
while (doc_it.next()) |doc_entry| {
doc_entry.value_ptr.*.deinit();
self.gpa.destroy(doc_entry.value_ptr.*);
self.gpa.free(doc_entry.key_ptr.*);
}
coll.docs.deinit(self.gpa);
for (coll.slab.items) |*seg| seg.deinit(self.gpa);
coll.slab.deinit(self.gpa);
coll.seg_starts.deinit(self.gpa);
}
/// Free every collection in a database along with its owned name keys.
@@ -153,12 +203,13 @@ pub const Engine = struct {
/// regenerating them from it, which is far cheaper than scanning.
fn evict_doc(self: *Engine, coll: *Collection, id_key: []const u8) void {
const old = coll.docs.fetchRemove(id_key) orelse return;
coll.id_index.remove_doc(self.gpa, old.value, old.key);
for (coll.indexes.items) |*ix| ix.remove_doc(self.gpa, old.value, old.key);
old.value.*.deinit();
self.gpa.destroy(old.value);
// Resolve the bytes before any mutation; the slab is untouched by
// index removal, so the slice is safe for the call.
const old_bytes = coll.doc_bytes(old.value);
coll.id_index.remove_doc(self.gpa, old_bytes, old.key);
for (coll.indexes.items) |*ix| ix.remove_doc(self.gpa, old_bytes, old.key);
self.gpa.free(old.key);
// This document's log record just became garbage.
// This document's log record (and its slab bytes) just became garbage.
self.live_docs -= 1;
self.dead_docs += 1;
}
@@ -229,17 +280,17 @@ pub const Engine = struct {
mode: enum { insert, replace },
) !void {
const coll = try self.get_or_create_collection(db_name, coll_name);
const owned = try self.own_with_id(doc, oid_gen);
const id_value = owned.get("_id") orelse unreachable;
// Ownership of the key moves to the map once `stored` is set; until
// then this frame still owns both it and `owned`.
const doc_bytes = try self.serialize_with_id(doc, oid_gen);
defer self.gpa.free(doc_bytes);
// A document _id materializes a spine; free it right after the key
// is serialized.
var id_arena = std.heap.ArenaAllocator.init(self.gpa);
defer id_arena.deinit();
const id_value = (try bson.get_at(id_arena.allocator(), doc_bytes, "_id")) orelse unreachable;
// Ownership of the key moves to the map once `stored` is set.
const id_key = try bson.serialize_value(self.gpa, id_value);
var stored = false;
errdefer if (!stored) {
owned.deinit();
self.gpa.destroy(owned);
self.gpa.free(id_key);
};
errdefer if (!stored) self.gpa.free(id_key);
self.dup_index = null;
// 1. Build entries for every index. ParallelArrays escapes here,
@@ -250,7 +301,7 @@ pub const Engine = struct {
built_list.deinit(self.gpa);
}
for (coll.indexes.items) |*ix| {
var built = try ix.build_entries(self.gpa, owned, id_key);
var built = try ix.build_entries(self.gpa, doc_bytes, id_key);
built_list.append(self.gpa, .{ .built = built, .ix = ix }) catch |err| {
built.deinit(self.gpa);
return err;
@@ -259,7 +310,7 @@ pub const Engine = struct {
{
// The implicit _id_ index, through the same protocol: reserved
// before the log append, inserted infallibly after it.
var built = try coll.id_index.build_entries(self.gpa, owned, id_key);
var built = try coll.id_index.build_entries(self.gpa, doc_bytes, id_key);
built_list.append(self.gpa, .{ .built = built, .ix = &coll.id_index }) catch |err| {
built.deinit(self.gpa);
return err;
@@ -286,16 +337,16 @@ pub const Engine = struct {
}
// 5. Log (and sync) before anything becomes visible.
const doc_bytes = try serialize_doc(self.gpa, owned);
defer self.gpa.free(doc_bytes);
self.seq += 1;
try self.log.append_upsert(db_name, coll_name, doc_bytes, self.seq);
// 6. Replace drops the old document (and its index entries).
if (mode == .replace) self.evict_doc(coll, id_key);
// 7. Publish the document and its entries.
try coll.docs.put(self.gpa, id_key, owned);
// 7. Publish the document and its entries: copy the bytes into the
// slab and record the offset.
const off = try coll.slab_append(self.gpa, doc_bytes);
try coll.docs.put(self.gpa, id_key, off);
self.live_docs += 1;
for (built_list.items) |*b| {
if (b.built.multikey) b.ix.multikey = true;
@@ -316,13 +367,16 @@ pub const Engine = struct {
fn remove(self: *Engine, db_name: []const u8, coll_name: []const u8, id_key: []const u8) !bool {
const db = self.dbs.get(db_name) orelse return false;
const coll = db.collections.getPtr(coll_name) orelse return false;
const doc = coll.docs.get(id_key) orelse return false;
const off = coll.docs.get(id_key) orelse return false;
// Log (and sync) the delete before removing it from memory, so the
// log always describes at least as much as the in-memory state.
// Replay only reads _id out of a delete record, so log just that
// rather than a copy of the whole document.
const id_pairs = [_]bson.Pair{.{ .key = "_id", .value = doc.get("_id") orelse unreachable }};
const id_bytes = coll.doc_bytes(off);
var id_arena = std.heap.ArenaAllocator.init(self.gpa);
defer id_arena.deinit();
const id_pairs = [_]bson.Pair{.{ .key = "_id", .value = (try bson.get_at(id_arena.allocator(), id_bytes, "_id")) orelse unreachable }};
var id_doc: std.ArrayListUnmanaged(u8) = .empty;
defer id_doc.deinit(self.gpa);
try bson.write_doc(&id_pairs, self.gpa, &id_doc);
@@ -341,9 +395,10 @@ pub const Engine = struct {
return db.collections.getPtr(coll_name);
}
pub fn get_doc(self: *Engine, db_name: []const u8, coll_name: []const u8, id_key: []const u8) ?*const bson.Document {
pub fn get_doc(self: *Engine, db_name: []const u8, coll_name: []const u8, id_key: []const u8) ?[]const u8 {
const coll = self.get_collection(db_name, coll_name) orelse return null;
return coll.docs.get(id_key);
const off = coll.docs.get(id_key) orelse return null;
return coll.doc_bytes(off);
}
pub fn drop_collection(self: *Engine, db_name: []const u8, coll_name: []const u8) !bool {
@@ -387,7 +442,7 @@ pub const Engine = struct {
// ix.deinit frees every appended key. Nothing is persisted.
var doc_it = coll.docs.iterator();
while (doc_it.next()) |entry| {
try ix.append_doc_entries(self.gpa, entry.value_ptr.*, entry.key_ptr.*);
try ix.append_doc_entries(self.gpa, coll.doc_bytes(entry.value_ptr.*), entry.key_ptr.*);
}
_ = try ix.finish_bulk(self.gpa, true);
@@ -526,22 +581,20 @@ pub const Engine = struct {
/// Deep-copy a document into engine-owned storage, prepending a
/// generated ObjectId `_id` when absent.
fn own_with_id(self: *Engine, doc: *const bson.Document, oid_gen: *bson.ObjectIdGen) !*bson.Document {
/// The canonical bytes of `doc`, with an ObjectId `_id` generated when
/// absent. The result is owned by the caller.
fn serialize_with_id(self: *Engine, doc: *const bson.Document, oid_gen: *bson.ObjectIdGen) ![]u8 {
if (doc.get("_id") != null) return serialize_doc(self.gpa, doc);
var pairs: std.ArrayListUnmanaged(bson.Pair) = .empty;
defer pairs.deinit(self.gpa);
if (doc.get("_id") == null) {
const oid = oid_gen.new(self.io);
try pairs.append(self.gpa, .{ .key = "_id", .value = .{ .object_id = oid } });
}
const oid = oid_gen.new(self.io);
try pairs.append(self.gpa, .{ .key = "_id", .value = .{ .object_id = oid } });
try pairs.appendSlice(self.gpa, doc.pairs);
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(self.gpa);
try bson.write_doc(pairs.items, self.gpa, &out);
const owned = try self.gpa.create(bson.Document);
errdefer self.gpa.destroy(owned);
owned.* = try bson.Document.parse(self.gpa, out.items);
return owned;
return out.toOwnedSlice(self.gpa);
}
/// Keep the log file at roughly 1.5x the live data, rather than
@@ -560,7 +613,10 @@ pub const Engine = struct {
/// reclaimed; when that is little, we back the baseline off
/// multiplicatively so a garbage-free log is left alone.
fn maybe_compact(self: *Engine) !void {
if (self.log.end_pos < self.compact_threshold) return;
// The threshold counts data volume (uncompressed record bytes), not
// the on-disk size: a compressed log would otherwise stay under any
// byte threshold and never compact its garbage.
if (self.log.data_bytes < self.compact_threshold) return;
// Only rewrite when enough of the log is actually garbage. The old
// rule fired on bytes appended, which is the wrong question twice
// over: a 1 GB bulk load has no garbage at all yet would compact
@@ -603,8 +659,8 @@ pub const Engine = struct {
}
var doc_it = coll_entry.value_ptr.docs.iterator();
while (doc_it.next()) |doc_entry| {
const doc_bytes = try serialize_doc(self.gpa, doc_entry.value_ptr.*);
defer self.gpa.free(doc_bytes);
// The slab bytes are the canonical serialization.
const doc_bytes = coll_entry.value_ptr.doc_bytes(doc_entry.value_ptr.*);
try new_log.append_upsert(db_entry.key_ptr.*, coll_entry.key_ptr.*, doc_bytes, self.seq);
}
}
@@ -667,7 +723,7 @@ pub const Engine = struct {
if (ix.count() > 0) return; // defensive
var doc_it = coll.docs.iterator();
while (doc_it.next()) |doc_entry| {
ix.append_doc_entries(self.gpa, doc_entry.value_ptr.*, doc_entry.key_ptr.*) catch |err| switch (err) {
ix.append_doc_entries(self.gpa, coll.doc_bytes(doc_entry.value_ptr.*), doc_entry.key_ptr.*) catch |err| switch (err) {
error.ParallelArrays => {
std.debug.print("mongo-lite: WARNING: index '{s}' cannot index an existing document; entry skipped\n", .{ix.name});
continue;
@@ -712,11 +768,12 @@ fn serialize_doc(gpa: std.mem.Allocator, doc: *const bson.Document) ![]u8 {
fn apply_record(ctx: *anyopaque, record: storage.Record, doc: *bson.Document) anyerror!void {
const self: *Engine = @ptrCast(@alignCast(ctx));
var stored = false;
defer if (!stored) {
// The document is transient: only its canonical bytes are stored in the
// collection slab. Always owned by this frame.
defer {
doc.deinit();
self.gpa.destroy(doc);
};
}
const coll = self.get_or_create_collection(record.db, record.coll) catch return;
@@ -754,10 +811,12 @@ fn apply_record(ctx: *anyopaque, record: storage.Record, doc: *bson.Document) an
switch (record.type) {
storage.record_type_upsert => {
self.evict_doc(coll, id_key);
try coll.docs.put(self.gpa, id_key, doc);
const doc_bytes = try serialize_doc(self.gpa, doc);
defer self.gpa.free(doc_bytes);
const off = try coll.slab_append(self.gpa, doc_bytes);
try coll.docs.put(self.gpa, id_key, off);
self.live_docs += 1;
key_owned = true;
stored = true;
// The _id_ entry is added after replay, in build_all_indexes,
// together with the secondary indexes.
},
@@ -823,7 +882,7 @@ test "insert, query, remove" {
defer gpa.free(id_key);
try engine.lock();
const found = engine.get_doc("app", "users", id_key).?;
try testing.expectEqualStrings("bob", found.get("name").?.string);
try testing.expectEqualStrings("bob", (try bson.get_at(gpa, found, "name")).?.string);
const removed = try engine.remove_by_id("app", "users", .{ .int32 = 2 });
try testing.expect(removed);
engine.unlock();
@@ -952,7 +1011,7 @@ test "reopen replays log" {
try testing.expect(engine2.get_doc("app", "users", id_key) == null);
const id_key1 = try bson.serialize_value(gpa, bson.Value{ .int32 = 1 });
defer gpa.free(id_key1);
try testing.expectEqualStrings("alice", engine2.get_doc("app", "users", id_key1).?.get("name").?.string);
try testing.expectEqualStrings("alice", (try bson.get_at(gpa, engine2.get_doc("app", "users", id_key1).?, "name")).?.string);
engine2.unlock();
}
@@ -991,7 +1050,8 @@ test "auto _id generation survives reopen" {
var count: usize = 0;
while (it.next()) |entry| {
count += 1;
try testing.expect(entry.value_ptr.*.get("_id").?.object_id.len == 12);
const b = coll.doc_bytes(entry.value_ptr.*);
try testing.expect((try bson.get_at(gpa, b, "_id")).?.object_id.len == 12);
}
try testing.expectEqual(@as(usize, 1), count);
}