createIndex built the entry array one document at a time, and each
insert kept the array sorted by memmoving the tail -- O(n^2) bytes moved
over a full build, which was the entire cost of the operation. Entries
are now appended unsorted and ordered once (append_doc_entries +
finish_bulk), with uniqueness checked by a single adjacent-pair scan
instead of a binary search per document. build_all_indexes, which runs
for every index on every open, takes the same path.
createIndex over 65,536 documents, measured A/B:
{k: 1} 649ms -> 56ms
{s: 1} unique 678ms -> 53ms
{p: 1, k: -1} 653ms -> 54ms
lookup_range binary-searched only the equality prefix and then scanned
that whole band applying a filter, so a range on the first component of
an index touched every entry in it. Both ends are now binary searches
over the component the array is already sorted on, clamped into the
equality band. Note this does not move the range-scan row in compare.js:
that query filters on p, which has no index there, so it is a collection
scan and belongs to the matcher.
cmd_find passed a hardcoded 0 as the scan limit, so find().limit(n)
materialized the entire collection before slicing. It now stops once the
page is filled, when there is no sort to order the matches first; the
bound covers the skipped prefix because the scan counts matches rather
than returned documents.
lookup_range's bounds are checked by a new randomized test that compares
the result count against a brute-force filter over 600 generated
queries, with values chosen from a small domain so equal keys and the
inclusive/exclusive edges come up constantly. Verified it fails when
either bound is swapped.
1458 lines
61 KiB
Zig
1458 lines
61 KiB
Zig
//! In-memory database engine backed by the append-only log. Maps
|
|
//! db -> collection -> _id(serialized) -> owned Document. All mutations are
|
|
//! logged and synced before they become visible in memory, so a crash never
|
|
//! loses a committed write. Callers must hold the write lock (`lock`) around
|
|
//! any command that mutates state, and the read lock (`lock_read`) around
|
|
//! read-only commands so reads overlap with each other.
|
|
|
|
const std = @import("std");
|
|
const bson = @import("bson.zig");
|
|
const storage = @import("storage.zig");
|
|
const index = @import("index.zig");
|
|
|
|
pub const Collection = struct {
|
|
docs: std.StringHashMapUnmanaged(*bson.Document),
|
|
indexes: std.ArrayListUnmanaged(index.Index),
|
|
|
|
fn init() Collection {
|
|
return .{ .docs = .empty, .indexes = .empty };
|
|
}
|
|
|
|
/// The secondary index with this name, or null. The single by-name
|
|
/// lookup: index lifetime (who calls Index.deinit, and when) is decided
|
|
/// here rather than at each caller.
|
|
pub fn find_index(self: *Collection, name: []const u8) ?*index.Index {
|
|
for (self.indexes.items) |*ix| {
|
|
if (std.mem.eql(u8, ix.name, name)) return ix;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// 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| {
|
|
if (std.mem.eql(u8, ix.name, name)) {
|
|
var removed = self.indexes.orderedRemove(i);
|
|
removed.deinit(gpa);
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
};
|
|
|
|
pub const Db = struct {
|
|
collections: std.StringHashMapUnmanaged(Collection),
|
|
};
|
|
|
|
pub const Engine = struct {
|
|
gpa: std.mem.Allocator,
|
|
io: std.Io,
|
|
// One writer at a time (log append + fsync, map mutation); many
|
|
// concurrent readers (find/count/aggregate scans). Writer-preferring:
|
|
// a queued writer blocks new readers rather than starving.
|
|
rwlock: std.Io.RwLock,
|
|
log: storage.Log,
|
|
dbs: std.StringHashMapUnmanaged(Db),
|
|
seq: u64,
|
|
/// Floor for the compaction trigger. The real trigger also scales with
|
|
/// the live data size — see `maybe_compact`.
|
|
compact_threshold: u64,
|
|
/// Documents currently resident across every collection, and documents
|
|
/// superseded or deleted since the last compaction. Their ratio is the
|
|
/// share of the log that is garbage, which is what decides whether a
|
|
/// rewrite is worth doing — see `maybe_compact`.
|
|
live_docs: u64 = 0,
|
|
dead_docs: u64 = 0,
|
|
/// Set to the failing index's own stable name when an upsert is
|
|
/// rejected by a unique secondary index (error.DuplicateKeyIndex). The
|
|
/// command reads it while still holding the write lock.
|
|
dup_index: ?[]const u8 = null,
|
|
|
|
pub fn open(gpa: std.mem.Allocator, io: std.Io, path: []const u8) !Engine {
|
|
var engine = Engine{
|
|
.gpa = gpa,
|
|
.io = io,
|
|
.rwlock = .init,
|
|
.log = try storage.Log.open(gpa, io, path),
|
|
.dbs = .empty,
|
|
.seq = 0,
|
|
.compact_threshold = 16 * 1024 * 1024,
|
|
};
|
|
errdefer {
|
|
engine.log.close();
|
|
engine.dbs.deinit(gpa);
|
|
}
|
|
|
|
try engine.log.replay(&engine, apply_record);
|
|
// Replay registers empty indexes; build them from the live docs
|
|
// once replay completes (order-independent).
|
|
try engine.build_all_indexes();
|
|
return engine;
|
|
}
|
|
|
|
pub fn deinit(self: *Engine) void {
|
|
var db_it = self.dbs.iterator();
|
|
while (db_it.next()) |db_entry| {
|
|
self.free_db(db_entry.value_ptr);
|
|
self.gpa.free(db_entry.key_ptr.*);
|
|
}
|
|
self.dbs.deinit(self.gpa);
|
|
self.log.close();
|
|
}
|
|
|
|
/// Free every document in a collection along with its owned _id keys
|
|
/// and secondary indexes (whose entries alias the documents — freed
|
|
/// first).
|
|
fn free_collection(self: *Engine, coll: *Collection) void {
|
|
// Dropping a collection turns all of its records into garbage.
|
|
self.live_docs -= coll.docs.count();
|
|
self.dead_docs += coll.docs.count();
|
|
for (coll.indexes.items) |*ix| ix.deinit(self.gpa);
|
|
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);
|
|
}
|
|
|
|
/// Free every collection in a database along with its owned name keys.
|
|
fn free_db(self: *Engine, db: *Db) void {
|
|
var coll_it = db.collections.iterator();
|
|
while (coll_it.next()) |coll_entry| {
|
|
self.free_collection(coll_entry.value_ptr);
|
|
self.gpa.free(coll_entry.key_ptr.*);
|
|
}
|
|
db.collections.deinit(self.gpa);
|
|
}
|
|
|
|
/// Drop the document stored under `id_key`, freeing it and its key.
|
|
/// No-op when the id is absent. This is the single chokepoint where a
|
|
/// document dies, so index entries are removed here — before
|
|
/// old.value.deinit() and gpa.free(old.key) — keeping the entry aliasing
|
|
/// (values into the document arena, id into the docs map key) safe.
|
|
fn evict_doc(self: *Engine, coll: *Collection, id_key: []const u8) void {
|
|
for (coll.indexes.items) |*ix| ix.remove_id(self.gpa, id_key);
|
|
const old = coll.docs.fetchRemove(id_key) orelse return;
|
|
old.value.*.deinit();
|
|
self.gpa.destroy(old.value);
|
|
self.gpa.free(old.key);
|
|
// This document's log record just became garbage.
|
|
self.live_docs -= 1;
|
|
self.dead_docs += 1;
|
|
}
|
|
|
|
// -- commands (callers must hold the matching lock) ---------------------
|
|
|
|
/// Exclusive lock: for commands that mutate the engine.
|
|
pub fn lock(self: *Engine) !void {
|
|
try self.rwlock.lock(self.io);
|
|
}
|
|
|
|
pub fn unlock(self: *Engine) void {
|
|
self.rwlock.unlock(self.io);
|
|
}
|
|
|
|
/// Shared lock: for read-only commands (find, count, aggregate, list*).
|
|
/// Multiple readers may hold it simultaneously; writers wait for them.
|
|
pub fn lock_read(self: *Engine) !void {
|
|
try self.rwlock.lockShared(self.io);
|
|
}
|
|
|
|
pub fn unlock_read(self: *Engine) void {
|
|
self.rwlock.unlockShared(self.io);
|
|
}
|
|
|
|
/// Group commit: defer per-record fsyncs until end_batch. Callers must
|
|
/// hold the write lock and pair every begin with an end (the command's
|
|
/// defer). Every document published before end_batch is fsynced by it,
|
|
/// so an acknowledged multi-write command is durable as a unit — the
|
|
/// same crash guarantee as the old fsync-per-record, with one sync per
|
|
/// command instead of one per document.
|
|
pub fn begin_batch(self: *Engine) void {
|
|
self.log.defer_sync = true;
|
|
}
|
|
|
|
pub fn end_batch(self: *Engine) !void {
|
|
self.log.defer_sync = false;
|
|
try self.log.sync();
|
|
}
|
|
|
|
/// Insert a document. Fails with error.DuplicateKey if the _id exists.
|
|
/// Generates an ObjectId _id when absent.
|
|
pub fn insert(self: *Engine, db_name: []const u8, coll_name: []const u8, doc: *const bson.Document, oid_gen: *bson.ObjectIdGen) !void {
|
|
return self.upsert(db_name, coll_name, doc, oid_gen, .insert);
|
|
}
|
|
|
|
/// Insert or replace a document by _id (upsert without existence check).
|
|
pub fn replace(self: *Engine, db_name: []const u8, coll_name: []const u8, doc: *const bson.Document, oid_gen: *bson.ObjectIdGen) !void {
|
|
return self.upsert(db_name, coll_name, doc, oid_gen, .replace);
|
|
}
|
|
|
|
/// One document's built entries for one index, tracked so a failure
|
|
/// anywhere before the log append frees them all.
|
|
const Built = struct {
|
|
built: index.BuiltEntries,
|
|
ix: *index.Index,
|
|
};
|
|
|
|
/// Shared body of `insert` and `replace`: they differ only in how an
|
|
/// existing _id is treated. Logs (and syncs) the new document before it
|
|
/// becomes visible in memory.
|
|
fn upsert(
|
|
self: *Engine,
|
|
db_name: []const u8,
|
|
coll_name: []const u8,
|
|
doc: *const bson.Document,
|
|
oid_gen: *bson.ObjectIdGen,
|
|
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 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);
|
|
};
|
|
self.dup_index = null;
|
|
|
|
// 1. Build entries for every index. ParallelArrays escapes here,
|
|
// before anything is logged or mutated.
|
|
var built_list: std.ArrayListUnmanaged(Built) = .empty;
|
|
defer {
|
|
for (built_list.items) |*b| b.built.deinit(self.gpa);
|
|
built_list.deinit(self.gpa);
|
|
}
|
|
for (coll.indexes.items) |*ix| {
|
|
var built = try ix.build_entries(self.gpa, owned, id_key);
|
|
built_list.append(self.gpa, .{ .built = built, .ix = ix }) catch |err| {
|
|
built.deinit(self.gpa);
|
|
return err;
|
|
};
|
|
}
|
|
|
|
// 2. The _id check, mirroring the pre-index behavior.
|
|
if (mode == .insert and coll.docs.contains(id_key)) return error.DuplicateKey;
|
|
|
|
// 3. Unique secondary-index checks; a rejected write never reaches
|
|
// the log.
|
|
for (built_list.items) |*b| {
|
|
if (!b.ix.unique) continue;
|
|
b.ix.check_unique(b.built.entries.items, id_key) catch {
|
|
self.dup_index = b.ix.name;
|
|
return error.DuplicateKeyIndex;
|
|
};
|
|
}
|
|
|
|
// 4. Reserve entry capacity — the last fallible step, so the entry
|
|
// insertion after the log append is infallible.
|
|
for (built_list.items) |*b| {
|
|
try b.ix.reserve_for(self.gpa, b.built.entries.items.len);
|
|
}
|
|
|
|
// 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);
|
|
self.live_docs += 1;
|
|
for (built_list.items) |*b| {
|
|
if (b.built.multikey) b.ix.multikey = true;
|
|
b.ix.insert_entries(&b.built);
|
|
}
|
|
stored = true;
|
|
try self.maybe_compact();
|
|
}
|
|
|
|
/// Remove a document by its `_id` value. Returns true if it existed.
|
|
/// The serialized-key encoding stays private to the engine.
|
|
pub fn remove_by_id(self: *Engine, db_name: []const u8, coll_name: []const u8, id: bson.Value) !bool {
|
|
const id_key = try bson.serialize_value(self.gpa, id);
|
|
defer self.gpa.free(id_key);
|
|
return self.remove(db_name, coll_name, id_key);
|
|
}
|
|
|
|
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;
|
|
|
|
// 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 }};
|
|
var id_doc: std.ArrayListUnmanaged(u8) = .empty;
|
|
defer id_doc.deinit(self.gpa);
|
|
try bson.write_doc(&id_pairs, self.gpa, &id_doc);
|
|
self.seq += 1;
|
|
try self.log.append_delete(db_name, coll_name, id_doc.items, self.seq);
|
|
|
|
self.evict_doc(coll, id_key);
|
|
// Deletes grow the log too. Without this a delete-heavy workload
|
|
// never compacts, because only upsert and ttl_sweep used to check.
|
|
try self.maybe_compact();
|
|
return true;
|
|
}
|
|
|
|
pub fn get_collection(self: *Engine, db_name: []const u8, coll_name: []const u8) ?*Collection {
|
|
const db = self.dbs.get(db_name) orelse return null;
|
|
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 {
|
|
const coll = self.get_collection(db_name, coll_name) orelse return null;
|
|
return coll.docs.get(id_key);
|
|
}
|
|
|
|
pub fn drop_collection(self: *Engine, db_name: []const u8, coll_name: []const u8) !bool {
|
|
const db = self.dbs.getPtr(db_name) orelse return false;
|
|
var removed = db.collections.fetchRemove(coll_name) orelse return false;
|
|
self.free_collection(&removed.value);
|
|
self.gpa.free(removed.key);
|
|
return true;
|
|
}
|
|
|
|
pub fn drop_database(self: *Engine, db_name: []const u8) !bool {
|
|
var removed = self.dbs.fetchRemove(db_name) orelse return false;
|
|
self.free_db(&removed.value);
|
|
self.gpa.free(removed.key);
|
|
return true;
|
|
}
|
|
|
|
/// Build and register a secondary index from a spec document
|
|
/// ({key, name, unique?, sparse?}). The create record is written only
|
|
/// after the index builds over the existing documents and passes
|
|
/// uniqueness, so a rejected create persists nothing. Returns the new
|
|
/// index (or the existing one when the spec matches — idempotent).
|
|
pub fn create_index(self: *Engine, db_name: []const u8, coll_name: []const u8, spec_doc: *const bson.Document) !*index.Index {
|
|
const coll = try self.get_or_create_collection(db_name, coll_name);
|
|
var ix = try index.parse_spec(self.gpa, spec_doc);
|
|
var committed = false;
|
|
// Runs on every return path (including the idempotent no-op): the
|
|
// parsed spec is only owned by the collection once committed.
|
|
defer if (!committed) ix.deinit(self.gpa);
|
|
|
|
if (coll.find_index(ix.name)) |existing| {
|
|
if (index.Index.spec_equal(existing, &ix)) return existing;
|
|
return error.IndexOptionsConflict;
|
|
}
|
|
|
|
// Build entries over the existing documents (the index is not
|
|
// exposed until the end, so mutating it is safe). Entries are
|
|
// appended unsorted and ordered once at the end — inserting each
|
|
// document into a sorted array memmoves the tail every time, which
|
|
// is what made this quadratic. On any failure the deferred
|
|
// 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.finish_bulk(true);
|
|
|
|
// Reserve the collection slot, then persist and publish.
|
|
try coll.indexes.ensureUnusedCapacity(self.gpa, 1);
|
|
var spec_bytes: std.ArrayListUnmanaged(u8) = .empty;
|
|
defer spec_bytes.deinit(self.gpa);
|
|
try ix.write_spec(self.gpa, &spec_bytes);
|
|
self.seq += 1;
|
|
try self.log.append_index_create(db_name, coll_name, spec_bytes.items, self.seq);
|
|
|
|
coll.indexes.appendAssumeCapacity(ix);
|
|
committed = true;
|
|
return &coll.indexes.items[coll.indexes.items.len - 1];
|
|
}
|
|
|
|
/// Remove a secondary index by name, persisting a drop record first.
|
|
/// Returns false when no such index exists.
|
|
pub fn drop_index(self: *Engine, db_name: []const u8, coll_name: []const u8, index_name: []const u8) !bool {
|
|
const db = self.dbs.get(db_name) orelse return false;
|
|
const coll = db.collections.getPtr(coll_name) orelse return false;
|
|
if (coll.find_index(index_name) == null) return false;
|
|
|
|
const name_pairs = [_]bson.Pair{.{ .key = "name", .value = .{ .string = index_name } }};
|
|
var name_doc: std.ArrayListUnmanaged(u8) = .empty;
|
|
defer name_doc.deinit(self.gpa);
|
|
try bson.write_doc(&name_pairs, self.gpa, &name_doc);
|
|
self.seq += 1;
|
|
try self.log.append_index_drop(db_name, coll_name, name_doc.items, self.seq);
|
|
|
|
_ = coll.remove_index(self.gpa, index_name);
|
|
return true;
|
|
}
|
|
|
|
/// Delete every document expired as of `now_ms` (Unix milliseconds)
|
|
/// under some TTL index, and return how many were deleted. Callers must
|
|
/// hold the write lock; the server's monitor coroutine (src/server.zig)
|
|
/// is the only caller in production, tests call it with a fixed clock.
|
|
///
|
|
/// Each expiry goes through `remove`, so it is logged and fsynced like
|
|
/// any other delete and survives a restart. Expiry is therefore coarse
|
|
/// by design (as in MongoDB): an expired document stays visible until
|
|
/// the next sweep.
|
|
pub fn ttl_sweep(self: *Engine, now_ms: i64) !usize {
|
|
var deleted: usize = 0;
|
|
// Ids are duped rather than aliased: `remove` frees the docs-map key
|
|
// that `Entry.id` points at, which would leave the rest of the batch
|
|
// pointing into freed memory.
|
|
var ids: std.ArrayListUnmanaged([]u8) = .empty;
|
|
defer {
|
|
for (ids.items) |id| self.gpa.free(id);
|
|
ids.deinit(self.gpa);
|
|
}
|
|
|
|
var db_it = self.dbs.iterator();
|
|
while (db_it.next()) |db_entry| {
|
|
var coll_it = db_entry.value_ptr.collections.iterator();
|
|
while (coll_it.next()) |coll_entry| {
|
|
for (ids.items) |id| self.gpa.free(id);
|
|
ids.clearRetainingCapacity();
|
|
|
|
for (coll_entry.value_ptr.indexes.items) |*ix| {
|
|
const ttl = ix.ttl orelse continue;
|
|
const cutoff: i128 = @as(i128, now_ms) - @as(i128, ttl) * 1000;
|
|
for (ix.entries.items) |e| {
|
|
// The type test cannot be a range lookup: bson
|
|
// compare order ranks datetime above null, numbers
|
|
// and strings, so a datetime upper bound would also
|
|
// select every value of a lesser type.
|
|
if (e.key[0] != .datetime) continue;
|
|
if (@as(i128, e.key[0].datetime) > cutoff) continue;
|
|
try ids.append(self.gpa, try self.gpa.dupe(u8, e.id));
|
|
}
|
|
}
|
|
if (ids.items.len == 0) continue;
|
|
|
|
// One document can be expired by several entries (an array
|
|
// of dates) or by several TTL indexes.
|
|
std.mem.sort([]u8, ids.items, {}, less_id_bytes);
|
|
var w: usize = 1;
|
|
for (ids.items[1..]) |id| {
|
|
if (std.mem.eql(u8, id, ids.items[w - 1])) {
|
|
self.gpa.free(id);
|
|
} else {
|
|
ids.items[w] = id;
|
|
w += 1;
|
|
}
|
|
}
|
|
ids.items.len = w;
|
|
|
|
// `remove` only mutates the collection's docs map and index
|
|
// entries, never the dbs/collections maps, so both iterators
|
|
// above stay valid.
|
|
for (ids.items) |id| {
|
|
if (try self.remove(db_entry.key_ptr.*, coll_entry.key_ptr.*, id)) deleted += 1;
|
|
}
|
|
}
|
|
}
|
|
// A TTL-only workload never reaches the threshold check in `upsert`,
|
|
// so the log would otherwise grow without bound.
|
|
if (deleted > 0) try self.maybe_compact();
|
|
return deleted;
|
|
}
|
|
|
|
pub fn database_names(self: *Engine, out: *std.ArrayListUnmanaged([]const u8)) !void {
|
|
var it = self.dbs.iterator();
|
|
while (it.next()) |entry| try out.append(self.gpa, entry.key_ptr.*);
|
|
}
|
|
|
|
pub fn collection_names(self: *Engine, db_name: []const u8, out: *std.ArrayListUnmanaged([]const u8)) !void {
|
|
const db = self.dbs.get(db_name) orelse return;
|
|
var it = db.collections.iterator();
|
|
while (it.next()) |entry| try out.append(self.gpa, entry.key_ptr.*);
|
|
}
|
|
|
|
// -- internals -----------------------------------------------------------
|
|
|
|
pub fn get_or_create_collection(self: *Engine, db_name: []const u8, coll_name: []const u8) !*Collection {
|
|
const db = self.dbs.getPtr(db_name) orelse {
|
|
const db_key = try self.gpa.dupe(u8, db_name);
|
|
errdefer self.gpa.free(db_key);
|
|
try self.dbs.put(self.gpa, db_key, .{ .collections = .empty });
|
|
return self.get_or_create_collection(db_name, coll_name);
|
|
};
|
|
if (db.collections.getPtr(coll_name)) |coll| return coll;
|
|
const coll_key = try self.gpa.dupe(u8, coll_name);
|
|
errdefer self.gpa.free(coll_key);
|
|
try db.collections.put(self.gpa, coll_key, Collection.init());
|
|
return db.collections.getPtr(coll_name) orelse unreachable;
|
|
}
|
|
|
|
/// 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 {
|
|
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 } });
|
|
}
|
|
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;
|
|
}
|
|
|
|
/// Keep the log file at roughly 1.5x the live data, rather than
|
|
/// compacting every fixed number of appended bytes.
|
|
///
|
|
/// A fixed byte trigger makes total rewrite traffic quadratic: a 1 GB
|
|
/// dataset with a 16 MiB threshold compacts ~64 times, rewriting 1 GB
|
|
/// each time. Triggering on file size relative to the live size makes
|
|
/// successive compactions geometric, so the total bytes rewritten over
|
|
/// the life of the log is O(n) rather than O(n²) — and it bounds the
|
|
/// disk footprint directly, which is what the threshold is really for.
|
|
///
|
|
/// The other half of the problem is the opposite workload: a pure bulk
|
|
/// insert has no garbage at all, so every compaction rewrites a
|
|
/// perfectly compact file for nothing. `compact` reports how much it
|
|
/// 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;
|
|
// 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
|
|
// ~64 times under a 16 MiB threshold (rewriting 1 GB each time,
|
|
// hence quadratic), while a small collection rewritten in place
|
|
// accumulates garbage indefinitely without ever hitting the count.
|
|
//
|
|
// Garbage share is dead / (live + dead); this fires at ~20%, so the
|
|
// file stays near 1.25x the live data and each compaction is paid
|
|
// for by the space it reclaims.
|
|
if (self.dead_docs * 4 < self.live_docs) return;
|
|
try self.compact();
|
|
}
|
|
|
|
/// Rewrite the log with only live documents, atomically swapping the file.
|
|
/// Callers must hold the write lock.
|
|
pub fn compact(self: *Engine) !void {
|
|
const tmp_path = try std.fmt.allocPrint(self.gpa, "{s}.tmp", .{self.log.path});
|
|
defer self.gpa.free(tmp_path);
|
|
std.Io.Dir.cwd().deleteFile(self.io, tmp_path) catch {};
|
|
var new_log = try storage.Log.open(self.gpa, self.io, tmp_path);
|
|
defer new_log.close();
|
|
// One fsync for the whole rewrite, not one per document. The
|
|
// rewrite's durability comes from the rename below, which is only
|
|
// safe to publish after a single sync of the finished file.
|
|
new_log.defer_sync = true;
|
|
|
|
var db_it = self.dbs.iterator();
|
|
while (db_it.next()) |db_entry| {
|
|
var coll_it = db_entry.value_ptr.collections.iterator();
|
|
while (coll_it.next()) |coll_entry| {
|
|
// Re-emit the index definitions first: a compacted log that
|
|
// dropped them would resurrect the collections without
|
|
// indexes on replay.
|
|
for (coll_entry.value_ptr.indexes.items) |*ix| {
|
|
var spec_bytes: std.ArrayListUnmanaged(u8) = .empty;
|
|
defer spec_bytes.deinit(self.gpa);
|
|
try ix.write_spec(self.gpa, &spec_bytes);
|
|
try new_log.append_index_create(db_entry.key_ptr.*, coll_entry.key_ptr.*, spec_bytes.items, self.seq);
|
|
}
|
|
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);
|
|
try new_log.append_upsert(db_entry.key_ptr.*, coll_entry.key_ptr.*, doc_bytes, self.seq);
|
|
}
|
|
}
|
|
}
|
|
const new_end_pos = new_log.end_pos;
|
|
// Durable before the rename makes it the database.
|
|
try new_log.sync();
|
|
|
|
try std.Io.Dir.renameAbsolute(tmp_path, self.log.path, self.io);
|
|
// Persist the rename: fsync the parent directory so the new
|
|
// directory entry survives a power loss right after compaction.
|
|
const parent = parent_dir(self.log.path);
|
|
var dir_file = try std.Io.Dir.cwd().openFile(self.io, parent, .{ .mode = .read_only, .allow_directory = true });
|
|
defer dir_file.close(self.io);
|
|
try dir_file.sync(self.io);
|
|
|
|
const old_path = try self.gpa.dupe(u8, self.log.path);
|
|
// A batch may span a compaction (a large insert crossing the
|
|
// threshold): keep the deferred-sync mode on the swapped-in log so
|
|
// the remaining batch records stay grouped with the same command.
|
|
const deferred = self.log.defer_sync;
|
|
self.log.close();
|
|
self.log = try storage.Log.open(self.gpa, self.io, old_path);
|
|
self.log.defer_sync = deferred;
|
|
// Log.open starts at end_pos 0 and does not replay; continue appending
|
|
// where the compacted file actually ends.
|
|
self.log.end_pos = new_end_pos;
|
|
// The rewritten log holds only live documents.
|
|
self.dead_docs = 0;
|
|
self.gpa.free(old_path);
|
|
}
|
|
|
|
/// Rebuild every empty index from the live documents. Runs after replay
|
|
/// completes, so it is order-independent: a create record, the documents
|
|
/// it indexes, and any drop record all replay first. A duplicate under a
|
|
/// unique index logs a loud warning and keeps the index (still correct
|
|
/// as a candidate generator; future writes are still enforced) — the
|
|
/// database always opens, leaving dropIndexes as an in-band recovery
|
|
/// path.
|
|
fn build_all_indexes(self: *Engine) !void {
|
|
var db_it = self.dbs.iterator();
|
|
while (db_it.next()) |db_entry| {
|
|
var coll_it = db_entry.value_ptr.collections.iterator();
|
|
while (coll_it.next()) |coll_entry| {
|
|
for (coll_entry.value_ptr.indexes.items) |*ix| {
|
|
if (ix.entries.items.len > 0) continue; // defensive
|
|
var doc_it = coll_entry.value_ptr.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) {
|
|
error.ParallelArrays => {
|
|
std.debug.print("mongo-lite: WARNING: index '{s}' cannot index an existing document; entry skipped\n", .{ix.name});
|
|
continue;
|
|
},
|
|
else => return err,
|
|
};
|
|
}
|
|
// Tolerated, not enforced: the database must always open.
|
|
if (try ix.finish_bulk(false)) {
|
|
std.debug.print("mongo-lite: WARNING: unique index '{s}' has duplicate keys in existing data; duplicates not enforced for existing documents\n", .{ix.name});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Register an (empty) index from a persisted spec document. A repeated
|
|
/// create record for the same name is an idempotent no-op.
|
|
fn register_index_from_spec(self: *Engine, coll: *Collection, spec_doc: *const bson.Document) !void {
|
|
var ix = try index.parse_spec(self.gpa, spec_doc);
|
|
var committed = false;
|
|
defer if (!committed) ix.deinit(self.gpa);
|
|
if (coll.find_index(ix.name) != null) return;
|
|
try coll.indexes.append(self.gpa, ix);
|
|
committed = true;
|
|
}
|
|
};
|
|
|
|
fn less_id_bytes(_: void, a: []const u8, b: []const u8) bool {
|
|
return std.mem.order(u8, a, b) == .lt;
|
|
}
|
|
|
|
fn parent_dir(path: []const u8) []const u8 {
|
|
const last = std.mem.lastIndexOfScalar(u8, path, '/') orelse return ".";
|
|
if (last == 0) return "/";
|
|
return path[0..last];
|
|
}
|
|
|
|
fn serialize_doc(gpa: std.mem.Allocator, doc: *const bson.Document) ![]u8 {
|
|
var out: std.ArrayListUnmanaged(u8) = .empty;
|
|
errdefer out.deinit(gpa);
|
|
try doc.to_bytes(gpa, &out);
|
|
return out.toOwnedSlice(gpa);
|
|
}
|
|
|
|
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) {
|
|
doc.deinit();
|
|
self.gpa.destroy(doc);
|
|
};
|
|
|
|
const coll = self.get_or_create_collection(record.db, record.coll) catch return;
|
|
|
|
// Index records carry no _id — handle them before the lookup. Replay
|
|
// registers indexes empty; Engine.open builds them from the live docs
|
|
// after replay completes.
|
|
switch (record.type) {
|
|
storage.record_type_index_create => {
|
|
self.register_index_from_spec(coll, doc) catch |err| {
|
|
std.debug.print("mongo-lite: index create record failed to apply: {s}\n", .{@errorName(err)});
|
|
return;
|
|
};
|
|
return;
|
|
},
|
|
storage.record_type_index_drop => {
|
|
const name_value = doc.get("name") orelse return;
|
|
const name = switch (name_value) {
|
|
.string => |s| s,
|
|
else => return,
|
|
};
|
|
_ = coll.remove_index(self.gpa, name);
|
|
return;
|
|
},
|
|
else => {},
|
|
}
|
|
|
|
const id_value = doc.get("_id") orelse {
|
|
std.debug.print("mongo-lite: log record without _id, skipping\n", .{});
|
|
return;
|
|
};
|
|
const id_key = try bson.serialize_value(self.gpa, id_value);
|
|
var key_owned = false;
|
|
defer if (!key_owned) self.gpa.free(id_key);
|
|
|
|
switch (record.type) {
|
|
storage.record_type_upsert => {
|
|
self.evict_doc(coll, id_key);
|
|
try coll.docs.put(self.gpa, id_key, doc);
|
|
self.live_docs += 1;
|
|
key_owned = true;
|
|
stored = true;
|
|
},
|
|
storage.record_type_delete => self.evict_doc(coll, id_key),
|
|
else => {},
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const testing = std.testing;
|
|
|
|
const TmpLog = storage.TmpLog;
|
|
|
|
fn test_env(threaded: *std.Io.Threaded) struct { io: std.Io, gen: bson.ObjectIdGen } {
|
|
const io = threaded.io();
|
|
const gen = bson.ObjectIdGen.init(io);
|
|
return .{ .io = io, .gen = gen };
|
|
}
|
|
|
|
fn make_doc(gpa: std.mem.Allocator, id: i32, name: []const u8) !bson.Document {
|
|
var arena = std.heap.ArenaAllocator.init(gpa);
|
|
errdefer arena.deinit();
|
|
const pairs = try arena.allocator().alloc(bson.Pair, 2);
|
|
pairs[0] = .{ .key = try arena.allocator().dupe(u8, "_id"), .value = .{ .int32 = id } };
|
|
pairs[1] = .{ .key = try arena.allocator().dupe(u8, "name"), .value = .{ .string = try arena.allocator().dupe(u8, name) } };
|
|
return .{ .arena = arena, .pairs = pairs };
|
|
}
|
|
|
|
test "insert, query, remove" {
|
|
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);
|
|
var engine = try Engine.open(gpa, io, tmp.path);
|
|
defer engine.deinit();
|
|
|
|
var d1 = try make_doc(gpa, 1, "alice");
|
|
defer d1.deinit();
|
|
var d2 = try make_doc(gpa, 2, "bob");
|
|
defer d2.deinit();
|
|
|
|
try engine.lock();
|
|
try engine.insert("app", "users", &d1, &env.gen);
|
|
try engine.insert("app", "users", &d2, &env.gen);
|
|
engine.unlock();
|
|
|
|
// duplicate key
|
|
var d3 = try make_doc(gpa, 1, "alice2");
|
|
defer d3.deinit();
|
|
try engine.lock();
|
|
try testing.expectError(error.DuplicateKey, engine.insert("app", "users", &d3, &env.gen));
|
|
engine.unlock();
|
|
|
|
// find by id
|
|
const id_key = try bson.serialize_value(gpa, bson.Value{ .int32 = 2 });
|
|
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);
|
|
const removed = try engine.remove_by_id("app", "users", .{ .int32 = 2 });
|
|
try testing.expect(removed);
|
|
engine.unlock();
|
|
}
|
|
|
|
test "live/dead doc accounting drives compaction" {
|
|
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);
|
|
var engine = try Engine.open(gpa, io, tmp.path);
|
|
defer engine.deinit();
|
|
// Keep compaction from firing and resetting dead_docs mid-test.
|
|
engine.compact_threshold = std.math.maxInt(u64);
|
|
|
|
var d1 = try make_doc(gpa, 1, "alice");
|
|
defer d1.deinit();
|
|
var d2 = try make_doc(gpa, 2, "bob");
|
|
defer d2.deinit();
|
|
|
|
try engine.lock();
|
|
defer engine.unlock();
|
|
|
|
try engine.insert("app", "users", &d1, &env.gen);
|
|
try engine.insert("app", "users", &d2, &env.gen);
|
|
try testing.expectEqual(@as(u64, 2), engine.live_docs);
|
|
try testing.expectEqual(@as(u64, 0), engine.dead_docs);
|
|
|
|
// A replace supersedes one record: live is unchanged, garbage grows.
|
|
var d1b = try make_doc(gpa, 1, "alice2");
|
|
defer d1b.deinit();
|
|
try engine.replace("app", "users", &d1b, &env.gen);
|
|
try testing.expectEqual(@as(u64, 2), engine.live_docs);
|
|
try testing.expectEqual(@as(u64, 1), engine.dead_docs);
|
|
|
|
// A delete drops a live doc and leaves its record behind as garbage.
|
|
try testing.expect(try engine.remove_by_id("app", "users", .{ .int32 = 2 }));
|
|
try testing.expectEqual(@as(u64, 1), engine.live_docs);
|
|
try testing.expectEqual(@as(u64, 2), engine.dead_docs);
|
|
|
|
// Removing something absent must not move either counter.
|
|
try testing.expect(!try engine.remove_by_id("app", "users", .{ .int32 = 99 }));
|
|
try testing.expectEqual(@as(u64, 1), engine.live_docs);
|
|
try testing.expectEqual(@as(u64, 2), engine.dead_docs);
|
|
|
|
// Dropping the collection accounts for everything it still held, and
|
|
// must leave live_docs at zero rather than wrapping.
|
|
try testing.expect(try engine.drop_collection("app", "users"));
|
|
try testing.expectEqual(@as(u64, 0), engine.live_docs);
|
|
try testing.expectEqual(@as(u64, 3), engine.dead_docs);
|
|
}
|
|
|
|
test "compaction reclaims garbage but leaves a garbage-free log alone" {
|
|
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);
|
|
var engine = try Engine.open(gpa, io, tmp.path);
|
|
defer engine.deinit();
|
|
engine.compact_threshold = 4096; // small enough to be crossed here
|
|
|
|
try engine.lock();
|
|
defer engine.unlock();
|
|
|
|
// Pure inserts produce no garbage, so the log must never be rewritten.
|
|
for (0..200) |i| {
|
|
var d = try make_doc(gpa, @intCast(i), "x");
|
|
defer d.deinit();
|
|
try engine.insert("app", "c", &d, &env.gen);
|
|
}
|
|
try testing.expectEqual(@as(u64, 0), engine.dead_docs);
|
|
const after_insert = engine.log.end_pos;
|
|
try testing.expect(after_insert > engine.compact_threshold);
|
|
|
|
// Rewriting every document makes the log mostly garbage; compaction
|
|
// must fire and bring the file back down near the live size.
|
|
for (0..200) |round| {
|
|
for (0..200) |i| {
|
|
var d = try make_doc(gpa, @intCast(i), if (round % 2 == 0) "yy" else "z");
|
|
defer d.deinit();
|
|
try engine.replace("app", "c", &d, &env.gen);
|
|
}
|
|
if (engine.log.end_pos < after_insert * 2) break;
|
|
}
|
|
try testing.expectEqual(@as(u64, 200), engine.live_docs);
|
|
// Bounded well below the ~40x of record bytes those rewrites wrote.
|
|
try testing.expect(engine.log.end_pos < after_insert * 2);
|
|
}
|
|
|
|
test "reopen replays log" {
|
|
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);
|
|
{
|
|
var engine = try Engine.open(gpa, io, tmp.path);
|
|
defer engine.deinit();
|
|
var d1 = try make_doc(gpa, 1, "alice");
|
|
defer d1.deinit();
|
|
var d2 = try make_doc(gpa, 2, "bob");
|
|
defer d2.deinit();
|
|
try engine.lock();
|
|
try engine.insert("app", "users", &d1, &env.gen);
|
|
try engine.insert("app", "users", &d2, &env.gen);
|
|
_ = try engine.remove_by_id("app", "users", .{ .int32 = 2 });
|
|
engine.unlock();
|
|
}
|
|
|
|
var engine2 = try Engine.open(gpa, io, tmp.path);
|
|
defer engine2.deinit();
|
|
try engine2.lock();
|
|
const id_key = try bson.serialize_value(gpa, bson.Value{ .int32 = 2 });
|
|
defer gpa.free(id_key);
|
|
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);
|
|
engine2.unlock();
|
|
}
|
|
|
|
test "auto _id generation survives reopen" {
|
|
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);
|
|
{
|
|
var engine = try Engine.open(gpa, io, tmp.path);
|
|
defer engine.deinit();
|
|
|
|
var doc = try make_doc(gpa, 0, "no-id-here");
|
|
defer doc.deinit();
|
|
// strip _id
|
|
const stripped = doc.pairs[1..];
|
|
|
|
var arena = std.heap.ArenaAllocator.init(gpa);
|
|
defer arena.deinit();
|
|
var d2 = try bson.Document.alloc(gpa, try arena.allocator().dupe(bson.Pair, stripped));
|
|
defer d2.deinit();
|
|
|
|
try engine.lock();
|
|
try engine.insert("app", "no_ids", &d2, &env.gen);
|
|
engine.unlock();
|
|
}
|
|
|
|
var engine2 = try Engine.open(gpa, io, tmp.path);
|
|
defer engine2.deinit();
|
|
const coll = engine2.get_collection("app", "no_ids").?;
|
|
var it = coll.docs.iterator();
|
|
var count: usize = 0;
|
|
while (it.next()) |entry| {
|
|
count += 1;
|
|
try testing.expect(entry.value_ptr.*.get("_id").?.object_id.len == 12);
|
|
}
|
|
try testing.expectEqual(@as(usize, 1), count);
|
|
}
|
|
|
|
test "compaction rewrites log and keeps data" {
|
|
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);
|
|
{
|
|
var engine = try Engine.open(gpa, io, tmp.path);
|
|
engine.compact_threshold = 1; // always compact
|
|
defer engine.deinit();
|
|
|
|
var docs: [4]bson.Document = undefined;
|
|
defer for (&docs) |*d| d.deinit();
|
|
try engine.lock();
|
|
for (0..4) |i| {
|
|
docs[i] = try make_doc(gpa, @intCast(i + 1), "user-{d}");
|
|
try engine.insert("app", "users", &docs[i], &env.gen);
|
|
}
|
|
engine.unlock();
|
|
}
|
|
|
|
// Reopen after compaction and keep writing: with the log reopened at
|
|
// end_pos 0, appends would clobber the compacted records.
|
|
var engine2 = try Engine.open(gpa, io, tmp.path);
|
|
defer engine2.deinit();
|
|
try engine2.lock();
|
|
var extra = try make_doc(gpa, 5, "eve");
|
|
defer extra.deinit();
|
|
try engine2.insert("app", "users", &extra, &env.gen);
|
|
engine2.unlock();
|
|
|
|
var engine3 = try Engine.open(gpa, io, tmp.path);
|
|
defer engine3.deinit();
|
|
try engine3.lock();
|
|
for (1..6) |i| {
|
|
const id_key = try bson.serialize_value(gpa, bson.Value{ .int32 = @intCast(i) });
|
|
defer gpa.free(id_key);
|
|
try testing.expect(engine3.get_doc("app", "users", id_key) != null);
|
|
}
|
|
engine3.unlock();
|
|
}
|
|
|
|
test "concurrent readers and writers on a threaded Io" {
|
|
// Real worker threads: writers hold the exclusive lock, readers the
|
|
// shared lock. Proves the RwLock split keeps committed writes visible
|
|
// to concurrent readers and never corrupts the maps.
|
|
const gpa = testing.allocator;
|
|
var threaded: std.Io.Threaded = .init(gpa, .{});
|
|
defer threaded.deinit();
|
|
const io = threaded.io();
|
|
|
|
var tmp = try TmpLog.init(gpa);
|
|
defer tmp.deinit(gpa);
|
|
var engine = try Engine.open(gpa, io, tmp.path);
|
|
defer engine.deinit();
|
|
|
|
const writers = 4;
|
|
const readers = 4;
|
|
const per_writer: i32 = 200;
|
|
const total: i32 = writers * per_writer;
|
|
var next_id = std.atomic.Value(i32).init(1);
|
|
var remaining = std.atomic.Value(usize).init(@intCast(total));
|
|
|
|
const Worker = struct {
|
|
fn writer(e: *Engine, id_counter: *std.atomic.Value(i32), pending: *std.atomic.Value(usize), alloc: std.mem.Allocator) error{Canceled}!void {
|
|
while (true) {
|
|
const id = id_counter.fetchAdd(1, .monotonic);
|
|
if (id > total) return;
|
|
var doc = make_doc(alloc, id, "user") catch return error.Canceled;
|
|
defer doc.deinit();
|
|
e.lock() catch return error.Canceled;
|
|
defer e.unlock();
|
|
e.insert("app", "users", &doc, undefined) catch return error.Canceled;
|
|
_ = pending.fetchSub(1, .monotonic);
|
|
}
|
|
}
|
|
|
|
fn reader(e: *Engine, pending: *std.atomic.Value(usize)) error{Canceled}!void {
|
|
while (pending.load(.acquire) > 0) {
|
|
e.lock_read() catch return error.Canceled;
|
|
defer e.unlock_read();
|
|
if (e.get_collection("app", "users")) |coll| {
|
|
var n: usize = 0;
|
|
var it = coll.docs.iterator();
|
|
while (it.next()) |_| n += 1;
|
|
// A reader must never observe more docs than can exist.
|
|
if (n > @as(usize, @intCast(total))) return error.Canceled;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
var group: std.Io.Group = .init;
|
|
defer group.cancel(io);
|
|
for (0..readers) |_| group.async(io, Worker.reader, .{ &engine, &remaining });
|
|
for (0..writers) |_| group.async(io, Worker.writer, .{ &engine, &next_id, &remaining, gpa });
|
|
try group.await(io);
|
|
|
|
// Every committed write must be visible once all writers finish.
|
|
try engine.lock_read();
|
|
defer engine.unlock_read();
|
|
const coll = engine.get_collection("app", "users") orelse return error.TestUnexpectedResult;
|
|
try testing.expectEqual(@as(usize, @intCast(total)), coll.docs.count());
|
|
for (1..total + 1) |i| {
|
|
const id_key = try bson.serialize_value(gpa, bson.Value{ .int32 = @intCast(i) });
|
|
defer gpa.free(id_key);
|
|
try testing.expect(engine.get_doc("app", "users", id_key) != null);
|
|
}
|
|
}
|
|
|
|
// -- index tests -----------------------------------------------------------
|
|
|
|
/// A spec document for a single-path index, built by serializing and
|
|
/// re-parsing so the pairs are arena-owned.
|
|
fn index_spec(gpa: std.mem.Allocator, path: []const u8, name: []const u8, unique: bool, sparse: bool, ttl: ?i64) !bson.Document {
|
|
var out: std.ArrayListUnmanaged(u8) = .empty;
|
|
defer out.deinit(gpa);
|
|
var pairs: std.ArrayListUnmanaged(bson.Pair) = .empty;
|
|
defer pairs.deinit(gpa);
|
|
try pairs.appendSlice(gpa, &.{
|
|
.{ .key = "key", .value = .{ .doc = &.{.{ .key = path, .value = .{ .int32 = 1 } }} } },
|
|
.{ .key = "name", .value = .{ .string = name } },
|
|
.{ .key = "unique", .value = .{ .bool = unique } },
|
|
.{ .key = "sparse", .value = .{ .bool = sparse } },
|
|
});
|
|
if (ttl) |secs| try pairs.append(gpa, .{ .key = "expireAfterSeconds", .value = .{ .int64 = secs } });
|
|
try bson.write_doc(pairs.items, gpa, &out);
|
|
return bson.Document.parse(gpa, out.items);
|
|
}
|
|
|
|
/// Number of entries the named index has for a single-value equality key.
|
|
fn index_count(gpa: std.mem.Allocator, engine: *Engine, db_name: []const u8, coll_name: []const u8, name: []const u8, key_value: bson.Value) !usize {
|
|
const coll = engine.get_collection(db_name, coll_name) orelse return 0;
|
|
for (coll.indexes.items) |*ix| {
|
|
if (std.mem.eql(u8, ix.name, name)) {
|
|
var out: std.ArrayListUnmanaged([]const u8) = .empty;
|
|
defer out.deinit(gpa);
|
|
try ix.lookup_eq(gpa, &.{key_value}, &out);
|
|
return out.items.len;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
fn make_user(gpa: std.mem.Allocator, id: i32, email: []const u8) !bson.Document {
|
|
var arena = std.heap.ArenaAllocator.init(gpa);
|
|
errdefer arena.deinit();
|
|
const pairs = try arena.allocator().alloc(bson.Pair, 2);
|
|
pairs[0] = .{ .key = try arena.allocator().dupe(u8, "_id"), .value = .{ .int32 = id } };
|
|
pairs[1] = .{ .key = try arena.allocator().dupe(u8, "email"), .value = .{ .string = try arena.allocator().dupe(u8, email) } };
|
|
return .{ .arena = arena, .pairs = pairs };
|
|
}
|
|
|
|
test "unique index enforced on insert, replace, and upsert-conflict" {
|
|
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);
|
|
var engine = try Engine.open(gpa, io, tmp.path);
|
|
defer engine.deinit();
|
|
|
|
var spec = try index_spec(gpa, "email", "email_1", true, false, null);
|
|
defer spec.deinit();
|
|
try engine.lock();
|
|
_ = try engine.create_index("app", "users", &spec);
|
|
|
|
var d1 = try make_user(gpa, 1, "a@x.io");
|
|
defer d1.deinit();
|
|
try engine.insert("app", "users", &d1, &env.gen);
|
|
|
|
// A second doc with the same email is rejected and never logged.
|
|
var d2 = try make_user(gpa, 2, "a@x.io");
|
|
defer d2.deinit();
|
|
try testing.expectError(error.DuplicateKeyIndex, engine.insert("app", "users", &d2, &env.gen));
|
|
try testing.expectEqualStrings("email_1", engine.dup_index.?);
|
|
|
|
// A replace that keeps its own email is fine (own entries excluded).
|
|
var d1b = try make_user(gpa, 1, "a@x.io");
|
|
defer d1b.deinit();
|
|
try engine.replace("app", "users", &d1b, &env.gen);
|
|
try testing.expectEqual(@as(usize, 1), try index_count(gpa, &engine, "app", "users", "email_1", .{ .string = "a@x.io" }));
|
|
|
|
// An update that would collide is rejected.
|
|
var d2b = try make_user(gpa, 2, "a@x.io");
|
|
defer d2b.deinit();
|
|
try testing.expectError(error.DuplicateKeyIndex, engine.replace("app", "users", &d2b, &env.gen));
|
|
|
|
// A different email still inserts.
|
|
var d3 = try make_user(gpa, 3, "b@x.io");
|
|
defer d3.deinit();
|
|
try engine.insert("app", "users", &d3, &env.gen);
|
|
engine.unlock();
|
|
}
|
|
|
|
test "index maintained across update and delete" {
|
|
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);
|
|
var engine = try Engine.open(gpa, io, tmp.path);
|
|
defer engine.deinit();
|
|
|
|
var spec = try index_spec(gpa, "a", "a_1", false, false, null);
|
|
defer spec.deinit();
|
|
try engine.lock();
|
|
_ = try engine.create_index("app", "items", &spec);
|
|
|
|
var d1 = try doc_with_a(gpa, 1, 10);
|
|
defer d1.deinit();
|
|
var d2 = try doc_with_a(gpa, 2, 20);
|
|
defer d2.deinit();
|
|
try engine.insert("app", "items", &d1, &env.gen);
|
|
try engine.insert("app", "items", &d2, &env.gen);
|
|
try testing.expectEqual(@as(usize, 1), try index_count(gpa, &engine, "app", "items", "a_1", .{ .int32 = 20 }));
|
|
|
|
// Replace doc 1 with a new value: old entry gone, new entry present.
|
|
var d1b = try doc_with_a(gpa, 1, 30);
|
|
defer d1b.deinit();
|
|
try engine.replace("app", "items", &d1b, &env.gen);
|
|
try testing.expectEqual(@as(usize, 0), try index_count(gpa, &engine, "app", "items", "a_1", .{ .int32 = 10 }));
|
|
try testing.expectEqual(@as(usize, 1), try index_count(gpa, &engine, "app", "items", "a_1", .{ .int32 = 30 }));
|
|
|
|
// Delete doc 2: its entry is removed.
|
|
_ = try engine.remove_by_id("app", "items", .{ .int32 = 2 });
|
|
try testing.expectEqual(@as(usize, 0), try index_count(gpa, &engine, "app", "items", "a_1", .{ .int32 = 20 }));
|
|
engine.unlock();
|
|
}
|
|
|
|
/// A document with an integer `a` field (on top of _id + name).
|
|
fn doc_with_a(gpa: std.mem.Allocator, id: i32, a: i32) !bson.Document {
|
|
var arena = std.heap.ArenaAllocator.init(gpa);
|
|
errdefer arena.deinit();
|
|
const pairs = try arena.allocator().alloc(bson.Pair, 3);
|
|
pairs[0] = .{ .key = try arena.allocator().dupe(u8, "_id"), .value = .{ .int32 = id } };
|
|
pairs[1] = .{ .key = try arena.allocator().dupe(u8, "name"), .value = .{ .string = try arena.allocator().dupe(u8, "x") } };
|
|
pairs[2] = .{ .key = try arena.allocator().dupe(u8, "a"), .value = .{ .int32 = a } };
|
|
return .{ .arena = arena, .pairs = pairs };
|
|
}
|
|
|
|
test "index survives reopen and compaction" {
|
|
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);
|
|
{
|
|
var engine = try Engine.open(gpa, io, tmp.path);
|
|
defer engine.deinit();
|
|
engine.compact_threshold = 1; // every write compacts
|
|
var spec = try index_spec(gpa, "email", "email_1", false, false, null);
|
|
defer spec.deinit();
|
|
try engine.lock();
|
|
_ = try engine.create_index("app", "users", &spec);
|
|
var d1 = try make_user(gpa, 1, "a@x.io");
|
|
defer d1.deinit();
|
|
var d2 = try make_user(gpa, 2, "b@x.io");
|
|
defer d2.deinit();
|
|
try engine.insert("app", "users", &d1, &env.gen);
|
|
try engine.insert("app", "users", &d2, &env.gen);
|
|
engine.unlock();
|
|
}
|
|
|
|
// Reopen: the index (rebuilt from the compacted log) still finds docs.
|
|
var engine2 = try Engine.open(gpa, io, tmp.path);
|
|
defer engine2.deinit();
|
|
try engine2.lock();
|
|
try testing.expectEqual(@as(usize, 1), try index_count(gpa, &engine2, "app", "users", "email_1", .{ .string = "b@x.io" }));
|
|
engine2.unlock();
|
|
}
|
|
|
|
test "index drop survives reopen" {
|
|
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);
|
|
{
|
|
var engine = try Engine.open(gpa, io, tmp.path);
|
|
defer engine.deinit();
|
|
var spec = try index_spec(gpa, "email", "email_1", false, false, null);
|
|
defer spec.deinit();
|
|
try engine.lock();
|
|
_ = try engine.create_index("app", "users", &spec);
|
|
var d1 = try make_user(gpa, 1, "a@x.io");
|
|
defer d1.deinit();
|
|
try engine.insert("app", "users", &d1, &env.gen);
|
|
try testing.expect(try engine.drop_index("app", "users", "email_1"));
|
|
engine.unlock();
|
|
}
|
|
|
|
var engine2 = try Engine.open(gpa, io, tmp.path);
|
|
defer engine2.deinit();
|
|
try engine2.lock();
|
|
try testing.expectEqual(@as(usize, 0), engine2.get_collection("app", "users").?.indexes.items.len);
|
|
engine2.unlock();
|
|
}
|
|
|
|
test "drop_collection frees indexes; log without index records replays" {
|
|
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);
|
|
{
|
|
var engine = try Engine.open(gpa, io, tmp.path);
|
|
defer engine.deinit();
|
|
var spec = try index_spec(gpa, "email", "email_1", false, false, null);
|
|
defer spec.deinit();
|
|
try engine.lock();
|
|
_ = try engine.create_index("app", "users", &spec);
|
|
var d1 = try make_user(gpa, 1, "a@x.io");
|
|
defer d1.deinit();
|
|
try engine.insert("app", "users", &d1, &env.gen);
|
|
// Dropped in memory; free_collection releases the index memory
|
|
// (verified by testing.allocator at engine.deinit).
|
|
try testing.expect(try engine.drop_collection("app", "users"));
|
|
try testing.expect(engine.get_collection("app", "users") == null);
|
|
// A log that only ever contained plain upserts replays fine.
|
|
var d2 = try make_doc(gpa, 2, "bob");
|
|
defer d2.deinit();
|
|
try engine.insert("app", "plain", &d2, &env.gen);
|
|
engine.unlock();
|
|
}
|
|
|
|
var engine2 = try Engine.open(gpa, io, tmp.path);
|
|
defer engine2.deinit();
|
|
try engine2.lock();
|
|
const id_key = try bson.serialize_value(gpa, bson.Value{ .int32 = 2 });
|
|
defer gpa.free(id_key);
|
|
try testing.expect(engine2.get_doc("app", "plain", id_key) != null);
|
|
// Pre-existing limitation (documented in the README): drop_collection
|
|
// writes no log record, so the collection and its index resurrect.
|
|
const users = engine2.get_collection("app", "users").?;
|
|
try testing.expectEqual(@as(usize, 1), users.indexes.items.len);
|
|
try testing.expectEqual(@as(usize, 1), try index_count(gpa, &engine2, "app", "users", "email_1", .{ .string = "a@x.io" }));
|
|
engine2.unlock();
|
|
}
|
|
|
|
/// A document with an `expireAt` field of any type (omitted when null).
|
|
fn doc_with_expire(gpa: std.mem.Allocator, id: i32, expire: ?bson.Value) !bson.Document {
|
|
var arena = std.heap.ArenaAllocator.init(gpa);
|
|
errdefer arena.deinit();
|
|
const n: usize = if (expire == null) 1 else 2;
|
|
const pairs = try arena.allocator().alloc(bson.Pair, n);
|
|
pairs[0] = .{ .key = try arena.allocator().dupe(u8, "_id"), .value = .{ .int32 = id } };
|
|
if (expire) |v| {
|
|
const value = switch (v) {
|
|
.string => |s| bson.Value{ .string = try arena.allocator().dupe(u8, s) },
|
|
else => v,
|
|
};
|
|
pairs[1] = .{ .key = try arena.allocator().dupe(u8, "expireAt"), .value = value };
|
|
}
|
|
return .{ .arena = arena, .pairs = pairs };
|
|
}
|
|
|
|
test "ttl_sweep deletes expired documents and the deletion survives reopen" {
|
|
var threaded: std.Io.Threaded = .init_single_threaded;
|
|
defer threaded.deinit();
|
|
var env = test_env(&threaded);
|
|
const io = env.io;
|
|
const gpa = testing.allocator;
|
|
|
|
// A fixed clock: the sweep takes `now` as a parameter precisely so the
|
|
// test does not depend on the wall clock.
|
|
const now_ms: i64 = 1_700_000_000_000;
|
|
|
|
var tmp = try TmpLog.init(gpa);
|
|
defer tmp.deinit(gpa);
|
|
{
|
|
var engine = try Engine.open(gpa, io, tmp.path);
|
|
defer engine.deinit();
|
|
var spec = try index_spec(gpa, "expireAt", "expireAt_1", false, false, 60);
|
|
defer spec.deinit();
|
|
try engine.lock();
|
|
defer engine.unlock();
|
|
_ = try engine.create_index("app", "sessions", &spec);
|
|
|
|
const docs = [_]struct { id: i32, expire: ?bson.Value }{
|
|
.{ .id = 1, .expire = .{ .datetime = now_ms - 120_000 } }, // long expired
|
|
.{ .id = 2, .expire = .{ .datetime = now_ms - 60_000 } }, // exactly at the cutoff
|
|
.{ .id = 3, .expire = .{ .datetime = now_ms - 30_000 } }, // not yet
|
|
.{ .id = 4, .expire = .{ .datetime = now_ms + 3_600_000 } }, // future
|
|
.{ .id = 5, .expire = .{ .string = "tomorrow" } }, // not a date: never expires
|
|
.{ .id = 6, .expire = null }, // no field: indexed as null
|
|
};
|
|
for (docs) |d| {
|
|
var doc = try doc_with_expire(gpa, d.id, d.expire);
|
|
defer doc.deinit();
|
|
try engine.insert("app", "sessions", &doc, &env.gen);
|
|
}
|
|
const coll = engine.get_collection("app", "sessions").?;
|
|
try testing.expectEqual(@as(usize, 6), coll.docs.count());
|
|
try testing.expectEqual(@as(usize, 6), coll.indexes.items[0].entries.items.len);
|
|
|
|
// The cutoff is inclusive: doc 2 goes with doc 1.
|
|
try testing.expectEqual(@as(usize, 2), try engine.ttl_sweep(now_ms));
|
|
try testing.expectEqual(@as(usize, 4), coll.docs.count());
|
|
try testing.expectEqual(@as(usize, 4), coll.indexes.items[0].entries.items.len);
|
|
try testing.expectEqual(@as(usize, 0), try index_count(gpa, &engine, "app", "sessions", "expireAt_1", .{ .datetime = now_ms - 120_000 }));
|
|
// The string and the missing field are untouched by any sweep.
|
|
try testing.expectEqual(@as(usize, 1), try index_count(gpa, &engine, "app", "sessions", "expireAt_1", .{ .string = "tomorrow" }));
|
|
try testing.expectEqual(@as(usize, 1), try index_count(gpa, &engine, "app", "sessions", "expireAt_1", .null));
|
|
|
|
// Idempotent: nothing else is expired at the same instant.
|
|
try testing.expectEqual(@as(usize, 0), try engine.ttl_sweep(now_ms));
|
|
// An hour later doc 3 has expired too; doc 4 still has not.
|
|
try testing.expectEqual(@as(usize, 1), try engine.ttl_sweep(now_ms + 3_000_000));
|
|
try testing.expectEqual(@as(usize, 3), coll.docs.count());
|
|
}
|
|
|
|
// Sweeps go through `remove`, so they are logged: the deletions hold
|
|
// across a restart, and the TTL index comes back with its expiry.
|
|
var engine2 = try Engine.open(gpa, io, tmp.path);
|
|
defer engine2.deinit();
|
|
try engine2.lock();
|
|
defer engine2.unlock();
|
|
const coll = engine2.get_collection("app", "sessions").?;
|
|
try testing.expectEqual(@as(usize, 3), coll.docs.count());
|
|
try testing.expectEqual(@as(usize, 1), coll.indexes.items.len);
|
|
try testing.expectEqual(@as(?i64, 60), coll.indexes.items[0].ttl);
|
|
try testing.expectEqual(@as(usize, 3), coll.indexes.items[0].entries.items.len);
|
|
for ([_]i32{ 1, 2, 3 }) |id| {
|
|
const id_key = try bson.serialize_value(gpa, bson.Value{ .int32 = id });
|
|
defer gpa.free(id_key);
|
|
try testing.expect(engine2.get_doc("app", "sessions", id_key) == null);
|
|
}
|
|
const alive = try bson.serialize_value(gpa, bson.Value{ .int32 = 4 });
|
|
defer gpa.free(alive);
|
|
try testing.expect(engine2.get_doc("app", "sessions", alive) != null);
|
|
}
|
|
|
|
test "ttl_sweep spans collections and several TTL indexes on one collection" {
|
|
var threaded: std.Io.Threaded = .init_single_threaded;
|
|
defer threaded.deinit();
|
|
var env = test_env(&threaded);
|
|
const io = env.io;
|
|
const gpa = testing.allocator;
|
|
const now_ms: i64 = 1_700_000_000_000;
|
|
|
|
var tmp = try TmpLog.init(gpa);
|
|
defer tmp.deinit(gpa);
|
|
var engine = try Engine.open(gpa, io, tmp.path);
|
|
defer engine.deinit();
|
|
try engine.lock();
|
|
defer engine.unlock();
|
|
|
|
// Two TTL indexes over the same collection (MongoDB allows this): one
|
|
// document is expired by both, and must only be deleted once.
|
|
var spec_a = try index_spec(gpa, "expireAt", "expireAt_1", false, false, 60);
|
|
defer spec_a.deinit();
|
|
var spec_b = try index_spec(gpa, "seenAt", "seenAt_1", false, false, 10);
|
|
defer spec_b.deinit();
|
|
_ = try engine.create_index("app", "sessions", &spec_a);
|
|
_ = try engine.create_index("app", "sessions", &spec_b);
|
|
|
|
var both = try bson.Document.alloc(gpa, &.{
|
|
.{ .key = "_id", .value = .{ .int32 = 1 } },
|
|
.{ .key = "expireAt", .value = .{ .datetime = now_ms - 120_000 } },
|
|
.{ .key = "seenAt", .value = .{ .datetime = now_ms - 120_000 } },
|
|
});
|
|
defer both.deinit();
|
|
try engine.insert("app", "sessions", &both, &env.gen);
|
|
|
|
// A second collection with its own TTL index, and a plain collection
|
|
// that no sweep may touch.
|
|
var spec_c = try index_spec(gpa, "at", "at_1", false, false, 0);
|
|
defer spec_c.deinit();
|
|
_ = try engine.create_index("app", "events", &spec_c);
|
|
var ev = try bson.Document.alloc(gpa, &.{
|
|
.{ .key = "_id", .value = .{ .int32 = 2 } },
|
|
// expireAfterSeconds 0: expires at exactly the stored instant.
|
|
.{ .key = "at", .value = .{ .datetime = now_ms } },
|
|
});
|
|
defer ev.deinit();
|
|
try engine.insert("app", "events", &ev, &env.gen);
|
|
var plain = try make_doc(gpa, 3, "keep");
|
|
defer plain.deinit();
|
|
try engine.insert("other", "plain", &plain, &env.gen);
|
|
|
|
try testing.expectEqual(@as(usize, 2), try engine.ttl_sweep(now_ms));
|
|
try testing.expectEqual(@as(usize, 0), engine.get_collection("app", "sessions").?.docs.count());
|
|
try testing.expectEqual(@as(usize, 0), engine.get_collection("app", "events").?.docs.count());
|
|
try testing.expectEqual(@as(usize, 1), engine.get_collection("other", "plain").?.docs.count());
|
|
}
|