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:
160
src/db.zig
160
src/db.zig
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user