Found by the churn harness, and it is the difference between reclamation working and reclamation being unreachable. A checkpoint is what hands back empty slab windows, and a checkpoint is armed by log volume. A delete logs only an `_id`. So deleting half of a 190 MB collection moved the log by a couple of megabytes, no checkpoint ran, and the garbage sailed straight past the rebuild threshold -- the rebuild got there first every time and reset the window map it would have used. Measured before this: six rounds of delete-and-refill, six rebuilds, 1 MB reclaimed. After: the same six rounds, 256 MB reclaimed. The fix is one line of ordering. `compact` now checkpoints before it walks the collections, so the cheap half of the job runs first: a checkpoint hands back whole windows for the cost of one publish, where a rebuild copies every live byte in the database. The per-collection gate then judges what reclamation left rather than what it was about to take, so a collection whose garbage was all in empty windows is not rewritten at all. No new threshold and no new state -- the gate that decides is the one added in "a rebuild copies only the collections that have garbage", now reading a post-reclamation number. 186/186 unit tests in ReleaseFast and ReleaseSafe, 83/83 fuzz, the full e2e matrix, crash-fuzz 60 cycles.
5734 lines
262 KiB
Zig
5734 lines
262 KiB
Zig
//! Database engine over an mmap'd data file with the append-only log in front
|
|
//! of it as the write-ahead log. Maps db -> collection -> `_id_` B+tree ->
|
|
//! absolute slab offset; documents, tree pages and overflow records all live in
|
|
//! the data file, so resident memory is the working set rather than the size of
|
|
//! the database. All mutations are logged and synced before they become visible,
|
|
//! so a crash never loses a committed write, and a checkpoint publishes the data
|
|
//! file and truncates the log so an open does not replay everything ever
|
|
//! written. 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.
|
|
//!
|
|
//! Two invariants the rest of this file depends on. A checkpoint never renumbers
|
|
//! slab offsets, because index leaves hold them physically -- only `compact`
|
|
//! moves documents, and it rebuilds every index in the same pass. And an index
|
|
//! must never under-approximate: it generates candidates and the full filter is
|
|
//! re-applied to those, so a missing entry is a missing query result that
|
|
//! nothing else detects (see `assert_indexes_cover_every_document`).
|
|
|
|
const std = @import("std");
|
|
const builtin = @import("builtin");
|
|
const bson = @import("bson.zig");
|
|
const storage = @import("storage.zig");
|
|
const index = @import("index.zig");
|
|
const pgr = @import("pager.zig");
|
|
const cursor = @import("cursor.zig");
|
|
// Always active, including in the default ReleaseFast build -- see assert.zig
|
|
// for why std.debug.assert is the wrong tool for these invariants.
|
|
const assert = @import("assert.zig").assert;
|
|
// For the durability invariants: a panic carries no expression text, so the
|
|
// message is all an operator gets.
|
|
const assert_msg = @import("assert.zig").assert_msg;
|
|
|
|
/// Pages in a standard slab extent: 8 MiB, as the old in-memory segments were.
|
|
/// Slack is bounded by one extent per collection.
|
|
const slab_extent_pages: u32 = (8 * 1024 * 1024) / pgr.page_size;
|
|
|
|
/// Shortest run worth taking off the free list for a slab, 1 MiB. Below this
|
|
/// the extent is exhausted after a few documents, and every exhaustion writes
|
|
/// off whatever is left of the one before it.
|
|
const slab_run_min_pages: u32 = slab_extent_pages / 8;
|
|
|
|
/// Pages a slab allocation of `len` bytes needs at minimum.
|
|
fn pages_for(len: usize) u32 {
|
|
return @intCast((len + pgr.page_size - 1) / pgr.page_size);
|
|
}
|
|
|
|
const LogKind = enum { upsert, delete, index_create, index_drop };
|
|
|
|
/// Dead bytes in one `map_align` window. The window is the unit of reclamation
|
|
/// -- a whole system page is the smallest thing `mark_appendable` and
|
|
/// `protect_stable` can hand back -- so a counter never exceeds `map_align`,
|
|
/// and the width follows from that rather than being chosen.
|
|
///
|
|
/// Two bytes per window on the usual platforms. That is the entire memory cost
|
|
/// of knowing where a collection's garbage is: 2.7 MB for a 21 GB slab on
|
|
/// 16 KiB pages, 10.6 MB on 4 KiB ones. The alternative shapes -- an interval
|
|
/// set, a free-run list -- cost memory proportional to the number of *dead
|
|
/// documents*, which for 200-byte documents at that scale is gigabytes, and
|
|
/// would make `evict_doc` allocate after the write is already committed.
|
|
const WindowDead = if (pgr.map_align <= std.math.maxInt(u16)) u16 else u32;
|
|
|
|
/// A run of pages a collection's slab owns, plus where its garbage is.
|
|
///
|
|
/// This replaced a bare `pgr.Extent` because an extent can only be given back
|
|
/// whole, and a churning collection almost never empties one. A run is split
|
|
/// instead: the windows inside it with nothing live left go to the pager and
|
|
/// the run becomes two shorter ones. So the list is kept sorted by page number,
|
|
/// which makes `run_of` a binary search and the sortedness itself an assert --
|
|
/// allocation order stopped being meaningful once a run could be recycled to a
|
|
/// *lower* address than one already owned.
|
|
const SlabRun = struct {
|
|
first: u32,
|
|
pages: u32,
|
|
/// The run's start, rounded up to `map_align`: the first offset that begins
|
|
/// a whole window. `alloc_pages` works in 4 KiB pages, so a run need not
|
|
/// start on a system page.
|
|
window_first: u64,
|
|
/// Dead bytes per window, `dead[i]` covering
|
|
/// `[window_first + i*map_align, +map_align)`. `map_align` means the window
|
|
/// holds nothing live and can be handed back.
|
|
dead: []WindowDead,
|
|
|
|
/// Windows wholly inside the pages `[first, first+pages)`. The bytes
|
|
/// outside them -- below `window_first`, and the tail after the last whole
|
|
/// window -- are real slab that documents do live in; their garbage is
|
|
/// counted in `Collection.dead_unlocated` instead, because it can never be
|
|
/// reclaimed on its own.
|
|
fn window_count(first: u32, pages: u32) usize {
|
|
const from = @as(u64, first) << pgr.page_shift;
|
|
const to = from + (@as(u64, pages) << pgr.page_shift);
|
|
const wf = std.mem.alignForward(u64, from, pgr.map_align);
|
|
const we = std.mem.alignBackward(u64, to, pgr.map_align);
|
|
return if (we > wf) @intCast((we - wf) / pgr.map_align) else 0;
|
|
}
|
|
|
|
fn start(self: SlabRun) u64 {
|
|
return @as(u64, self.first) << pgr.page_shift;
|
|
}
|
|
|
|
fn end(self: SlabRun) u64 {
|
|
return (@as(u64, self.first) + self.pages) << pgr.page_shift;
|
|
}
|
|
|
|
/// One past the last byte covered by a window counter.
|
|
fn window_end(self: SlabRun) u64 {
|
|
return self.window_first + self.dead.len * pgr.map_align;
|
|
}
|
|
};
|
|
|
|
pub const Collection = struct {
|
|
/// Documents live as canonical BSON bytes in the data file, in extents this
|
|
/// collection owns; the map holds each document's offset. Those are
|
|
/// *absolute file offsets* now, which is what makes doc_bytes a single add
|
|
/// rather than a binary search over segment starts -- and what removes the
|
|
/// dangling-pointer hazard the old segment list had, since the mapping's
|
|
/// base never moves.
|
|
///
|
|
/// Removed documents leave garbage bytes until a rebuild rewrites them. A
|
|
/// checkpoint must never renumber these offsets: every index leaf holds one
|
|
/// (PLAN amendment A3).
|
|
/// Live documents. The `_id_` index is the lookup now, so this is only a
|
|
/// count -- kept because the compaction trigger and `collStats` want it and
|
|
/// the tree cannot answer it in O(1).
|
|
doc_count: u64,
|
|
/// The data file this collection's documents live in.
|
|
pager: *pgr.Pager,
|
|
/// Page runs owned by this collection's slab, sorted by page number, each
|
|
/// carrying the map of where its dead bytes are. See `SlabRun`.
|
|
slab_runs: std.ArrayListUnmanaged(SlabRun),
|
|
/// Absolute file offset of the next document write, and the end of the
|
|
/// extent it falls in.
|
|
slab_tail: u64,
|
|
slab_end: u64,
|
|
/// Slab this collection has consumed and not yet given back. `slab_tail`
|
|
/// cannot answer that -- it is an absolute file offset, so it jumps forward
|
|
/// whenever a fresh extent is taken.
|
|
///
|
|
/// It used to mean "bytes ever appended since the last rebuild", which was
|
|
/// the same thing while a rebuild was the only way to get slab back. Window
|
|
/// reclamation subtracts from it, and that is what keeps
|
|
/// `slab_used - live_bytes` equal to the garbage the collection still has
|
|
/// -- with no new persistent field, since both halves are already in the
|
|
/// catalog.
|
|
slab_used: u64,
|
|
/// This collection's outstanding page promise, for the document slab. Per
|
|
/// collection because concurrent writers must not release each other's --
|
|
/// see `pager.Reservation`.
|
|
hold: pgr.Reservation,
|
|
/// Of those bytes, the ones still reachable. `slab_used - live_bytes` is
|
|
/// this collection's slab garbage, which only a rebuild reclaims. Kept per
|
|
/// collection so dropping one can move the right amount from the engine's
|
|
/// live total to its dead total.
|
|
live_bytes: u64,
|
|
/// Garbage this collection knows it has but cannot place in a window: the
|
|
/// edges of a run that fall outside any whole window, and -- the larger
|
|
/// share -- everything that died before the last restart, since the window
|
|
/// map is not persisted.
|
|
///
|
|
/// It exists to keep one identity exact:
|
|
///
|
|
/// sum of every window counter + dead_unlocated == slab_used - live_bytes
|
|
///
|
|
/// Without it the two halves of the accounting would drift apart at every
|
|
/// open, and there would be no assert that could tell drift from a lost
|
|
/// update. What it costs is only that garbage from before a restart is not
|
|
/// reclaimed window-wise; it still arms compaction like any other.
|
|
dead_unlocated: u64,
|
|
/// Windows whose counter has reached `map_align`, i.e. how much there is
|
|
/// for the next checkpoint to give back.
|
|
///
|
|
/// It exists so that a checkpoint costs nothing on a collection with
|
|
/// nothing to reclaim. Scanning would otherwise be O(slab) per checkpoint
|
|
/// whatever the workload -- and the workload this design is *known* not to
|
|
/// help, small documents on large system pages, is exactly the one that
|
|
/// would pay that for no return.
|
|
full_windows: u32,
|
|
/// Slab handed back to the pager by window reclamation, cumulative for the
|
|
/// life of the process. Purely an observation: it is what distinguishes
|
|
/// "the ratio improved because reclamation worked" from "the ratio improved
|
|
/// for some other reason", which is the only way to read the churn gate.
|
|
reclaimed_bytes: u64,
|
|
/// Secondary indexes (persisted through the log). Heap-allocated, so an
|
|
/// `*Index` handed out by `find_index` or `create_index` stays valid when
|
|
/// a sibling index is dropped. Held by value, `orderedRemove` memmoved the
|
|
/// whole ~5 KB struct and every live pointer into the list -- a query
|
|
/// plan's `index` field, or a slice into an index's promoted-key buffer --
|
|
/// silently aimed at a different index or past the end. Nothing exercised
|
|
/// that concurrently yet; the mmap work makes it worse, since an Index
|
|
/// will own a mapping.
|
|
indexes: std.ArrayListUnmanaged(*index.Index),
|
|
/// Guards this collection's docs/slab/indexes. Writers take it
|
|
/// exclusive, readers shared; never held while taking the catalog lock,
|
|
/// and never more than one collection lock at a time.
|
|
lock: std.Io.RwLock = .init,
|
|
/// The secondary index that rejected the most recent unique write
|
|
/// (duplicate-key error path); per-collection so concurrent writers on
|
|
/// other collections cannot clobber it mid-command.
|
|
dup_index: ?[]const u8 = null,
|
|
/// The implicit _id_ index: every document has an _id and it is not
|
|
/// sparse, so entry count equals document count and a full scan of it
|
|
/// cannot miss a document — which is what the sort planner's full-scan
|
|
/// plan relies on. Kept out of `indexes` so the listing/drop commands
|
|
/// and the log format are unchanged (it is rebuilt on open like
|
|
/// everything else). `bson.encode_key` keys are canonical, so it also
|
|
/// replaces the old serialization-guarded docs-map fast path for
|
|
/// integer/string/etc. _id lookups.
|
|
id_index: index.Index,
|
|
/// Identity-and-layout token for open cursors. Drawn from
|
|
/// `Engine.layout_epoch_seq`, so it is unique across the engine's life and
|
|
/// bumped again by every rebuild.
|
|
///
|
|
/// It answers two questions a cursor cannot answer any other way. A rebuild
|
|
/// moves every document, so a saved slab offset (or a saved index anchor's
|
|
/// offset) is stale -- and the keys surviving unchanged makes that *worse*,
|
|
/// because a lookup then succeeds and quietly resolves to the wrong bytes.
|
|
/// And a cursor holds namespace *strings*, not a `*Collection`, so a
|
|
/// drop-and-recreate under the same name would otherwise be invisible to it;
|
|
/// drawing from an engine-wide sequence rather than starting each collection
|
|
/// at zero is what makes the recreated one compare unequal.
|
|
layout_epoch: u64,
|
|
|
|
fn init(gpa: std.mem.Allocator, pager: *pgr.Pager, layout_epoch: u64) !Collection {
|
|
var self: Collection = .{
|
|
.doc_count = 0,
|
|
.pager = pager,
|
|
.slab_runs = .empty,
|
|
.slab_tail = 0,
|
|
.slab_end = 0,
|
|
.slab_used = 0,
|
|
.live_bytes = 0,
|
|
.dead_unlocated = 0,
|
|
.full_windows = 0,
|
|
.reclaimed_bytes = 0,
|
|
.hold = .{},
|
|
.indexes = .empty,
|
|
.id_index = undefined,
|
|
.layout_epoch = layout_epoch,
|
|
};
|
|
const keys = [_]index.IndexKey{.{ .path = "_id", .descending = false }};
|
|
// unique: the tree, not the docs map, is what enforces _id uniqueness
|
|
// now (PLAN A3/A4). It is keyed on bson.encode_key, which is canonical
|
|
// where serialize_value is not, so int32 1 / int64 1 / double 1.0
|
|
// collide as they do in MongoDB -- see the migration note in
|
|
// apply_record.
|
|
self.id_index = try index.Index.init(gpa, pager, "_id_", &keys, true, false, null);
|
|
return self;
|
|
}
|
|
|
|
/// 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;
|
|
}
|
|
|
|
/// Take ownership of a page run, keeping `slab_runs` sorted by page number.
|
|
/// The only place a run enters the list, so the sort order and the window
|
|
/// map are established together and cannot disagree.
|
|
fn insert_run(self: *Collection, gpa: std.mem.Allocator, first: u32, pages: u32) !void {
|
|
const dead = try gpa.alloc(WindowDead, SlabRun.window_count(first, pages));
|
|
errdefer gpa.free(dead);
|
|
@memset(dead, 0);
|
|
var at: usize = 0;
|
|
while (at < self.slab_runs.items.len and self.slab_runs.items[at].first < first) at += 1;
|
|
// A recycled run must not overlap one this collection already owns:
|
|
// that would be the pager handing out pages twice, and the symptom
|
|
// would be a document quietly overwritten rather than anything failing.
|
|
if (at > 0) {
|
|
const prev = self.slab_runs.items[at - 1];
|
|
assert_msg(prev.first + prev.pages <= first, "a slab run overlaps the one below it");
|
|
}
|
|
if (at < self.slab_runs.items.len) {
|
|
assert_msg(first + pages <= self.slab_runs.items[at].first, "a slab run overlaps the one above it");
|
|
}
|
|
try self.slab_runs.insert(gpa, at, .{
|
|
.first = first,
|
|
.pages = pages,
|
|
.window_first = std.mem.alignForward(u64, @as(u64, first) << pgr.page_shift, pgr.map_align),
|
|
.dead = dead,
|
|
});
|
|
}
|
|
|
|
/// The run holding `off`, or null if no run does. Binary search, which the
|
|
/// sorted list is for: `mark_dead` runs once per evicted document, and a
|
|
/// collection with a fragmented slab can own thousands of runs.
|
|
fn run_of(self: *const Collection, off: u64) ?usize {
|
|
const page: u32 = @intCast(off >> pgr.page_shift);
|
|
var lo: usize = 0;
|
|
var hi: usize = self.slab_runs.items.len;
|
|
while (lo < hi) {
|
|
const mid = lo + (hi - lo) / 2;
|
|
const r = self.slab_runs.items[mid];
|
|
if (page < r.first) {
|
|
hi = mid;
|
|
} else if (page >= r.first + r.pages) {
|
|
lo = mid + 1;
|
|
} else {
|
|
return mid;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// Record that `[off, off+len)` of slab is garbage.
|
|
///
|
|
/// Infallible, and that is the constraint the whole representation was
|
|
/// chosen around: the two callers are `evict_doc`, which runs after the
|
|
/// log record is already durable, and the appender's skip accounting. An
|
|
/// allocation here would be a failure with nowhere to report it.
|
|
///
|
|
/// Bytes that fall outside a whole window -- the head of a run before its
|
|
/// first window boundary, and the tail after its last -- go to
|
|
/// `dead_unlocated`. They are not lost, only unreclaimable on their own.
|
|
fn mark_dead(self: *Collection, off: u64, len: u64) void {
|
|
if (len == 0) return;
|
|
const ri = self.run_of(off) orelse {
|
|
assert_msg(false, "dead slab bytes fall outside every run the collection owns");
|
|
unreachable;
|
|
};
|
|
const r = &self.slab_runs.items[ri];
|
|
const stop = off + len;
|
|
// A document is written inside one extent by construction
|
|
// (`slab_reserve` never lets an append cross `slab_end`), so a dead
|
|
// range that crosses a run boundary means an offset from a different
|
|
// layout -- a stale index entry, which is the failure the layout epoch
|
|
// exists to prevent.
|
|
assert_msg(stop <= r.end(), "a dead slab range crosses the end of the run holding it");
|
|
var pos = off;
|
|
if (pos < r.window_first) {
|
|
const n = @min(stop, r.window_first) - pos;
|
|
self.dead_unlocated += n;
|
|
pos += n;
|
|
}
|
|
const win_end = r.window_end();
|
|
while (pos < stop and pos < win_end) {
|
|
const w: usize = @intCast((pos - r.window_first) / pgr.map_align);
|
|
const w_end = r.window_first + (w + 1) * pgr.map_align;
|
|
const n = @min(stop, w_end) - pos;
|
|
// A window cannot hold more dead bytes than it has bytes. Tripping
|
|
// this means the same range was marked twice -- a double eviction,
|
|
// or a recycled offset marked against the previous owner's map.
|
|
assert_msg(r.dead[w] + n <= pgr.map_align, "a slab window holds more dead bytes than it has");
|
|
const was_full = r.dead[w] == pgr.map_align;
|
|
r.dead[w] += @intCast(n);
|
|
if (!was_full and r.dead[w] == pgr.map_align) self.full_windows += 1;
|
|
pos += n;
|
|
}
|
|
if (pos < stop) self.dead_unlocated += stop - pos;
|
|
}
|
|
|
|
/// Garbage this collection has placed in windows. Walks every window, so it
|
|
/// belongs to the reclamation scan and to tests, not to a hot path.
|
|
fn dead_located(self: *const Collection) u64 {
|
|
var sum: u64 = 0;
|
|
for (self.slab_runs.items) |r| {
|
|
for (r.dead) |d| sum += d;
|
|
}
|
|
return sum;
|
|
}
|
|
|
|
/// Drop the window maps and the run list. The pages themselves are the
|
|
/// caller's business -- a drop hands them to the pager, a rebuild has
|
|
/// already done so.
|
|
fn free_runs(self: *Collection, gpa: std.mem.Allocator) void {
|
|
for (self.slab_runs.items) |r| gpa.free(r.dead);
|
|
self.slab_runs.clearRetainingCapacity();
|
|
self.full_windows = 0;
|
|
}
|
|
|
|
/// One piece of a run that survives reclamation, with a window map of its
|
|
/// own copied out of the original.
|
|
///
|
|
/// Every kept piece gets a fresh array, including a run nothing was taken
|
|
/// from. Moving the original array instead would save a copy and make the
|
|
/// failure path have to know which arrays it still owns -- the version that
|
|
/// tried it had a double free in the out-of-memory case, which is the one
|
|
/// case nothing exercises.
|
|
fn keep_piece(
|
|
out: *std.ArrayListUnmanaged(SlabRun),
|
|
gpa: std.mem.Allocator,
|
|
r: SlabRun,
|
|
p0: u32,
|
|
p1: u32,
|
|
) !void {
|
|
const wf = std.mem.alignForward(u64, @as(u64, p0) << pgr.page_shift, pgr.map_align);
|
|
const we = std.mem.alignBackward(u64, @as(u64, p1) << pgr.page_shift, pgr.map_align);
|
|
const count: usize = if (we > wf) @intCast((we - wf) / pgr.map_align) else 0;
|
|
const dead = try gpa.alloc(WindowDead, count);
|
|
errdefer gpa.free(dead);
|
|
// A piece boundary is either the run's own start/end or a window
|
|
// boundary, so the piece's windows line up with a contiguous stretch of
|
|
// the original's and the counters can be copied rather than rebuilt.
|
|
const base: usize = @intCast((wf - r.window_first) / pgr.map_align);
|
|
@memcpy(dead, r.dead[base..][0..count]);
|
|
try out.append(gpa, .{ .first = p0, .pages = p1 - p0, .window_first = wf, .dead = dead });
|
|
}
|
|
|
|
/// Give back every window with nothing live left in it, splitting the runs
|
|
/// around what is kept. Returns the bytes handed to the pager.
|
|
///
|
|
/// Counting is the whole test: a window reaches `map_align` dead only once
|
|
/// every document with a byte in it has been through `evict_doc`, which
|
|
/// removes its index entries before marking it. So "no live bytes" and "no
|
|
/// reference to these bytes" are the same statement, and nothing has to be
|
|
/// scanned to establish it.
|
|
///
|
|
/// Fallible, and arranged so a failure changes nothing: the replacement
|
|
/// list is built whole before the old one is touched. The garbage simply
|
|
/// stays and the next checkpoint tries again.
|
|
fn reclaim_windows(self: *Collection, gpa: std.mem.Allocator) !u64 {
|
|
assert_msg(
|
|
self.slab_used >= self.live_bytes,
|
|
"a collection cannot hold more live bytes than it ever appended",
|
|
);
|
|
// The identity, checked where every window is being walked anyway.
|
|
assert_msg(
|
|
self.dead_located() + self.dead_unlocated == self.slab_used - self.live_bytes,
|
|
"the collection's placed and unplaced garbage must add up to its garbage",
|
|
);
|
|
var out: std.ArrayListUnmanaged(SlabRun) = .empty;
|
|
errdefer {
|
|
for (out.items) |p| gpa.free(p.dead);
|
|
out.deinit(gpa);
|
|
}
|
|
var give: std.ArrayListUnmanaged(pgr.Extent) = .empty;
|
|
defer give.deinit(gpa);
|
|
|
|
var freed: u64 = 0;
|
|
for (self.slab_runs.items) |r| {
|
|
var keep_from = r.first;
|
|
var i: usize = 0;
|
|
while (i < r.dead.len) {
|
|
if (r.dead[i] != pgr.map_align) {
|
|
i += 1;
|
|
continue;
|
|
}
|
|
var j = i + 1;
|
|
while (j < r.dead.len and r.dead[j] == pgr.map_align) j += 1;
|
|
const from = r.window_first + i * pgr.map_align;
|
|
const to = r.window_first + j * pgr.map_align;
|
|
// The appender's own extent is off limits, and not by
|
|
// filtering: bytes above the cursor have never been written, so
|
|
// no window covering them can have reached `map_align` dead.
|
|
// Tripping this means a range was marked dead twice.
|
|
assert_msg(
|
|
to <= self.slab_tail or from >= self.slab_end,
|
|
"reclaiming a slab window the append cursor is still walking",
|
|
);
|
|
const p_from: u32 = @intCast(from >> pgr.page_shift);
|
|
const p_to: u32 = @intCast(to >> pgr.page_shift);
|
|
if (p_from > keep_from) try keep_piece(&out, gpa, r, keep_from, p_from);
|
|
try give.append(gpa, .{ .first = p_from, .pages = p_to - p_from });
|
|
freed += to - from;
|
|
keep_from = p_to;
|
|
i = j;
|
|
}
|
|
if (keep_from < r.first + r.pages) {
|
|
try keep_piece(&out, gpa, r, keep_from, r.first + r.pages);
|
|
}
|
|
}
|
|
if (freed == 0) {
|
|
for (out.items) |p| gpa.free(p.dead);
|
|
out.deinit(gpa);
|
|
// Every full window was given back or there were none, so nothing
|
|
// is left for the next checkpoint to find.
|
|
self.full_windows = 0;
|
|
return 0;
|
|
}
|
|
|
|
// Past the last fallible step: swap the list in, then hand the pages
|
|
// over. A `free_pages` that fails here leaks the run -- it is no longer
|
|
// the collection's and not yet the pager's -- which costs space and
|
|
// nothing else. The other order would leave the same pages owned twice.
|
|
for (self.slab_runs.items) |r| gpa.free(r.dead);
|
|
self.slab_runs.deinit(gpa);
|
|
self.slab_runs = out;
|
|
self.full_windows = 0;
|
|
for (give.items) |e| self.pager.free_pages(e.first, e.pages) catch {};
|
|
assert_msg(self.slab_used >= self.live_bytes + freed, "reclaiming more slab than the collection has");
|
|
self.slab_used -= freed;
|
|
self.reclaimed_bytes += freed;
|
|
return freed;
|
|
}
|
|
|
|
/// Append `bytes` to the slab, returning its flat offset. The last
|
|
/// segment holds up to `slab_segment_size`; a full one starts the next.
|
|
/// Make room for a document of `len` bytes, so the append that follows
|
|
/// cannot fail.
|
|
///
|
|
/// Separated from the append because the append runs *after* the log
|
|
/// record is durable, where failure has nowhere to go: the write is already
|
|
/// committed and reporting an error for it would be a lie the next open
|
|
/// contradicts. Reserving first keeps the fallible half before the log.
|
|
///
|
|
/// Returns the slab bytes it wrote off along the way, for the caller to
|
|
/// charge to the engine's dead total. See `note_skip`.
|
|
fn slab_reserve(self: *Collection, gpa: std.mem.Allocator, len: usize) !u64 {
|
|
// A checkpoint can land in the middle of an extent, which freezes the
|
|
// page the tail points into. Appending there would store inside the
|
|
// durable image, so abandon the rest of the extent and start a fresh
|
|
// one. The waste is bounded by one extent per collection per checkpoint.
|
|
//
|
|
// `is_unpublished_at` rather than a comparison against the stable mark:
|
|
// an extent recycled off the free list starts *below* the mark and is
|
|
// still writable. Asking the mark meant every recycled extent was thrown
|
|
// away after one document, so churn never reused anything.
|
|
//
|
|
// Room is checked from the *rounded-up* cursor rather than the cursor
|
|
// itself, so the round-up `slab_append` may have to do is guaranteed to
|
|
// fit. Without that the append's own re-check could discover it needs a
|
|
// fresh extent, which is fallible, after the log record is already
|
|
// durable. Costs under one system page per extent.
|
|
if (self.pager.is_unpublished_at(self.slab_tail) and self.appendable_end(len)) return 0;
|
|
// The page holding the tail is frozen, but the *rest* of the extent is
|
|
// not: nothing above the live cursor is referenced by the image or by an
|
|
// index. So skip to the next system page and keep the extent, instead of
|
|
// throwing away what is left of 8 MiB.
|
|
//
|
|
// This is what the plan called for ("append cursors are rounded up to the
|
|
// system page size at each checkpoint") and it matters more than it
|
|
// sounds: abandoning the extent costs ~8 MiB per collection per
|
|
// checkpoint, and a pure-insert workload generates no garbage, so
|
|
// compaction never fires and nothing ever gives it back. Measured at 40
|
|
// collections: the data file reached 11.8x the live data and grew by
|
|
// ~335 MB per checkpoint, heading for DatabaseTooLarge at around 6 GB of
|
|
// real data.
|
|
if (self.appendable_end(len)) {
|
|
const resumed = std.mem.alignForward(u64, self.slab_tail, pgr.map_align);
|
|
const skipped = self.note_skip(resumed - self.slab_tail);
|
|
self.pager.mark_appendable(resumed, self.slab_end);
|
|
self.slab_tail = resumed;
|
|
return skipped;
|
|
}
|
|
// Nothing will ever be written between the cursor and the end of the
|
|
// extent this collection is walking away from -- the extent stays
|
|
// allocated to it and every byte above the cursor is unreachable. That
|
|
// is garbage, in the whole 8 MiB, and the reservation is the only place
|
|
// that knows about it.
|
|
assert_msg(self.slab_tail <= self.slab_end, "the slab cursor is past the end of its extent");
|
|
const skipped = self.note_skip(self.slab_end - self.slab_tail);
|
|
// A document larger than the standard extent gets one of its own; BSON
|
|
// reaches 16 MB and the extent is 8 MiB.
|
|
const want_pages: u32 = @max(slab_extent_pages, pages_for(len));
|
|
try self.pager.reserve_pages(&self.hold, want_pages);
|
|
// Off the free list first, or window reclamation is decorative: the
|
|
// pages come back, nothing asks for them in a shape they arrive in, and
|
|
// the file grows by the whole write volume anyway. A floor of 1 MiB,
|
|
// because a shorter extent is exhausted after a handful of documents
|
|
// and every exhaustion abandons what is left of it -- and because the
|
|
// floor is what makes trimming a larger run harmless.
|
|
const min_pages: u32 = @min(want_pages, @max(pages_for(len), slab_run_min_pages));
|
|
const run = self.pager.alloc_slab_run(&self.hold, min_pages, want_pages) orelse pgr.Extent{
|
|
.first = self.pager.alloc_pages_assume_reserved(&self.hold, want_pages),
|
|
.pages = want_pages,
|
|
};
|
|
try self.insert_run(gpa, run.first, run.pages);
|
|
self.slab_tail = @as(u64, run.first) << pgr.page_shift;
|
|
self.slab_end = self.slab_tail + (@as(u64, run.pages) << pgr.page_shift);
|
|
return skipped;
|
|
}
|
|
|
|
/// Count `bytes` of slab that no document will ever occupy, and hand the
|
|
/// same number back so the caller can charge the engine's dead total.
|
|
///
|
|
/// The appender skips slab in two places -- rounding the cursor up to a
|
|
/// system page after a checkpoint froze the page it pointed into, and
|
|
/// abandoning the tail of an extent that no longer has room. Neither used to
|
|
/// be counted anywhere: not in `slab_used`, which only ever grew by a
|
|
/// document's length, and so not in the engine's `dead_bytes` either. It is
|
|
/// garbage all the same -- only a rebuild gets it back -- and it was
|
|
/// invisible to the trigger that decides whether a rebuild is worth doing.
|
|
///
|
|
/// Two collections churning against a checkpoint every 32 MiB skip up to a
|
|
/// system page each per checkpoint, and an abandoned extent tail can be
|
|
/// most of 8 MiB. Counted here, that garbage arms compaction like any other.
|
|
///
|
|
/// Skipped slab always starts at the cursor -- all three callers write off
|
|
/// the bytes in front of it and then move it -- so this is also where the
|
|
/// window map learns about it. That matters more for the abandoned tail
|
|
/// than for the round-up: most of 8 MiB of a run is whole windows, dead on
|
|
/// arrival, and reclaiming them is free.
|
|
fn note_skip(self: *Collection, bytes: u64) u64 {
|
|
self.slab_used += bytes;
|
|
self.mark_dead(self.slab_tail, bytes);
|
|
return bytes;
|
|
}
|
|
|
|
/// Whether a document of `len` bytes fits in this extent even if the cursor
|
|
/// first has to be rounded up to a system page. The reservation and the
|
|
/// append both ask this, so they agree on what "there is room" means.
|
|
fn appendable_end(self: *const Collection, len: usize) bool {
|
|
return std.mem.alignForward(u64, self.slab_tail, pgr.map_align) + len <= self.slab_end;
|
|
}
|
|
|
|
/// Where a document landed, and what the landing cost besides its own
|
|
/// length. `skipped` is `note_skip`'s tally for this append.
|
|
const Appended = struct { off: u64, skipped: u64 };
|
|
|
|
/// Copy `bytes` into the slab and return its absolute file offset.
|
|
/// Infallible: slab_reserve must have run for at least this many bytes.
|
|
fn slab_append(self: *Collection, bytes: []const u8) Appended {
|
|
// The cursor was checked in `slab_reserve`, but a checkpoint can have
|
|
// published since -- the reservation runs before the log append and this
|
|
// runs after it, with an fsync in between. `publish` clears the whole
|
|
// unpublished set, so a cursor that was writable then can be inside the
|
|
// frozen image now, and the copy below would store into it: a bus error
|
|
// where the protection is compiled in, and a silent overwrite of durable
|
|
// data in ReleaseFast, where it is not.
|
|
//
|
|
// Re-arming is infallible because `slab_reserve` measured its room from
|
|
// the rounded-up cursor. The pager's append lock holds off the next
|
|
// publish for the rest of this function, so the answer stays true.
|
|
self.pager.lock_append();
|
|
defer self.pager.unlock_append();
|
|
var skipped: u64 = 0;
|
|
if (!self.pager.is_unpublished_at(self.slab_tail)) {
|
|
const resumed = std.mem.alignForward(u64, self.slab_tail, pgr.map_align);
|
|
skipped = self.note_skip(resumed - self.slab_tail);
|
|
self.pager.mark_appendable(resumed, self.slab_end);
|
|
self.slab_tail = resumed;
|
|
}
|
|
assert_msg(
|
|
self.slab_tail + bytes.len <= self.slab_end,
|
|
"document append overran the slab reservation",
|
|
);
|
|
const off = self.slab_tail;
|
|
@memcpy(self.pager.bytes_mut(off, bytes.len), bytes);
|
|
self.slab_tail += bytes.len;
|
|
self.slab_used += bytes.len;
|
|
// Here rather than at the call site: a rebuild appends through this same
|
|
// path, and its copies are live by definition.
|
|
self.live_bytes += bytes.len;
|
|
return .{ .off = off, .skipped = skipped };
|
|
}
|
|
|
|
/// 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 {
|
|
// An absolute file offset, so this is base + off. The length comes from
|
|
// the document's own BSON int32 prefix, as it always has.
|
|
const len: usize = std.mem.readInt(u32, self.pager.bytes(off, 4)[0..4], .little);
|
|
return self.pager.bytes(off, len);
|
|
}
|
|
|
|
/// Remove and free the index with this name. Returns whether it existed.
|
|
/// `orderedRemove` now moves 8-byte pointers rather than whole Index
|
|
/// structs, so the surviving indexes do not move and pointers to them stay
|
|
/// valid; only the removed one dies, here.
|
|
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)) {
|
|
_ = self.indexes.orderedRemove(i);
|
|
ix.deinit(gpa);
|
|
gpa.destroy(ix);
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
};
|
|
|
|
pub const Db = struct {
|
|
/// Collections are heap-allocated so their addresses are stable while a
|
|
/// command holds a collection lock — the map may reallocate under the
|
|
/// catalog lock, but the pointers it holds do not move.
|
|
collections: std.StringHashMapUnmanaged(*Collection),
|
|
};
|
|
|
|
pub const Engine = struct {
|
|
gpa: std.mem.Allocator,
|
|
io: std.Io,
|
|
// Legacy whole-engine lock, used by the unit tests' explicit
|
|
// lock()/lock_read() calls. The server uses the finer-grained locks
|
|
// below: catalog (maps), per-collection (docs/slab/indexes), and
|
|
// log_lock (append + commit).
|
|
rwlock: std.Io.RwLock,
|
|
/// Guards the dbs/collections maps. Commands hold it shared for their
|
|
/// whole duration so a concurrent DDL cannot mutate the maps under
|
|
/// them; DDL takes it exclusive.
|
|
catalog_lock: std.Io.RwLock = .init,
|
|
/// Serializes log appends, seals and the commit sync.
|
|
log_lock: std.Io.Mutex = .init,
|
|
/// Serializes commit decisions; the group-commit leader holds it while
|
|
/// sealing and syncing.
|
|
commit_lock: std.Io.Mutex = .init,
|
|
/// Sequence number covered by the last completed commit. A seq rather
|
|
/// than a file position: an append leaves its bytes in the log's open
|
|
/// block without moving end_pos, so a position comparison would call
|
|
/// buffered-but-unwritten records durable.
|
|
committed_seq: u64 = 0,
|
|
/// Writers increment before appending and decrement after; the commit
|
|
/// leader waits for this to reach zero so its seal covers every append
|
|
/// in flight, coalescing many writers' fsyncs into one.
|
|
pending_appends: std.atomic.Value(u32) = std.atomic.Value(u32).init(0),
|
|
committing: bool = false,
|
|
commit_done: std.Io.Condition = std.Io.Condition.init,
|
|
/// Set when the garbage ratio crosses the compaction threshold; the
|
|
/// write command's epilogue runs compact after releasing its locks.
|
|
/// Atomic because it is set under a *collection* lock (see `note_compact`)
|
|
/// but read by the epilogue holding no lock at all.
|
|
compact_pending: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
|
|
/// Set while a compaction runs, so only one runs at a time. Compactions
|
|
/// share one tmp path and each ends in a rename onto the log, so two at
|
|
/// once would publish one compaction's half-written file as the database.
|
|
compacting: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
|
|
/// Collections rewritten by a rebuild since the process started. Reported by
|
|
/// `serverStatus`, because "the ratio improved" and "the ratio improved
|
|
/// because reclamation worked rather than because a rebuild ran" are
|
|
/// different results and no ratio distinguishes them. Under `counter_lock`.
|
|
compactions: u64 = 0,
|
|
log: storage.Log,
|
|
/// The data file: documents live here, and the B+tree arenas follow.
|
|
///
|
|
/// Heap-allocated because `open` builds an Engine on the stack and returns
|
|
/// it by value: every Collection holds a `*Pager`, and those were taken
|
|
/// during replay, before the move. They all dangled -- which surfaced as a
|
|
/// corrupt docs hashmap on the *second* engine in a test, not as anything
|
|
/// resembling its cause.
|
|
pager: *pgr.Pager,
|
|
dbs: std.StringHashMapUnmanaged(Db),
|
|
seq: u64,
|
|
/// Floor for the compaction trigger. The real trigger also scales with
|
|
/// the live data size — see `note_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 `note_compact`.
|
|
live_docs: u64 = 0,
|
|
dead_docs: u64 = 0,
|
|
/// Hands out `Collection.layout_epoch` values. Monotonic and never reset, so
|
|
/// no two collection instances -- including a drop followed by a recreate
|
|
/// under the same name -- ever share one.
|
|
layout_epoch_seq: u64 = 0,
|
|
/// Open cursors. Lives on the engine rather than the server because the C
|
|
/// API seam (PLAN D1) lists cursor iteration, and because the unit tests
|
|
/// build an Engine with no server at all. Its mutex is a leaf: see
|
|
/// `cursor.Store`.
|
|
cursors: cursor.Store,
|
|
/// The same question in bytes, about the *data file* rather than the log.
|
|
/// Once a checkpoint truncates the log, the log no longer holds the garbage
|
|
/// -- the doc slab does, and only a rebuild reclaims it. These are what
|
|
/// `note_compact` gates on; counting documents would let one collection of
|
|
/// 16 KiB documents and one of 40 B documents look identical.
|
|
live_bytes: u64 = 0,
|
|
dead_bytes: u64 = 0,
|
|
/// Guards the four counters above -- `live_docs`, `dead_docs`,
|
|
/// `live_bytes`, `dead_bytes` -- and nothing else.
|
|
///
|
|
/// They are the only engine-wide mutable state a writer touches while
|
|
/// holding nothing but its own collection's lock, so two writers on
|
|
/// different collections reach them with no lock in common. The lost update
|
|
/// that allows is the smaller half of the problem. The larger half is that a
|
|
/// reader had no way to see them consistently with the per-collection totals
|
|
/// they are supposed to equal: `checkpoint` reads each collection's counters
|
|
/// under that collection's lock -- which orders it against that
|
|
/// collection's writer -- and then read these with no lock at all, so it
|
|
/// could see a total that predated a write it had just serialized. Its own
|
|
/// assertion then aborted the server, correctly, about a database that was
|
|
/// consistent.
|
|
///
|
|
/// A leaf: nothing else is taken while it is held, and it is never held
|
|
/// across an append, an fsync, or an allocation.
|
|
counter_lock: std.Io.Mutex = .init,
|
|
/// The checkpoint's own page promise, for the catalog and free-list pages it
|
|
/// writes. Separate from any collection's for the same reason those are
|
|
/// separate from each other.
|
|
hold: pgr.Reservation = .{},
|
|
/// Set when the log has grown enough since the last checkpoint to be worth
|
|
/// reclaiming. Read by the write epilogue and the TTL monitor, both of which
|
|
/// run without holding a collection lock.
|
|
checkpoint_pending: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
|
|
/// Log bytes that trigger a checkpoint. Distinct from the compaction
|
|
/// threshold: compaction is about the *garbage share* of the data, a
|
|
/// checkpoint is about how much replay an open would otherwise have to do.
|
|
checkpoint_threshold: u64 = 32 * 1024 * 1024,
|
|
/// Whether replay must maintain index entries as it goes.
|
|
///
|
|
/// A full replay does not: it puts documents in place and lets
|
|
/// `build_all_indexes` bulk-pack every index afterwards, which is O(n log n)
|
|
/// once instead of per record. After a checkpoint that is wrong -- the
|
|
/// indexes arrive already populated, `rebuild_index` skips a non-empty one by
|
|
/// design, and the records replayed on top would be invisible to every
|
|
/// index. The symptom was a document present in the collection and missing
|
|
/// from `_id_`, which after the hashmap goes away means simply missing.
|
|
replay_maintains_indexes: bool = false,
|
|
/// 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,
|
|
|
|
/// The registry an embedded caller gets without configuring anything; the
|
|
/// CLI replaces it through `reconfigure_cursors`.
|
|
fn default_cursor_store(gpa: std.mem.Allocator, io: std.Io) !cursor.Store {
|
|
return cursor.Store.init(gpa, io, cursor.default_capacity, cursor.default_idle_timeout_ms);
|
|
}
|
|
|
|
pub fn open(gpa: std.mem.Allocator, io: std.Io, path: []const u8) !Engine {
|
|
var log = try storage.Log.open(gpa, io, path);
|
|
errdefer log.close();
|
|
|
|
// The data file sits beside the log and is *kept*: a valid watermark in
|
|
// it means most of the log never has to be replayed.
|
|
const data_path = try std.fmt.allocPrint(gpa, "{s}.data", .{log.path});
|
|
defer gpa.free(data_path);
|
|
|
|
const pager_box = try gpa.create(pgr.Pager);
|
|
errdefer gpa.destroy(pager_box);
|
|
pager_box.* = try pgr.Pager.open(gpa, io, data_path, .{});
|
|
|
|
var engine = Engine{
|
|
.gpa = gpa,
|
|
.io = io,
|
|
.rwlock = .init,
|
|
.log = log,
|
|
.pager = pager_box,
|
|
.dbs = .empty,
|
|
.seq = 0,
|
|
.compact_threshold = 16 * 1024 * 1024,
|
|
.cursors = try default_cursor_store(gpa, io),
|
|
};
|
|
errdefer {
|
|
engine.cursors.deinit();
|
|
engine.pager.deinit();
|
|
engine.dbs.deinit(gpa);
|
|
}
|
|
|
|
// A checkpoint, if the data file has one, decides where replay starts.
|
|
// Nothing below the watermark needs re-applying: the data file already
|
|
// holds its effect.
|
|
var replay_from: u64 = 0;
|
|
if (engine.pager.loaded.generation != 0) {
|
|
engine.read_catalog() catch |err| {
|
|
// The image is unusable but the log is not. Warn, drop
|
|
// everything loaded, and fall back to a full replay -- the
|
|
// database must always open.
|
|
std.debug.print(
|
|
"multiforadb: WARNING: data file catalog unreadable ({s}); " ++
|
|
"replaying the log in full\n",
|
|
.{@errorName(err)},
|
|
);
|
|
engine.reset_after_failed_catalog();
|
|
replay_from = 0;
|
|
};
|
|
if (replay_from == 0 and engine.dbs.count() > 0) {
|
|
replay_from = engine.pager.loaded.seq;
|
|
engine.replay_maintains_indexes = true;
|
|
engine.seq = replay_from;
|
|
engine.committed_seq = replay_from;
|
|
engine.live_docs = engine.pager.loaded.live_docs;
|
|
// `dead_bytes` is *not* restored from the watermark. It is
|
|
// derived, not stored: `read_catalog` has already summed
|
|
// `slab_used - live_bytes` over the collections the catalog
|
|
// still lists. The watermark's copy is a hint for anything
|
|
// inspecting the header without parsing the catalog, and it
|
|
// would be wrong here in one specific way -- a collection
|
|
// dropped after the last checkpoint takes its garbage with it,
|
|
// and the hint would keep charging the engine for it.
|
|
}
|
|
}
|
|
|
|
try engine.log.replay(&engine, apply_record, replay_from);
|
|
// Replay registers empty indexes; build them from the live docs
|
|
// once replay completes (order-independent). A checkpointed open finds
|
|
// them already populated, and the guard in rebuild_index skips them.
|
|
try engine.build_all_indexes();
|
|
engine.assert_indexes_cover_every_document();
|
|
// Everything replayed is durable by definition -- it was read back off
|
|
// the log -- so the commit watermark starts level with the sequence.
|
|
engine.committed_seq = engine.seq;
|
|
return engine;
|
|
}
|
|
|
|
/// Replace the cursor registry with one of a different shape. Only legal
|
|
/// before the server starts accepting connections, because it drops every
|
|
/// cursor -- asserted rather than left to the comment, since the method is
|
|
/// public and a later caller would otherwise get silent data loss.
|
|
pub fn reconfigure_cursors(self: *Engine, capacity: u32, idle_timeout_ms: i64) !void {
|
|
assert_msg(self.cursors.live == 0, "reconfigured the cursor registry with cursors open");
|
|
const fresh = try cursor.Store.init(self.gpa, self.io, capacity, idle_timeout_ms);
|
|
self.cursors.deinit();
|
|
self.cursors = fresh;
|
|
}
|
|
|
|
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);
|
|
// Before the pager: a cursor's arena is its own, but freeing cursors
|
|
// first keeps the teardown order the same as the construction order
|
|
// reversed, which is the only order that stays obviously correct as
|
|
// cursors grow to hold more.
|
|
self.cursors.deinit();
|
|
self.pager.deinit();
|
|
self.gpa.destroy(self.pager);
|
|
self.log.close();
|
|
}
|
|
|
|
/// Slab a reservation wrote off, on the engine's books. Its own acquisition
|
|
/// rather than the reservation's caller adding to the field: skips cluster
|
|
/// at a checkpoint -- `publish` freezes every collection's append cursor at
|
|
/// once, so the next write to each of them skips -- which is precisely when
|
|
/// several writers reach this counter at the same moment.
|
|
fn count_slab_skip(self: *Engine, bytes: u64) void {
|
|
if (bytes == 0) return;
|
|
self.counter_lock.lockUncancelable(self.io);
|
|
defer self.counter_lock.unlock(self.io);
|
|
self.dead_bytes += bytes;
|
|
}
|
|
|
|
/// A snapshot of the four counters, taken together. Both readers reason
|
|
/// about a *relation* -- the compaction trigger about the ratio of two of
|
|
/// them, the checkpoint about how they compare to the sum over collections
|
|
/// -- so reading them one at a time would be comparing two moments.
|
|
const Counters = struct { live_docs: u64, dead_docs: u64, live_bytes: u64, dead_bytes: u64 };
|
|
|
|
fn counters(self: *Engine) Counters {
|
|
self.counter_lock.lockUncancelable(self.io);
|
|
defer self.counter_lock.unlock(self.io);
|
|
return .{
|
|
.live_docs = self.live_docs,
|
|
.dead_docs = self.dead_docs,
|
|
.live_bytes = self.live_bytes,
|
|
.dead_bytes = self.dead_bytes,
|
|
};
|
|
}
|
|
|
|
/// 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 {
|
|
self.counter_lock.lockUncancelable(self.io);
|
|
// Dropping a collection turns all of its records into garbage. The
|
|
// engine's live count includes every collection's documents, so it can
|
|
// never be smaller than this one's -- and a u64 underflow here would
|
|
// read as an astronomically large live count, permanently suppressing
|
|
// compaction rather than crashing.
|
|
assert_msg(self.live_docs >= coll.doc_count, "dropping a collection would underflow the engine's live count");
|
|
self.live_docs -= coll.doc_count;
|
|
self.dead_docs += coll.doc_count;
|
|
assert_msg(self.live_bytes >= coll.live_bytes, "dropping a collection would underflow the engine's live bytes");
|
|
self.live_bytes -= coll.live_bytes;
|
|
// A drop *reclaims*, it does not deaden. The loop below hands every page
|
|
// this collection owned back to the pager, so its live bytes are not
|
|
// garbage waiting for a rebuild -- they are already gone. Adding them to
|
|
// `dead_bytes` armed a compaction for space that had just been returned,
|
|
// and a rebuild costs a full copy of every *other* collection.
|
|
//
|
|
// Its garbage goes the other way, for the same reason: the bytes this
|
|
// collection had already lost to eviction were counted in `dead_bytes`
|
|
// when they died, and those pages are being freed too. That keeps
|
|
// `dead_bytes` exactly the sum of `slab_used - live_bytes` over the
|
|
// collections that still exist, which is what `read_catalog` recomputes
|
|
// on open and what `write_catalog` asserts.
|
|
assert_msg(
|
|
coll.slab_used >= coll.live_bytes,
|
|
"a collection cannot hold more live bytes than it ever appended",
|
|
);
|
|
const coll_dead = coll.slab_used - coll.live_bytes;
|
|
assert_msg(
|
|
self.dead_bytes >= coll_dead,
|
|
"dropping a collection would underflow the engine's dead bytes",
|
|
);
|
|
self.dead_bytes -= coll_dead;
|
|
self.counter_lock.unlock(self.io);
|
|
coll.id_index.deinit(self.gpa);
|
|
for (coll.indexes.items) |ix| {
|
|
ix.deinit(self.gpa);
|
|
self.gpa.destroy(ix);
|
|
}
|
|
coll.indexes.deinit(self.gpa);
|
|
// Give the slab's pages back. They become reusable two generations
|
|
// later, so a fallback to the previous image still finds them intact.
|
|
for (coll.slab_runs.items) |r| {
|
|
self.pager.free_pages(r.first, r.pages) catch {};
|
|
}
|
|
coll.free_runs(self.gpa);
|
|
coll.slab_runs.deinit(self.gpa);
|
|
self.gpa.destroy(coll);
|
|
}
|
|
|
|
/// 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, keyed by the slab
|
|
/// offset the map hands back. It used to matter that the map key was still
|
|
/// alive at this point, because entries aliased it; entries carry an
|
|
/// offset now, so that constraint is gone.
|
|
///
|
|
/// The document itself is handed to the index: entries are located by
|
|
/// regenerating them from it, which is far cheaper than scanning.
|
|
/// `id_enc` is `bson.encode_key` of the document's `_id` -- the canonical
|
|
/// encoding, which is what the `_id_` index is keyed on.
|
|
/// Copy a new document's bytes into the collection's slab and count them as
|
|
/// live at both levels. `Collection.slab_append` maintains the collection's
|
|
/// own total (a rebuild appends through it too, and its copies are live by
|
|
/// definition); the engine's total only moves when a document actually
|
|
/// becomes live, which a rebuild's copies do not.
|
|
/// Drop the unclaimed part of every promise a write to this collection took:
|
|
/// the slab's and one per index. Called after the write is published, under
|
|
/// the same collection lock the reservations were taken under.
|
|
fn release_write_reservations(self: *Engine, coll: *Collection) void {
|
|
self.pager.release_reservation(&coll.hold);
|
|
self.pager.release_reservation(&coll.id_index.hold);
|
|
for (coll.indexes.items) |ix| self.pager.release_reservation(&ix.hold);
|
|
}
|
|
|
|
/// A document becoming live: its bytes into the slab, and every engine
|
|
/// counter that describes. The document count moved here from the two call
|
|
/// sites so that one document costs one acquisition of `counter_lock` and
|
|
/// leaves the totals agreeing at every moment a reader could look.
|
|
fn publish_doc_bytes(self: *Engine, coll: *Collection, bytes: []const u8) u64 {
|
|
const appended = coll.slab_append(bytes);
|
|
self.counter_lock.lockUncancelable(self.io);
|
|
defer self.counter_lock.unlock(self.io);
|
|
self.live_bytes += bytes.len;
|
|
self.live_docs += 1;
|
|
// Slab the append had to write off. Charged here, under the same
|
|
// collection lock the append ran under, so a checkpoint's catalog walk
|
|
// never sees the collection's total moved and the engine's not.
|
|
self.dead_bytes += appended.skipped;
|
|
return appended.off;
|
|
}
|
|
|
|
fn evict_doc(self: *Engine, coll: *Collection, id_enc: []const u8) void {
|
|
const off = coll.id_index.lookup_exact(id_enc) orelse return;
|
|
// 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(off);
|
|
coll.id_index.remove_doc(self.gpa, old_bytes, off);
|
|
for (coll.indexes.items) |ix| ix.remove_doc(self.gpa, old_bytes, off);
|
|
// This document's log record (and its slab bytes) just became garbage.
|
|
{
|
|
self.counter_lock.lockUncancelable(self.io);
|
|
defer self.counter_lock.unlock(self.io);
|
|
assert_msg(self.live_docs >= 1, "evicting a document would underflow the engine's live count");
|
|
self.live_docs -= 1;
|
|
self.dead_docs += 1;
|
|
assert_msg(self.live_bytes >= old_bytes.len, "evicting a document would underflow the engine's live bytes");
|
|
self.live_bytes -= old_bytes.len;
|
|
self.dead_bytes += old_bytes.len;
|
|
}
|
|
assert_msg(coll.doc_count >= 1, "evicting a document would underflow the collection's count");
|
|
coll.doc_count -= 1;
|
|
assert_msg(coll.live_bytes >= old_bytes.len, "evicting a document would underflow the collection's live bytes");
|
|
coll.live_bytes -= old_bytes.len;
|
|
// Last, and after every index entry naming these bytes is gone. That
|
|
// ordering is what makes window reclamation safe to do by counting
|
|
// alone: a window only reaches `map_align` dead once every document
|
|
// touching it has been through here, so nothing reachable is inside it.
|
|
coll.mark_dead(off, old_bytes.len);
|
|
}
|
|
|
|
/// `bson.encode_key` of a stored document's `_id`, owned by the caller.
|
|
fn id_enc_of(gpa: std.mem.Allocator, doc_bytes: []const u8) ![]u8 {
|
|
var arena = std.heap.ArenaAllocator.init(gpa);
|
|
defer arena.deinit();
|
|
const id_value = (try bson.get_at(arena.allocator(), doc_bytes, "_id")) orelse
|
|
return error.MissingId;
|
|
var enc: std.ArrayListUnmanaged(u8) = .empty;
|
|
errdefer enc.deinit(gpa);
|
|
try bson.encode_key(id_value, gpa, &enc);
|
|
return enc.toOwnedSlice(gpa);
|
|
}
|
|
|
|
// -- 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);
|
|
}
|
|
|
|
// -- per-collection locking (the server's command dispatch) ------------
|
|
|
|
/// Lock the catalog for a command's duration.
|
|
pub fn lock_catalog(self: *Engine, exclusive: bool) !void {
|
|
if (exclusive) {
|
|
try self.catalog_lock.lock(self.io);
|
|
} else {
|
|
try self.catalog_lock.lockShared(self.io);
|
|
}
|
|
}
|
|
|
|
pub fn unlock_catalog(self: *Engine, exclusive: bool) void {
|
|
if (exclusive) self.catalog_lock.unlock(self.io) else self.catalog_lock.unlockShared(self.io);
|
|
}
|
|
|
|
/// With the catalog lock held, resolve the target collection and take
|
|
/// its lock. When the collection is missing and `create` is set, the
|
|
/// catalog lock is upgraded to exclusive to create it (then restored to
|
|
/// shared); the collection lock is acquired before the exclusive catalog
|
|
/// lock is dropped, so a concurrent drop can never free it underneath.
|
|
/// Returns null when the collection does not exist (and create is off).
|
|
pub fn lock_collection(
|
|
self: *Engine,
|
|
db_name: []const u8,
|
|
coll_name: []const u8,
|
|
write: bool,
|
|
create: bool,
|
|
) !?*Collection {
|
|
var coll = self.get_collection(db_name, coll_name);
|
|
if (coll == null and create) {
|
|
self.catalog_lock.unlockShared(self.io);
|
|
try self.catalog_lock.lock(self.io);
|
|
coll = try self.get_or_create_collection(db_name, coll_name);
|
|
try self.lock_one(coll.?, write);
|
|
self.catalog_lock.unlock(self.io);
|
|
try self.catalog_lock.lockShared(self.io);
|
|
return coll;
|
|
}
|
|
if (coll) |c| try self.lock_one(c, write);
|
|
return coll;
|
|
}
|
|
|
|
fn lock_one(self: *Engine, coll: *Collection, write: bool) !void {
|
|
if (write) {
|
|
try coll.lock.lock(self.io);
|
|
} else {
|
|
try coll.lock.lockShared(self.io);
|
|
}
|
|
}
|
|
|
|
pub fn unlock_collection(self: *Engine, coll: *Collection, write: bool) void {
|
|
if (write) coll.lock.unlock(self.io) else coll.lock.unlockShared(self.io);
|
|
}
|
|
|
|
/// Ensure this command's appends are durable. The commit leader waits
|
|
/// for writers mid-append to finish, then seals and syncs once, covering
|
|
/// every append in flight — followers that arrived during the leader's
|
|
/// commit find their records already covered and return without a sync
|
|
/// of their own. Every acknowledged write is fsynced before its reply,
|
|
/// so the crash guarantees are unchanged.
|
|
pub fn commit(self: *Engine) !void {
|
|
try self.commit_lock.lock(self.io);
|
|
defer self.commit_lock.unlock(self.io);
|
|
// A commit can never have sealed more than was ever appended.
|
|
assert_msg(self.committed_seq <= self.seq, "commit claims to have sealed more than was appended");
|
|
// Everything this command appended is at or below the current seq.
|
|
// Read it before waiting, so a leader that sealed before this
|
|
// command's appends cannot be mistaken for one that covered them.
|
|
try self.log_lock.lock(self.io);
|
|
const want = self.seq;
|
|
self.log_lock.unlock(self.io);
|
|
// A commit is in flight; wait for it, then check whether the
|
|
// leader's seal covered this writer's append.
|
|
//
|
|
// The error propagates rather than being swallowed: this function
|
|
// returning success is what tells the caller its write is on disk, so
|
|
// reporting success after a failed wait acknowledges a write that was
|
|
// never synced. A canceled connection has no reason to wait out
|
|
// another writer's commit, so cancelable is right here.
|
|
while (self.committing) try self.commit_done.wait(self.io, &self.commit_lock);
|
|
if (self.committed_seq >= want) {
|
|
return; // a concurrent commit already synced this writer's records
|
|
}
|
|
// Become the leader: the flag is set before the wait below, so any
|
|
// commit that arrives during it waits as a follower.
|
|
self.committing = true;
|
|
var done = false;
|
|
defer {
|
|
if (!done) {
|
|
self.committing = false;
|
|
// Broadcast: followers sleeping on `committing` all need to
|
|
// re-check it, not just one of them.
|
|
self.commit_done.broadcast(self.io);
|
|
}
|
|
}
|
|
// Wait for writers mid-append to finish so the seal covers them.
|
|
//
|
|
// Uncancelable: `committing` is set, so every other writer is now
|
|
// parked behind this leader. Abandoning the commit here would strand
|
|
// them for a full extra round trip, and the drain is bounded anyway --
|
|
// an in-flight append only holds log_lock long enough to buffer its
|
|
// record. Finishing is strictly better than bailing out.
|
|
while (self.pending_appends.load(.acquire) > 0) {
|
|
self.commit_done.waitUncancelable(self.io, &self.commit_lock);
|
|
}
|
|
// The drain is what makes the seal below cover every append in flight.
|
|
// Not asserted as pending_appends == 0 here: a new append can start
|
|
// at any moment (it increments without commit_lock), so a fresh
|
|
// writer can be in flight between the drain's last check and this
|
|
// point. The seal still covers every append that wrote bytes before
|
|
// the sync — appends serialize with it on log_lock — and any append
|
|
// that starts after it is sealed by its own commit.
|
|
try self.log_lock.lock(self.io);
|
|
defer self.log_lock.unlock(self.io);
|
|
try self.log.sync();
|
|
// Appends drained above, so the seal covered every record written so
|
|
// far -- including any that arrived while this leader waited.
|
|
self.committed_seq = self.seq;
|
|
// This writer's own records are now durable: the postcondition the
|
|
// caller relies on before it acknowledges the write. Paired with the
|
|
// same check in the dispatch epilogue (see commands.zig).
|
|
assert_msg(self.committed_seq >= want, "commit returning success without sealing this writer's records");
|
|
done = true;
|
|
self.committing = false;
|
|
// Broadcast: every follower waiting on `committing` must wake to see
|
|
// it cleared — a single signal would wake only one and strand the
|
|
// rest.
|
|
self.commit_done.broadcast(self.io);
|
|
}
|
|
|
|
/// Log an append (and its seq increment) under the log lock, marking
|
|
/// the append as in flight so a commit leader's seal covers it.
|
|
fn log_append(
|
|
self: *Engine,
|
|
comptime kind: LogKind,
|
|
db: []const u8,
|
|
coll: []const u8,
|
|
doc: []const u8,
|
|
) !void {
|
|
_ = self.pending_appends.fetchAdd(1, .acq_rel);
|
|
defer {
|
|
// The increment above pairs with this decrement on every return
|
|
// path, so the count can never be zero here.
|
|
assert_msg(self.pending_appends.load(.acquire) > 0, "log_append decrementing an already-zero in-flight count");
|
|
_ = self.pending_appends.fetchSub(1, .acq_rel);
|
|
// Wake a commit leader waiting for in-flight appends. The
|
|
// signal must be delivered while holding commit_lock: a leader
|
|
// between its pending_appends check and its wait() still holds
|
|
// the lock, so a signal here can never land in that window and
|
|
// be lost (which froze every writer once a few connections
|
|
// committed concurrently). The leader's wait() releases the
|
|
// lock, so this lock only blocks until it starts waiting.
|
|
// Broadcast rather than signal: if a follower waiting on
|
|
// `committing` snatches the single wakeup, the leader would
|
|
// sleep forever even with pending_appends back at zero.
|
|
//
|
|
// lockUncancelable, not lock: this is a cleanup path, and the
|
|
// cancelable variant can fail. Swallowing that failure and
|
|
// unlocking anyway would release a mutex we never took, which
|
|
// Mutex.unlock treats as `unreachable` -- a panic in ReleaseSafe
|
|
// and silent memory corruption in the default ReleaseFast build.
|
|
self.commit_lock.lockUncancelable(self.io);
|
|
self.commit_done.broadcast(self.io);
|
|
self.commit_lock.unlock(self.io);
|
|
}
|
|
try self.log_lock.lock(self.io);
|
|
defer self.log_lock.unlock(self.io);
|
|
self.seq += 1;
|
|
// Seqs start at 1 and only ever increase; 0 means "nothing appended",
|
|
// which is what committed_seq is compared against.
|
|
assert_msg(self.seq > 0, "log_append produced a zero seq");
|
|
switch (kind) {
|
|
.upsert => try self.log.append_upsert(db, coll, doc, self.seq),
|
|
.delete => try self.log.append_delete(db, coll, doc, self.seq),
|
|
.index_create => try self.log.append_index_create(db, coll, doc, self.seq),
|
|
.index_drop => try self.log.append_index_drop(db, coll, doc, self.seq),
|
|
}
|
|
}
|
|
|
|
/// 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 {
|
|
_ = try self.upsert(db_name, coll_name, doc, oid_gen, .insert);
|
|
}
|
|
|
|
/// Whether a write changed anything. A replace whose result is byte-identical
|
|
/// to what is stored is not an error and not a write: MongoDB reports it as
|
|
/// matched but not modified, and writes no oplog entry for it.
|
|
pub const Written = enum { modified, unchanged };
|
|
|
|
/// Insert or replace a document by _id (upsert without existence check).
|
|
/// Returns `.unchanged` when the stored document already had these exact
|
|
/// bytes -- see `Written`.
|
|
pub fn replace(
|
|
self: *Engine,
|
|
db_name: []const u8,
|
|
coll_name: []const u8,
|
|
doc: *const bson.Document,
|
|
oid_gen: *bson.ObjectIdGen,
|
|
) !Written {
|
|
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 },
|
|
) !Written {
|
|
const coll = try self.get_or_create_collection(db_name, coll_name);
|
|
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;
|
|
// The canonical encoding, because that is what `_id_` is keyed on. It
|
|
// used to be serialize_value, for a hashmap that no longer exists.
|
|
var id_enc_list: std.ArrayListUnmanaged(u8) = .empty;
|
|
defer id_enc_list.deinit(self.gpa);
|
|
try bson.encode_key(id_value, self.gpa, &id_enc_list);
|
|
const id_enc = id_enc_list.items;
|
|
coll.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);
|
|
}
|
|
{
|
|
// The implicit _id_ index, through the same protocol: reserved
|
|
// before the log append, inserted infallibly after it. Built
|
|
// *first* so it is checked first below -- MongoDB reports _id_
|
|
// when a write violates both it and a unique secondary.
|
|
var built = try coll.id_index.build_entries(self.gpa, doc_bytes);
|
|
built_list.append(self.gpa, .{ .built = built, .ix = &coll.id_index }) catch |err| {
|
|
built.deinit(self.gpa);
|
|
return err;
|
|
};
|
|
}
|
|
for (coll.indexes.items) |ix| {
|
|
var built = try ix.build_entries(self.gpa, doc_bytes);
|
|
built_list.append(self.gpa, .{ .built = built, .ix = ix }) catch |err| {
|
|
built.deinit(self.gpa);
|
|
return err;
|
|
};
|
|
}
|
|
|
|
// A replace that would store the same bytes is not a write at all. It
|
|
// has to be decided here -- after the document is serialized, so the
|
|
// comparison is against what would actually be stored, and before the
|
|
// log append, so a no-op costs no log record, no fsync, no slab bytes
|
|
// and no garbage. `nModified` is the visible half of this: MongoDB
|
|
// counts a document as modified only if the update altered it, so
|
|
// `$set: {x: 11}` on a document already holding `x: 11` is matched and
|
|
// not modified.
|
|
if (mode == .replace) {
|
|
if (coll.id_index.lookup_exact(id_enc)) |old_off| {
|
|
if (std.mem.eql(u8, coll.doc_bytes(old_off), doc_bytes)) return .unchanged;
|
|
}
|
|
}
|
|
|
|
// 2. Unique-index checks, _id_ included; a rejected write never
|
|
// reaches the log. `_id` uniqueness used to be a `docs.contains`
|
|
// probe here, which the docs map will not be around to answer
|
|
// (PLAN amendment A3) -- and the tree answers it better, since it
|
|
// is keyed on the canonical encode_key rather than serialize_value
|
|
// (A4). Exclude-self is null for an insert: the document has no
|
|
// entries yet, and passing its offset would hide precisely the
|
|
// same-_id collision this must catch. For a replace it is the
|
|
// document's *current* slab offset, since that is what its existing
|
|
// entries carry -- the new offset does not exist yet.
|
|
const exclude: ?u64 = if (mode == .replace) coll.id_index.lookup_exact(id_enc) else null;
|
|
for (built_list.items) |*b| {
|
|
if (!b.ix.unique) continue;
|
|
b.ix.check_unique(b.built.entries.items, exclude) catch {
|
|
// The implicit index keeps its own error identity, so
|
|
// commands.zig renders E11000 with index "_id_" exactly as
|
|
// before and needs no change; `dup_index` stays null, which is
|
|
// what that rendering treats as "the _id_ index".
|
|
if (b.ix == &coll.id_index) return error.DuplicateKey;
|
|
coll.dup_index = b.ix.name;
|
|
return error.DuplicateKeyIndex;
|
|
};
|
|
}
|
|
|
|
// 4. Reserve everything the publish step needs -- tree capacity and
|
|
// slab room -- as the last fallible work, so nothing after the log
|
|
// append can fail. The slab reservation used to be absent because
|
|
// appending to an in-memory ArrayList was the only failure mode; a
|
|
// file-backed slab can also fail on growth, and failing *after* the
|
|
// record is durable would report an error for a write the next open
|
|
// would produce anyway.
|
|
for (built_list.items) |*b| {
|
|
try b.ix.reserve_for(self.gpa, b.built.entries.items);
|
|
}
|
|
self.count_slab_skip(try coll.slab_reserve(self.gpa, doc_bytes.len));
|
|
|
|
// 5. Log (and sync) before anything becomes visible. The append
|
|
// takes the log lock; durability (fsync) is the command's commit.
|
|
try self.log_append(.upsert, db_name, coll_name, doc_bytes);
|
|
|
|
// 6. Replace drops the old document (and its index entries).
|
|
if (mode == .replace) self.evict_doc(coll, id_enc);
|
|
|
|
// 7. Publish the document and its entries: copy the bytes into the
|
|
// slab and record the offset. Infallible from here.
|
|
const off = self.publish_doc_bytes(coll, doc_bytes);
|
|
coll.doc_count += 1;
|
|
for (built_list.items) |*b| {
|
|
if (b.built.multikey) b.ix.multikey = true;
|
|
b.ix.insert_entries(&b.built, off);
|
|
}
|
|
// The write is published; anything the reservations above did not claim
|
|
// is dead. Leaving it promised would grow the file on every write. Every
|
|
// consumer this upsert reserved through, and only those: another
|
|
// collection may be mid-write on another thread.
|
|
self.release_write_reservations(coll);
|
|
self.note_compact();
|
|
self.note_checkpoint();
|
|
return .modified;
|
|
}
|
|
|
|
/// 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 {
|
|
var enc: std.ArrayListUnmanaged(u8) = .empty;
|
|
defer enc.deinit(self.gpa);
|
|
try bson.encode_key(id, self.gpa, &enc);
|
|
return self.remove(db_name, coll_name, enc.items);
|
|
}
|
|
|
|
/// `id_enc` is `bson.encode_key` of the `_id`.
|
|
fn remove(self: *Engine, db_name: []const u8, coll_name: []const u8, id_enc: []const u8) !bool {
|
|
const db = self.dbs.get(db_name) orelse return false;
|
|
const coll = db.collections.get(coll_name) orelse return false;
|
|
const off = coll.id_index.lookup_exact(id_enc) 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_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);
|
|
try self.log_append(.delete, db_name, coll_name, id_doc.items);
|
|
|
|
self.evict_doc(coll, id_enc);
|
|
// Deletes grow the log too. Without this a delete-heavy workload
|
|
// never compacts, because only upsert and ttl_sweep used to check.
|
|
self.note_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.get(coll_name);
|
|
}
|
|
|
|
pub fn get_doc(
|
|
self: *Engine,
|
|
db_name: []const u8,
|
|
coll_name: []const u8,
|
|
/// `bson.encode_key` of the `_id`.
|
|
id_enc: []const u8,
|
|
) ?[]const u8 {
|
|
const coll = self.get_collection(db_name, coll_name) orelse return null;
|
|
const off = coll.id_index.lookup_exact(id_enc) orelse return null;
|
|
return coll.doc_bytes(off);
|
|
}
|
|
|
|
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;
|
|
const removed = db.collections.fetchRemove(coll_name) orelse return false;
|
|
self.free_collection(removed.value);
|
|
self.gpa.free(removed.key);
|
|
// A cursor on this namespace is already safe -- it holds names, so its
|
|
// next getMore finds nothing to lock -- but reaping here frees the slots
|
|
// now instead of at the idle timeout, and keeps the open-cursor metric
|
|
// describing cursors that can still return something.
|
|
_ = self.cursors.kill_namespace(self.io, db_name, coll_name);
|
|
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);
|
|
_ = self.cursors.kill_namespace(self.io, db_name, null);
|
|
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);
|
|
const parsed = try index.parse_spec(self.gpa, self.pager, spec_doc);
|
|
// Boxed before anything is built into it, so publishing is a pointer
|
|
// append rather than a struct copy. An Index will own a mapping once
|
|
// the arena is file-backed, and copying one then would duplicate that
|
|
// ownership.
|
|
const ix = self.gpa.create(index.Index) catch |err| {
|
|
var dead = parsed;
|
|
dead.deinit(self.gpa);
|
|
return err;
|
|
};
|
|
ix.* = parsed;
|
|
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);
|
|
self.gpa.destroy(ix);
|
|
};
|
|
|
|
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.id_index.iter();
|
|
while (doc_it.next()) |entry| {
|
|
try ix.append_doc_entries(self.gpa, coll.doc_bytes(entry.off), entry.off);
|
|
}
|
|
_ = try ix.finish_bulk(self.gpa, true);
|
|
self.pager.release_reservation(&ix.hold);
|
|
|
|
// 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);
|
|
try self.log_append(.index_create, db_name, coll_name, spec_bytes.items);
|
|
|
|
coll.indexes.appendAssumeCapacity(ix);
|
|
committed = true;
|
|
return ix;
|
|
}
|
|
|
|
/// 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.get(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);
|
|
try self.log_append(.index_drop, db_name, coll_name, name_doc.items);
|
|
|
|
_ = 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;
|
|
// Catalog lock for the whole sweep (the collection pointers stay
|
|
// valid); each collection is swept under its own write lock, one at
|
|
// a time, never two at once.
|
|
try self.catalog_lock.lockShared(self.io);
|
|
defer self.catalog_lock.unlockShared(self.io);
|
|
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| {
|
|
const coll = coll_entry.value_ptr.*;
|
|
deleted += try self.ttl_sweep_coll(coll, now_ms, db_entry.key_ptr.*, coll_entry.key_ptr.*);
|
|
}
|
|
}
|
|
// A TTL-only workload never reaches the threshold check in `upsert`,
|
|
// so the log would otherwise grow without bound.
|
|
if (deleted > 0) self.note_compact();
|
|
return deleted;
|
|
}
|
|
|
|
/// Sweep one collection under its write lock; the lock is released on
|
|
/// every return path. Returns how many documents were removed.
|
|
fn ttl_sweep_coll(
|
|
self: *Engine,
|
|
coll: *Collection,
|
|
now_ms: i64,
|
|
db_name: []const u8,
|
|
coll_name: []const u8,
|
|
) !usize {
|
|
try coll.lock.lock(self.io);
|
|
defer coll.lock.unlock(self.io);
|
|
// Offsets, collected before any removal. They are values, so unlike
|
|
// the id slices this used to dupe -- which aliased a docs-map key that
|
|
// `remove` would free out from under the rest of the batch -- there is
|
|
// nothing to own here. Collect-then-remove still matters, because the
|
|
// iterator below aliases tree pages that removal reshapes.
|
|
var offs: std.ArrayListUnmanaged(u64) = .empty;
|
|
defer offs.deinit(self.gpa);
|
|
|
|
for (coll.indexes.items) |ix| {
|
|
const ttl = ix.ttl orelse continue;
|
|
const cutoff: i128 = @as(i128, now_ms) - @as(i128, ttl) * 1000;
|
|
// bson compare order ranks datetime above null, numbers
|
|
// and strings and below only timestamp and maxKey, so
|
|
// datetimes form a contiguous band in the encoded key
|
|
// order: seek the minimum datetime and stop when the
|
|
// leading type changes or the cutoff is passed.
|
|
const min_dt = [_]u8{ bson.encoded_datetime_tag, 0, 0, 0, 0, 0, 0, 0, 0 };
|
|
var it = ix.seek(&min_dt);
|
|
while (it.next()) |e| {
|
|
const ms = bson.encoded_leading_datetime(e.key) orelse break;
|
|
if (@as(i128, ms) > cutoff) break;
|
|
try offs.append(self.gpa, e.off);
|
|
}
|
|
}
|
|
if (offs.items.len == 0) return 0;
|
|
|
|
// One document can be expired by several entries (an array
|
|
// of dates) or by several TTL indexes.
|
|
std.mem.sort(u64, offs.items, {}, std.sort.asc(u64));
|
|
var w: usize = 1;
|
|
for (offs.items[1..]) |off| {
|
|
if (off != offs.items[w - 1]) {
|
|
offs.items[w] = off;
|
|
w += 1;
|
|
}
|
|
}
|
|
offs.items.len = w;
|
|
|
|
// `remove` works by _id, so recover each one from the document its
|
|
// offset names. get_at materializes a spine, hence the arena; the slab
|
|
// is untouched by the removals, so the bytes stay valid throughout.
|
|
var arena = std.heap.ArenaAllocator.init(self.gpa);
|
|
defer arena.deinit();
|
|
var removed: usize = 0;
|
|
for (offs.items) |off| {
|
|
const id_value = (try bson.get_at(arena.allocator(), coll.doc_bytes(off), "_id")) orelse continue;
|
|
var enc: std.ArrayListUnmanaged(u8) = .empty;
|
|
try bson.encode_key(id_value, arena.allocator(), &enc);
|
|
if (try self.remove(db_name, coll_name, enc.items)) removed += 1;
|
|
}
|
|
return removed;
|
|
}
|
|
|
|
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.get(coll_name)) |coll| return coll;
|
|
const coll_key = try self.gpa.dupe(u8, coll_name);
|
|
errdefer self.gpa.free(coll_key);
|
|
const new_coll = try self.gpa.create(Collection);
|
|
errdefer self.gpa.destroy(new_coll);
|
|
self.layout_epoch_seq += 1;
|
|
new_coll.* = try Collection.init(self.gpa, self.pager, self.layout_epoch_seq);
|
|
errdefer new_coll.id_index.deinit(self.gpa);
|
|
try db.collections.put(self.gpa, coll_key, new_coll);
|
|
return new_coll;
|
|
}
|
|
|
|
/// Deep-copy a document into engine-owned storage, prepending a
|
|
/// generated ObjectId `_id` when absent.
|
|
/// 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 {
|
|
// `_id` first, always -- generated here, or moved if the client put it
|
|
// later. MongoDB stores it first whatever order it arrives in, and the
|
|
// Node driver arrives in the other order: it fills a missing `_id` by
|
|
// assigning the property, which in JavaScript appends it, so an
|
|
// `insertOne({name, age})` reaches us as `{name, age, _id}`.
|
|
//
|
|
// Two things depend on this beyond field order in results. A replacement
|
|
// keeps `_id` at the front, so storing it elsewhere made replacing a
|
|
// document with itself a byte-level change and therefore a write. And
|
|
// the position is part of the stored bytes, so it has to be settled once,
|
|
// here, rather than by every reader.
|
|
if (doc.pairs.len > 0 and std.mem.eql(u8, doc.pairs[0].key, "_id")) {
|
|
return serialize_doc(self.gpa, doc);
|
|
}
|
|
var pairs: std.ArrayListUnmanaged(bson.Pair) = .empty;
|
|
defer pairs.deinit(self.gpa);
|
|
if (doc.get("_id")) |id| {
|
|
try pairs.append(self.gpa, .{ .key = "_id", .value = id });
|
|
for (doc.pairs) |p| {
|
|
if (std.mem.eql(u8, p.key, "_id")) continue;
|
|
try pairs.append(self.gpa, p);
|
|
}
|
|
} else {
|
|
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);
|
|
return out.toOwnedSlice(self.gpa);
|
|
}
|
|
|
|
/// 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.
|
|
/// Called at the end of a write command's in-lock section: when the
|
|
/// garbage share crosses the threshold, record that a compaction is
|
|
/// wanted. It runs in the command epilogue, after the collection lock is
|
|
/// released — never inline, since compact takes the collection locks
|
|
/// itself and would deadlock against the caller's.
|
|
/// Arm a checkpoint when the log has grown past the threshold. Cheap enough
|
|
/// to call on every write: one relaxed load and a compare.
|
|
fn note_checkpoint(self: *Engine) void {
|
|
if (self.log.log_bytes < self.checkpoint_threshold) return;
|
|
self.checkpoint_pending.store(true, .release);
|
|
}
|
|
|
|
/// Claim a pending checkpoint, if there is one.
|
|
pub fn take_checkpoint(self: *Engine) bool {
|
|
return self.checkpoint_pending.swap(false, .acq_rel);
|
|
}
|
|
|
|
fn note_compact(self: *Engine) void {
|
|
// Garbage is measured in the *data file*, not the log. This used to read
|
|
// `log.data_bytes`, which was the right question while the log was the
|
|
// only copy of the data -- but a checkpoint now truncates the log and
|
|
// `truncate_to_header` resets that counter, so the first gate stopped
|
|
// being reachable and compaction silently never fired again. The churn
|
|
// gate caught it: 50% churn over three rounds left the data file at 4.1x
|
|
// the live data, with a trigger that had been dead since the log started
|
|
// being reclaimed.
|
|
//
|
|
// One snapshot, because the second gate is a ratio: reading the two
|
|
// totals separately compares a live figure from one moment against a
|
|
// dead figure from another, and under concurrent writers that is how a
|
|
// rebuild fires on a database that does not want one.
|
|
const c = self.counters();
|
|
// Absolute volume first: a rewrite costs a full copy of the live data,
|
|
// so it is not worth doing for a few kilobytes however bad the ratio.
|
|
if (c.dead_bytes < self.compact_threshold) return;
|
|
// Then the share, dead / (live + dead), firing at ~20%: the file stays
|
|
// near 1.25x the live data and each rebuild is paid for by the space it
|
|
// reclaims. Bytes rather than document counts, because a rewrite copies
|
|
// bytes -- 100k evicted 40 B documents are not worth the same rebuild as
|
|
// 100k evicted 16 KiB ones.
|
|
if (c.dead_bytes * 4 < c.live_bytes) return;
|
|
self.compact_pending.store(true, .release);
|
|
}
|
|
|
|
/// Whether a compaction is wanted; clears the flag atomically so only one
|
|
/// of several concurrent writers takes the request. `compact` excludes
|
|
/// itself besides, so a caller that wins here still yields to a rewrite
|
|
/// already in progress.
|
|
pub fn take_compact(self: *Engine) bool {
|
|
return self.compact_pending.swap(false, .acq_rel);
|
|
}
|
|
|
|
/// Re-arm the compaction request. For a caller that took the request but
|
|
/// could not carry it out (a failed or abandoned rewrite), so the garbage
|
|
/// is reconsidered by a later, quieter epilogue instead of being forgotten.
|
|
pub fn request_compact(self: *Engine) void {
|
|
self.compact_pending.store(true, .release);
|
|
}
|
|
|
|
/// Rewrite the log with only live documents, atomically swapping the
|
|
/// file. Runs outside every collection lock (called from a write
|
|
/// command's epilogue). The snapshot takes the collection locks one at
|
|
/// a time without the log lock — so a concurrent writer can always
|
|
/// finish its append — then takes the log lock and retries until no
|
|
/// writer appended during the snapshot (detected via the record seq),
|
|
/// which makes the snapshot consistent with what the log contains.
|
|
///
|
|
/// Only one compaction runs at a time; a second caller returns immediately.
|
|
/// One document's new home during a rebuild: its docs-map key and the offset
|
|
/// it was copied to. Named rather than anonymous so the rebuild and the
|
|
/// repack agree on the type.
|
|
const Moved = struct { off: u64 };
|
|
|
|
/// Reclaim what a checkpoint cannot: dead document bytes and abandoned
|
|
/// pages.
|
|
///
|
|
/// A checkpoint publishes the structures where they already are. It cannot
|
|
/// move a document, because every index leaf holds that document's physical
|
|
/// offset -- so reclaiming a replaced document's bytes means rewriting the
|
|
/// documents *and* every index that points at them, together, which is what
|
|
/// this does.
|
|
///
|
|
/// It used to rewrite the *log* instead: re-emit every live document into a
|
|
/// fresh log and rename it over the old one. That is now the wrong shape
|
|
/// twice over. The log is no longer where the data lives, and a re-emitted
|
|
/// record carries a sequence that a later watermark can cover, which would
|
|
/// make the next open skip it (PLAN section 4). The log is simply truncated
|
|
/// by the checkpoint at the end.
|
|
pub fn compact(self: *Engine) !void {
|
|
// Claim it, or leave it to the one already running. A caller that loses
|
|
// this race has nothing to do: the winner's rebuild covers its garbage.
|
|
if (self.compacting.swap(true, .acq_rel)) return;
|
|
defer self.compacting.store(false, .release);
|
|
|
|
// Reclamation first, because it is the cheap half of the same job: a
|
|
// checkpoint hands back whole windows for the cost of one publish,
|
|
// where a rebuild copies every live byte in the database. Whatever it
|
|
// takes, the per-collection gate below no longer sees, so a collection
|
|
// whose garbage was all in empty windows is not rewritten at all.
|
|
//
|
|
// This is not a refinement, it is what makes reclamation reachable
|
|
// under a delete-heavy workload. A checkpoint is otherwise armed by log
|
|
// volume, and a delete logs only an `_id` -- so deleting half a 190 MB
|
|
// collection moves the log by a couple of megabytes and no checkpoint
|
|
// runs, while the garbage sails past the rebuild threshold. Measured
|
|
// with the churn harness: six rounds, six rebuilds, 1 MB reclaimed.
|
|
try self.checkpoint();
|
|
|
|
try self.catalog_lock.lockShared(self.io);
|
|
var rebuild_err: ?anyerror = null;
|
|
var db_it = self.dbs.iterator();
|
|
outer: while (db_it.next()) |db_entry| {
|
|
var coll_it = db_entry.value_ptr.collections.iterator();
|
|
while (coll_it.next()) |coll_entry| {
|
|
self.rebuild_collection(coll_entry.value_ptr.*) catch |err| {
|
|
rebuild_err = err;
|
|
break :outer;
|
|
};
|
|
}
|
|
}
|
|
// Before releasing the catalog, and whether the walk finished or gave up
|
|
// part way: a rebuild resets a collection's totals, so an incremental
|
|
// `dead_bytes` now describes collections that no longer exist in that
|
|
// shape. It used to be zeroed here, which was true only if a repack
|
|
// leaves nothing behind -- it does not. A checkpoint landing mid-rebuild
|
|
// forces the copy's own append cursor up to a system page, and those
|
|
// skipped bytes are as dead as the ones being reclaimed.
|
|
const dead_after = self.sum_dead_bytes();
|
|
self.catalog_lock.unlockShared(self.io);
|
|
self.counter_lock.lockUncancelable(self.io);
|
|
self.dead_bytes = dead_after;
|
|
self.dead_docs = 0;
|
|
self.counter_lock.unlock(self.io);
|
|
if (rebuild_err) |err| return err;
|
|
|
|
// Publish the rebuilt layout, which is also what reclaims the log. Until
|
|
// this lands the old pages are still referenced by the previous
|
|
// watermark, so a crash mid-rebuild simply loses the rebuild.
|
|
try self.checkpoint();
|
|
// And again, to walk the pages the rebuild just abandoned the rest of the
|
|
// way down the free list: one publish moves them from `pending` to
|
|
// `hold`, a second from `hold` to `ready`. Without this the space a
|
|
// rebuild reclaims is not reusable until two unrelated checkpoints have
|
|
// happened, so the next rebuild grows the file instead of reusing it --
|
|
// measured as ~1.2x of extra steady-state size under sustained churn.
|
|
//
|
|
// Safe for the same reason the delay exists: what the second publish
|
|
// releases is the pages the *pre-rebuild* image referenced, and that
|
|
// image is no longer the fallback -- the first publish made the rebuilt
|
|
// one current and the one before it the fallback. Both remain intact.
|
|
try self.checkpoint();
|
|
}
|
|
|
|
/// Whether rewriting this collection would pay for itself. The caller holds
|
|
/// its lock.
|
|
///
|
|
/// A rebuild copies a collection's live bytes to reclaim its dead ones, so
|
|
/// the one thing it must not do is copy a collection that has none. It used
|
|
/// to: `compact` walked every collection unconditionally, so garbage in one
|
|
/// paid for a full copy of the other thirty-nine.
|
|
///
|
|
/// The share is the same one `note_compact` applies to the engine's totals,
|
|
/// and that is what keeps the two from disagreeing. If no collection passes
|
|
/// this test then `dead_i < live_i / 4` for every one of them, so
|
|
/// `sum(dead) < sum(live) / 4` and the engine's trigger could not have fired
|
|
/// either. So a compaction that runs always rebuilds at least one
|
|
/// collection, and cannot spin re-arming itself over garbage no rebuild will
|
|
/// take. An absolute floor per collection would break exactly that: forty
|
|
/// collections each under the floor can sum to well over it.
|
|
fn wants_rebuild(coll: *const Collection) bool {
|
|
assert_msg(
|
|
coll.slab_used >= coll.live_bytes,
|
|
"a collection cannot hold more live bytes than it ever appended",
|
|
);
|
|
const dead = coll.slab_used - coll.live_bytes;
|
|
if (dead == 0) return false;
|
|
return dead * 4 >= coll.live_bytes;
|
|
}
|
|
|
|
/// Copy one collection's live documents into fresh extents and rebuild every
|
|
/// index against the new offsets.
|
|
///
|
|
/// Documents and indexes have to move together: an index leaf holds a
|
|
/// physical offset, so a document that moves without its indexes being
|
|
/// rebuilt is a stale entry pointing at whatever now occupies those bytes.
|
|
///
|
|
fn rebuild_collection(self: *Engine, coll: *Collection) !void {
|
|
try coll.lock.lock(self.io);
|
|
defer coll.lock.unlock(self.io);
|
|
if (!wants_rebuild(coll)) return;
|
|
|
|
var old_extents = try self.gpa.alloc(pgr.Extent, coll.slab_runs.items.len);
|
|
defer self.gpa.free(old_extents);
|
|
for (coll.slab_runs.items, 0..) |r, i| old_extents[i] = .{ .first = r.first, .pages = r.pages };
|
|
|
|
// Fresh slab. The old extents stay allocated until the free list
|
|
// releases them, two generations on.
|
|
coll.free_runs(self.gpa);
|
|
coll.slab_tail = 0;
|
|
coll.slab_end = 0;
|
|
coll.slab_used = 0;
|
|
coll.live_bytes = 0;
|
|
// The rebuild is the one place the located and unlocated halves are
|
|
// both reset: every byte it copies is live, so a fresh slab has no
|
|
// garbage to place. Anything the copy skips is marked as it happens.
|
|
coll.dead_unlocated = 0;
|
|
|
|
// Walk in _id order, which is also the order the new slab ends up in --
|
|
// so a later scan reads it sequentially.
|
|
var moved: std.ArrayListUnmanaged(Moved) = .empty;
|
|
defer moved.deinit(self.gpa);
|
|
try moved.ensureTotalCapacity(self.gpa, @intCast(coll.doc_count));
|
|
|
|
// The `_id_` tree is the enumeration, and it is in key order -- so the
|
|
// new slab ends up ordered and a later scan reads it sequentially.
|
|
var it = coll.id_index.iter();
|
|
while (it.next()) |entry| {
|
|
const bytes = doc_bytes_in(self.pager, entry.off);
|
|
// The skips are dropped rather than charged: this collection's
|
|
// totals were reset above and `compact` recomputes the engine's from
|
|
// what the rebuild leaves behind.
|
|
_ = try coll.slab_reserve(self.gpa, bytes.len);
|
|
const appended = coll.slab_append(bytes);
|
|
self.pager.release_reservation(&coll.hold);
|
|
try moved.append(self.gpa, .{ .off = appended.off });
|
|
}
|
|
// Republish the offsets.
|
|
|
|
// Rebuild every index from the new offsets, bulk-packed.
|
|
try self.repack_index(coll, &coll.id_index, moved.items);
|
|
for (coll.indexes.items) |ix| try self.repack_index(coll, ix, moved.items);
|
|
|
|
for (old_extents) |e| try self.pager.free_pages(e.first, e.pages);
|
|
|
|
// Every document has moved, so every offset an open cursor is holding
|
|
// now names different bytes. Bumped last, after the rebuild can no
|
|
// longer fail: a cursor invalidated by a rebuild that then errored out
|
|
// would have been invalidated for nothing.
|
|
self.layout_epoch_seq += 1;
|
|
coll.layout_epoch = self.layout_epoch_seq;
|
|
self.counter_lock.lockUncancelable(self.io);
|
|
self.compactions += 1;
|
|
self.counter_lock.unlock(self.io);
|
|
}
|
|
|
|
/// What the slab is doing, for `serverStatus`. Collections under the same
|
|
/// catalog-then-collection order everything else uses.
|
|
///
|
|
/// It exists because the milestone's own gate cannot be read without it. A
|
|
/// steady-state size ratio can look healthy while reclamation does nothing
|
|
/// -- the file grows, a rebuild periodically halves it, and the average
|
|
/// comes out fine. `reclaimed_bytes` climbing while `alloc_tail` stays put
|
|
/// is the shape that says the free list is load-bearing; either one alone
|
|
/// says very little.
|
|
pub const SlabStats = struct {
|
|
live_bytes: u64 = 0,
|
|
dead_bytes: u64 = 0,
|
|
slab_bytes: u64 = 0,
|
|
reclaimed_bytes: u64 = 0,
|
|
slab_runs: u64 = 0,
|
|
free_ready_pages: u32 = 0,
|
|
alloc_tail: u32 = 0,
|
|
compactions: u64 = 0,
|
|
};
|
|
|
|
pub fn slab_stats(self: *Engine) SlabStats {
|
|
var out: SlabStats = .{};
|
|
self.catalog_lock.lockSharedUncancelable(self.io);
|
|
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()) |ce| {
|
|
const coll = ce.value_ptr.*;
|
|
coll.lock.lockSharedUncancelable(self.io);
|
|
defer coll.lock.unlockShared(self.io);
|
|
out.live_bytes += coll.live_bytes;
|
|
out.slab_bytes += coll.slab_used;
|
|
out.reclaimed_bytes += coll.reclaimed_bytes;
|
|
out.slab_runs += coll.slab_runs.items.len;
|
|
}
|
|
}
|
|
self.catalog_lock.unlockShared(self.io);
|
|
// From the collections rather than the engine's running total, so this
|
|
// is the same figure `write_catalog` asserts against rather than a
|
|
// second opinion about it.
|
|
assert_msg(out.slab_bytes >= out.live_bytes, "the slab cannot hold more live bytes than it has");
|
|
out.dead_bytes = out.slab_bytes - out.live_bytes;
|
|
out.free_ready_pages = self.pager.free_ready_pages();
|
|
out.alloc_tail = self.pager.alloc_tail;
|
|
self.counter_lock.lockUncancelable(self.io);
|
|
out.compactions = self.compactions;
|
|
self.counter_lock.unlock(self.io);
|
|
return out;
|
|
}
|
|
|
|
/// The engine's dead-byte total, recomputed from the collections that
|
|
/// exist. Everywhere else the counter is incremental; a rebuild is the one
|
|
/// place that has to reset it, and the answer after a rebuild is not zero --
|
|
/// the copying skips slab of its own whenever a checkpoint lands mid-walk.
|
|
///
|
|
/// Each collection is read under its own lock, catalog then collection: the
|
|
/// order `compact` and `write_catalog` both use. Uncancelable, because the
|
|
/// caller has already rewritten the collections and the counter describing
|
|
/// them cannot be left behind.
|
|
fn sum_dead_bytes(self: *Engine) u64 {
|
|
var sum: u64 = 0;
|
|
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()) |ce| {
|
|
const coll = ce.value_ptr.*;
|
|
coll.lock.lockSharedUncancelable(self.io);
|
|
defer coll.lock.unlockShared(self.io);
|
|
assert_msg(
|
|
coll.slab_used >= coll.live_bytes,
|
|
"a collection cannot hold more live bytes than it ever appended",
|
|
);
|
|
sum += coll.slab_used - coll.live_bytes;
|
|
}
|
|
}
|
|
return sum;
|
|
}
|
|
|
|
fn repack_index(
|
|
self: *Engine,
|
|
coll: *Collection,
|
|
ix: *index.Index,
|
|
moved: []const Moved,
|
|
) !void {
|
|
_ = coll;
|
|
ix.reset_tree(self.gpa) catch |err| return err;
|
|
for (moved) |m| {
|
|
ix.append_doc_entries(self.gpa, doc_bytes_in(self.pager, m.off), m.off) catch |err| switch (err) {
|
|
error.ParallelArrays => continue,
|
|
else => return err,
|
|
};
|
|
}
|
|
// Duplicates are tolerated here for the same reason they are on open:
|
|
// refusing would make a maintenance task able to take the database down.
|
|
_ = ix.finish_bulk(self.gpa, false) catch |err| return err;
|
|
self.pager.release_reservation(&ix.hold);
|
|
}
|
|
|
|
/// Re-emit one collection's index specs and documents into the compacted
|
|
/// log, under the collection's write lock (released on every return
|
|
/// path, including errors).
|
|
/// 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.
|
|
/// Every document is reachable through every index that is supposed to cover
|
|
/// it, checked once at the end of an open.
|
|
///
|
|
/// An index that is merely *incomplete* is the worst failure this engine can
|
|
/// have, because nothing reports it: the index only generates candidates and
|
|
/// the full filter is re-applied to those, so a missing entry is a missing
|
|
/// query result and every other check still passes. That is exactly how the
|
|
/// replay-time `createIndex` bug survived -- `countDocuments` was right,
|
|
/// `find({})` was right, and only `find({k: v})` was quietly short.
|
|
///
|
|
/// `_id_` is exact: one entry per document, always. A secondary index is
|
|
/// checked only when its shape makes the count exact -- `sparse` omits
|
|
/// documents missing the key, and `multikey` contributes several entries for
|
|
/// one document -- so those are compared as a lower bound instead of an
|
|
/// equality. Debug and ReleaseSafe only; an open is not a hot path, but a
|
|
/// full index walk per collection is not free either.
|
|
fn assert_indexes_cover_every_document(self: *Engine) void {
|
|
if (builtin.mode == .ReleaseFast or builtin.mode == .ReleaseSmall) return;
|
|
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| {
|
|
const coll = coll_entry.value_ptr.*;
|
|
assert_msg(
|
|
coll.id_index.count() == coll.doc_count,
|
|
"the _id_ index must hold exactly one entry per document after an open",
|
|
);
|
|
assert_msg(
|
|
coll.id_index.unreachable_key_count() == 0,
|
|
"every _id_ entry must be findable by descent, not only by iteration",
|
|
);
|
|
for (coll.indexes.items) |ix| {
|
|
// Reachability applies to every index whatever its shape: an
|
|
// entry in the leaf chain that a descent cannot find is a
|
|
// query result that silently goes missing.
|
|
if (ix.unreachable_key_count() != 0) {
|
|
ix.dbg_root();
|
|
@panic("unreachable index entries");
|
|
}
|
|
if (ix.sparse or ix.multikey) continue;
|
|
assert_msg(
|
|
ix.count() >= coll.doc_count,
|
|
"a non-sparse index must cover every document after an open",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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| {
|
|
try self.rebuild_index(coll_entry.value_ptr.*, ix);
|
|
}
|
|
try self.rebuild_index(coll_entry.value_ptr.*, &coll_entry.value_ptr.*.id_index);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Rebuild one index from the live documents. Runs after replay, so it
|
|
/// is order-independent; indexes already holding entries (maintained
|
|
/// live) are skipped defensively. 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 rebuild_index(self: *Engine, coll: *Collection, ix: *index.Index) !void {
|
|
if (ix.count() > 0) return; // defensive
|
|
// The `_id_` tree is the enumeration of live documents now. It is also
|
|
// ordered, so this reads the slab sequentially where the hashmap read it
|
|
// in hash order.
|
|
var doc_it = coll.id_index.iter();
|
|
while (doc_it.next()) |entry| {
|
|
ix.append_doc_entries(self.gpa, coll.doc_bytes(entry.off), entry.off) catch |err| switch (err) {
|
|
error.ParallelArrays => {
|
|
std.debug.print(
|
|
"multiforadb: 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.
|
|
defer self.pager.release_reservation(&ix.hold);
|
|
if (try ix.finish_bulk(self.gpa, false)) {
|
|
std.debug.print(
|
|
"multiforadb: WARNING: unique index '{s}' has duplicate keys in existing " ++
|
|
"data; duplicates not enforced for existing documents\n",
|
|
.{
|
|
ix.name,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
// -- checkpoint ---------------------------------------------------------
|
|
|
|
/// The catalog: everything about where the engine's structures live that is
|
|
/// not recoverable by looking at the pages themselves.
|
|
///
|
|
/// Written wholesale into freshly allocated pages at every checkpoint, never
|
|
/// mutated in place, so the previous copy stays intact and referenced by the
|
|
/// previous watermark until the new one switches over. That is what makes it
|
|
/// untearable, and it is why there is no incremental catalog update path.
|
|
///
|
|
/// Format (little-endian throughout):
|
|
/// u32 magic "MFCT", u32 version, u64 live_docs, u32 db_count
|
|
/// per db: u32 name_len, name, u32 coll_count
|
|
/// per coll: u32 name_len, name, u64 slab_tail, u64 slab_end,
|
|
/// u32 extent_count, (u32 first, u32 pages)*, u32 index_count
|
|
/// index 0 is always the implicit _id_
|
|
/// per index: u32 name_len, name, u32 key_count,
|
|
/// (u32 path_len, path, u8 descending)*,
|
|
/// u8 flags(unique|sparse|multikey|has_ttl), i64 ttl,
|
|
/// u32 root, u32 first_leaf, u32 leaf_count, u32 depth,
|
|
/// u64 entry_count, u64 ovf_tail, u64 ovf_end,
|
|
/// u32 ovf_extent_count, (u32 first, u32 pages)*,
|
|
/// u32 node_count, (u32 page)*
|
|
/// u64 xxhash3 over everything above
|
|
const catalog_magic: u32 = 0x4D464354; // "MFCT"
|
|
const catalog_version: u32 = 1;
|
|
|
|
/// What a catalog walk observed, for the caller to check the engine's own
|
|
/// running totals against.
|
|
const CatalogSums = struct { live: u64, dead: u64 };
|
|
|
|
/// Serialize the catalog and return the byte totals it observed.
|
|
///
|
|
/// The engine's own totals are by definition the sums over collections, and
|
|
/// `read_catalog` rebuilds them that way, so a divergence means some path
|
|
/// published or evicted bytes at one level and not the other -- with a
|
|
/// compaction trigger that fires never or always as the visible symptom.
|
|
/// The check is worth making and this is where every collection is walked
|
|
/// anyway, but it cannot be made *here*: the sums are accumulated across
|
|
/// collections over time while the engine's totals move under them, so a
|
|
/// writer landing mid-walk would trip it on a database that is perfectly
|
|
/// consistent. The caller asserts them after the `seq` check has established
|
|
/// that no writer landed at all.
|
|
fn write_catalog(self: *Engine, out: *std.ArrayListUnmanaged(u8)) !CatalogSums {
|
|
const gpa = self.gpa;
|
|
var live_sum: u64 = 0;
|
|
var dead_sum: u64 = 0;
|
|
try put_u32(gpa, out, catalog_magic);
|
|
try put_u32(gpa, out, catalog_version);
|
|
try put_u64(gpa, out, self.counters().live_docs);
|
|
try put_u32(gpa, out, @intCast(self.dbs.count()));
|
|
var db_it = self.dbs.iterator();
|
|
while (db_it.next()) |db_entry| {
|
|
try put_bytes(gpa, out, db_entry.key_ptr.*);
|
|
const colls = &db_entry.value_ptr.collections;
|
|
try put_u32(gpa, out, @intCast(colls.count()));
|
|
var coll_it = colls.iterator();
|
|
while (coll_it.next()) |ce| {
|
|
const coll = ce.value_ptr.*;
|
|
// Everything below this line is written by a collection's own
|
|
// writer under its own lock, and `slab_runs` is an ArrayList
|
|
// that `slab_reserve` appends to -- so reading it under only the
|
|
// shared catalog lock could walk a slice a concurrent append had
|
|
// already reallocated. Lock order is catalog then collection,
|
|
// the same order `compact` uses.
|
|
try coll.lock.lockShared(self.io);
|
|
defer coll.lock.unlockShared(self.io);
|
|
try put_bytes(gpa, out, ce.key_ptr.*);
|
|
try put_u64(gpa, out, coll.slab_tail);
|
|
try put_u64(gpa, out, coll.slab_end);
|
|
try put_u64(gpa, out, coll.slab_used);
|
|
try put_u64(gpa, out, coll.live_bytes);
|
|
live_sum += coll.live_bytes;
|
|
assert_msg(
|
|
coll.live_bytes <= coll.slab_used,
|
|
"a collection cannot hold more live bytes than it ever appended",
|
|
);
|
|
dead_sum += coll.slab_used - coll.live_bytes;
|
|
// Runs, not extents, but the same two u32s: only the window map
|
|
// is new and it is deliberately not persisted (see
|
|
// `dead_unlocated`), so `catalog_version` stays 1 and there is
|
|
// no second read path to keep working.
|
|
try put_u32(gpa, out, @intCast(coll.slab_runs.items.len));
|
|
for (coll.slab_runs.items) |r| {
|
|
try put_u32(gpa, out, r.first);
|
|
try put_u32(gpa, out, r.pages);
|
|
}
|
|
try put_u32(gpa, out, @intCast(coll.indexes.items.len + 1));
|
|
try write_index_catalog(gpa, out, &coll.id_index);
|
|
for (coll.indexes.items) |ix| try write_index_catalog(gpa, out, ix);
|
|
}
|
|
}
|
|
try put_u64(gpa, out, std.hash.XxHash3.hash(0, out.items));
|
|
return .{ .live = live_sum, .dead = dead_sum };
|
|
}
|
|
|
|
fn write_index_catalog(
|
|
gpa: std.mem.Allocator,
|
|
out: *std.ArrayListUnmanaged(u8),
|
|
ix: *const index.Index,
|
|
) !void {
|
|
try put_bytes(gpa, out, ix.name);
|
|
try put_u32(gpa, out, @intCast(ix.keys.len));
|
|
for (ix.keys) |k| {
|
|
try put_bytes(gpa, out, k.path);
|
|
try out.append(gpa, @intFromBool(k.descending));
|
|
}
|
|
var flags: u8 = 0;
|
|
if (ix.unique) flags |= 1;
|
|
if (ix.sparse) flags |= 2;
|
|
if (ix.multikey) flags |= 4;
|
|
if (ix.ttl != null) flags |= 8;
|
|
try out.append(gpa, flags);
|
|
try put_u64(gpa, out, @bitCast(ix.ttl orelse 0));
|
|
try put_u32(gpa, out, ix.root);
|
|
try put_u32(gpa, out, ix.first_leaf);
|
|
try put_u32(gpa, out, ix.leaf_count);
|
|
try put_u32(gpa, out, ix.depth);
|
|
try put_u64(gpa, out, ix.entry_count);
|
|
try put_u64(gpa, out, ix.ovf_tail);
|
|
try put_u64(gpa, out, ix.ovf_end);
|
|
try put_u32(gpa, out, @intCast(ix.ovf_extents.items.len));
|
|
for (ix.ovf_extents.items) |e| {
|
|
try put_u32(gpa, out, e.first);
|
|
try put_u32(gpa, out, e.pages);
|
|
}
|
|
try put_u32(gpa, out, @intCast(ix.node_pages.items.len));
|
|
for (ix.node_pages.items) |pg| try put_u32(gpa, out, pg);
|
|
}
|
|
|
|
/// Rebuild the catalog from the data file. On any inconsistency this returns
|
|
/// an error and the caller falls back to a full replay.
|
|
fn read_catalog(self: *Engine) !void {
|
|
const wm = self.pager.loaded;
|
|
if (wm.catalog_len == 0) return error.NoCatalog;
|
|
const buf = self.pager.bytes(@as(u64, wm.catalog_page) << pgr.page_shift, @intCast(wm.catalog_len));
|
|
if (buf.len < 8) return error.CorruptCatalog;
|
|
const body = buf[0 .. buf.len - 8];
|
|
if (std.hash.XxHash3.hash(0, body) != std.mem.readInt(u64, buf[buf.len - 8 ..][0..8], .little)) {
|
|
return error.CorruptCatalog;
|
|
}
|
|
var r: Reader = .{ .b = body };
|
|
if (try r.read_u32() != catalog_magic) return error.CorruptCatalog;
|
|
if (try r.read_u32() != catalog_version) return error.CorruptCatalog;
|
|
self.live_docs = try r.read_u64();
|
|
const ndbs = try r.read_u32();
|
|
var d: u32 = 0;
|
|
while (d < ndbs) : (d += 1) {
|
|
const db_name = try r.read_bytes();
|
|
const ncolls = try r.read_u32();
|
|
var c: u32 = 0;
|
|
while (c < ncolls) : (c += 1) {
|
|
const coll_name = try r.read_bytes();
|
|
const coll = try self.get_or_create_collection(db_name, coll_name);
|
|
coll.slab_tail = try r.read_u64();
|
|
coll.slab_end = try r.read_u64();
|
|
coll.slab_used = try r.read_u64();
|
|
coll.live_bytes = try r.read_u64();
|
|
// Both engine totals are sums over collections rather than
|
|
// separately stored fields, so neither can disagree with the
|
|
// catalog. `slab_used - live_bytes` is this collection's slab
|
|
// garbage by definition -- bytes it appended and no longer
|
|
// reaches -- which is exactly what the rebuild trigger counts.
|
|
if (coll.slab_used < coll.live_bytes) return error.CorruptCatalog;
|
|
self.live_bytes += coll.live_bytes;
|
|
self.dead_bytes += coll.slab_used - coll.live_bytes;
|
|
// An open knows *that* the collection has garbage but not
|
|
// *where*: the window map is rebuilt empty, and the whole
|
|
// amount starts out unlocated. The consequence runs one way --
|
|
// a forgotten dead byte is a window that is not handed back,
|
|
// never a live window that is.
|
|
coll.dead_unlocated = coll.slab_used - coll.live_bytes;
|
|
const nex = try r.read_u32();
|
|
var e: u32 = 0;
|
|
while (e < nex) : (e += 1) {
|
|
const first = try r.read_u32();
|
|
const pages = try r.read_u32();
|
|
// Sorted insert rather than append: a catalog written
|
|
// before runs were address-ordered holds them in
|
|
// allocation order, and `run_of` is a binary search.
|
|
try coll.insert_run(self.gpa, first, pages);
|
|
}
|
|
const nix = try r.read_u32();
|
|
// Index 0 is the implicit _id_, already created by
|
|
// get_or_create_collection; the rest are registered here.
|
|
try read_index_catalog(self.gpa, &r, &coll.id_index);
|
|
var i: u32 = 1;
|
|
while (i < nix) : (i += 1) {
|
|
const boxed = try self.gpa.create(index.Index);
|
|
errdefer self.gpa.destroy(boxed);
|
|
boxed.* = try index.Index.init(self.gpa, self.pager, "", &.{}, false, false, null);
|
|
read_index_catalog(self.gpa, &r, boxed) catch |err| {
|
|
boxed.deinit(self.gpa);
|
|
self.gpa.destroy(boxed);
|
|
return err;
|
|
};
|
|
try coll.indexes.append(self.gpa, boxed);
|
|
}
|
|
// Nothing else to rebuild. The `_id_` tree *is* the lookup, and
|
|
// it is already in the file -- which is the whole reason an open
|
|
// no longer touches a single document page. The version of this
|
|
// that kept a hashmap had to read every document here to recover
|
|
// its `_id`, and that alone faulted the entire database in.
|
|
coll.doc_count = coll.id_index.count();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Replace an index's identity and tree position from the catalog. The index
|
|
/// arrives freshly initialised, so its own two starting pages are discarded
|
|
/// in favour of what was published.
|
|
fn read_index_catalog(
|
|
gpa: std.mem.Allocator,
|
|
r: *Reader,
|
|
ix: *index.Index,
|
|
) !void {
|
|
const name = try r.read_bytes();
|
|
const nkeys = try r.read_u32();
|
|
if (nkeys == 0 or nkeys > index.max_index_keys) return error.CorruptCatalog;
|
|
var keys = try gpa.alloc(index.IndexKey, nkeys);
|
|
var built: usize = 0;
|
|
errdefer {
|
|
for (keys[0..built]) |k| gpa.free(k.path);
|
|
gpa.free(keys);
|
|
}
|
|
while (built < nkeys) : (built += 1) {
|
|
const path = try r.read_bytes();
|
|
keys[built] = .{ .path = try gpa.dupe(u8, path), .descending = (try r.read_byte()) != 0 };
|
|
}
|
|
const flags = try r.read_byte();
|
|
const ttl_raw: i64 = @bitCast(try r.read_u64());
|
|
|
|
const new_name = try gpa.dupe(u8, name);
|
|
errdefer gpa.free(new_name);
|
|
|
|
// Swap in the published identity, freeing what init made.
|
|
for (ix.keys) |k| gpa.free(k.path);
|
|
gpa.free(ix.keys);
|
|
gpa.free(ix.name);
|
|
ix.name = new_name;
|
|
ix.keys = keys;
|
|
ix.unique = flags & 1 != 0;
|
|
ix.sparse = flags & 2 != 0;
|
|
ix.multikey = flags & 4 != 0;
|
|
ix.ttl = if (flags & 8 != 0) ttl_raw else null;
|
|
ix.root = try r.read_u32();
|
|
ix.first_leaf = try r.read_u32();
|
|
ix.leaf_count = try r.read_u32();
|
|
ix.depth = try r.read_u32();
|
|
ix.entry_count = @intCast(try r.read_u64());
|
|
ix.ovf_tail = try r.read_u64();
|
|
ix.ovf_end = try r.read_u64();
|
|
const novf = try r.read_u32();
|
|
var o: u32 = 0;
|
|
while (o < novf) : (o += 1) {
|
|
const first = try r.read_u32();
|
|
const pages = try r.read_u32();
|
|
try ix.ovf_extents.append(gpa, .{ .first = first, .pages = pages });
|
|
}
|
|
const nnodes = try r.read_u32();
|
|
if (nnodes < 2) return error.CorruptCatalog;
|
|
ix.node_pages.clearRetainingCapacity();
|
|
try ix.node_pages.ensureTotalCapacity(gpa, nnodes);
|
|
var n: u32 = 0;
|
|
while (n < nnodes) : (n += 1) ix.node_pages.appendAssumeCapacity(try r.read_u32());
|
|
}
|
|
|
|
/// Add a replayed document's entries to every index that is already
|
|
/// populated. Best effort and infallible: replay must not refuse to start,
|
|
/// and an index that cannot key this document is reported and left alone --
|
|
/// the same tolerance `rebuild_index` has always had.
|
|
fn index_doc_on_replay(self: *Engine, coll: *Collection, doc_bytes: []const u8, off: u64) void {
|
|
self.index_one(&coll.id_index, doc_bytes, off);
|
|
for (coll.indexes.items) |ix| self.index_one(ix, doc_bytes, off);
|
|
}
|
|
|
|
fn index_one(self: *Engine, ix: *index.Index, doc_bytes: []const u8, off: u64) void {
|
|
var built = ix.build_entries(self.gpa, doc_bytes) catch return;
|
|
defer built.deinit(self.gpa);
|
|
if (built.multikey) ix.multikey = true;
|
|
ix.reserve_for(self.gpa, built.entries.items) catch return;
|
|
ix.insert_entries(&built, off);
|
|
}
|
|
|
|
fn rebuild_docs_map(self: *Engine, coll: *Collection) !void {
|
|
var arena = std.heap.ArenaAllocator.init(self.gpa);
|
|
defer arena.deinit();
|
|
var it = coll.id_index.iter();
|
|
while (it.next()) |e| {
|
|
_ = arena.reset(.retain_capacity);
|
|
const id_value = (try bson.get_at(arena.allocator(), coll.doc_bytes(e.off), "_id")) orelse continue;
|
|
const id_key = try bson.serialize_value(self.gpa, id_value);
|
|
errdefer self.gpa.free(id_key);
|
|
try coll.docs.put(self.gpa, id_key, e.off);
|
|
}
|
|
}
|
|
|
|
/// Discard everything a failed catalog load put in place, so the caller can
|
|
/// replay the log into a clean engine.
|
|
fn reset_after_failed_catalog(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.clearRetainingCapacity();
|
|
self.live_docs = 0;
|
|
self.dead_docs = 0;
|
|
self.live_bytes = 0;
|
|
self.dead_bytes = 0;
|
|
self.seq = 0;
|
|
self.committed_seq = 0;
|
|
}
|
|
|
|
/// Hand back every slab window with nothing live left in it, across every
|
|
/// collection. The first phase of a checkpoint.
|
|
///
|
|
/// Inside the checkpoint rather than a hook after it, and that placement is
|
|
/// the whole safety argument. Reclamation changes two things: it splits
|
|
/// `slab_runs`, which the catalog describes, and it calls
|
|
/// `pager.free_pages`, which the free list describes. The checkpoint's
|
|
/// single `publish` makes both durable together, so a crash before it
|
|
/// leaves the old catalog and the old free list -- no reclamation happened
|
|
/// -- and a crash after leaves both describing the new ownership. There is
|
|
/// no order in between to get wrong, and no new record type or replay path.
|
|
///
|
|
/// Batched with the checkpoint for a second reason: the write path pays
|
|
/// only for a counter update, and the cadence of actually returning pages
|
|
/// is the checkpoint threshold rather than per-delete.
|
|
///
|
|
/// Catalog shared, then each collection exclusive, one at a time -- the
|
|
/// order `compact` and `write_catalog` both use.
|
|
fn reclaim_slabs(self: *Engine) void {
|
|
self.catalog_lock.lockSharedUncancelable(self.io);
|
|
defer self.catalog_lock.unlockShared(self.io);
|
|
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()) |ce| self.reclaim_collection(ce.value_ptr.*);
|
|
}
|
|
}
|
|
|
|
fn reclaim_collection(self: *Engine, coll: *Collection) void {
|
|
coll.lock.lockUncancelable(self.io);
|
|
defer coll.lock.unlock(self.io);
|
|
// The common case, and the reason this is affordable at every
|
|
// checkpoint: a collection with no full window is not scanned at all.
|
|
if (coll.full_windows == 0) return;
|
|
// Out of memory here means the garbage stays where it is. Nothing is
|
|
// lost and the next checkpoint tries again.
|
|
const freed = coll.reclaim_windows(self.gpa) catch return;
|
|
if (freed == 0) return;
|
|
self.counter_lock.lockUncancelable(self.io);
|
|
assert_msg(self.dead_bytes >= freed, "reclaiming more slab than the engine counts as dead");
|
|
self.dead_bytes -= freed;
|
|
self.counter_lock.unlock(self.io);
|
|
// A cursor holding slab offsets is now holding some that name pages
|
|
// this collection no longer owns -- and reading them would succeed,
|
|
// since the pages are only on the free list, so the answer would be
|
|
// plausible garbage rather than an error. `cursor_still_valid` compares
|
|
// this epoch and kills such a cursor with QueryPlanKilled, which is
|
|
// what a rebuild already does to it.
|
|
//
|
|
// Only when something was actually given back: a collection that
|
|
// reclaimed nothing must not have its cursors killed on the cadence of
|
|
// the checkpoint.
|
|
self.layout_epoch_seq += 1;
|
|
coll.layout_epoch = self.layout_epoch_seq;
|
|
}
|
|
|
|
/// Publish the current state as a checkpoint.
|
|
///
|
|
/// The watermark equals the sequence the log has already made durable, never
|
|
/// more: `commit` first, then snapshot, and the snapshot is validated against
|
|
/// an unchanged `seq` under the log lock -- the same bounded-retry shape
|
|
/// compaction has always used. That is the crash-recovery invariant (PLAN
|
|
/// D6) reduced to an ordering.
|
|
pub fn checkpoint(self: *Engine) !void {
|
|
try self.commit();
|
|
self.reclaim_slabs();
|
|
|
|
var buf: std.ArrayListUnmanaged(u8) = .empty;
|
|
defer buf.deinit(self.gpa);
|
|
|
|
var attempt: usize = 0;
|
|
const attempt_max = 8;
|
|
while (attempt < attempt_max) : (attempt += 1) {
|
|
buf.clearRetainingCapacity();
|
|
try self.catalog_lock.lockShared(self.io);
|
|
const snapshot_seq = self.seq;
|
|
const before = self.counters();
|
|
const sums = self.write_catalog(&buf) catch |err| {
|
|
self.catalog_lock.unlockShared(self.io);
|
|
return err;
|
|
};
|
|
self.catalog_lock.unlockShared(self.io);
|
|
|
|
try self.log_lock.lock(self.io);
|
|
if (self.seq != snapshot_seq) {
|
|
// A writer landed mid-snapshot; the catalog describes a state
|
|
// that no longer matches the log. Retry rather than publish it.
|
|
self.log_lock.unlock(self.io);
|
|
continue;
|
|
}
|
|
if (snapshot_seq > self.committed_seq) {
|
|
// A writer appended before the snapshot and its commit has not
|
|
// landed yet -- it is between `insert` and `commit`, or inside
|
|
// one, waiting on the leader's fsync. The seq check above does
|
|
// not catch this: nothing appended *during* the walk, the
|
|
// append was already there when it started.
|
|
//
|
|
// Publishing here would claim durability for a record that is
|
|
// still in the log's buffer, and the truncation that follows a
|
|
// checkpoint would then throw it away. That is the one thing
|
|
// the whole watermark ordering exists to prevent (PLAN D6), and
|
|
// it used to be an assertion -- so the failure mode was a
|
|
// server abort under exactly the load that makes checkpoints
|
|
// frequent. Reproduced in seconds by four writers against a
|
|
// checkpoint loop, and the window is as wide as an fsync.
|
|
//
|
|
// Seal it and take the snapshot again rather than spinning:
|
|
// `commit` covers every append made so far, so one more round
|
|
// is enough. Outside `log_lock`, which `commit` takes itself.
|
|
self.log_lock.unlock(self.io);
|
|
try self.commit();
|
|
continue;
|
|
}
|
|
// Only when the walk was quiet. An unchanged `seq` is not enough on
|
|
// its own: a writer bumps it when it appends the log record and
|
|
// updates the byte counters afterwards, so it can be past the seq
|
|
// the snapshot captured and still be about to move `live_bytes`
|
|
// under a collection the walk has already been through. Requiring
|
|
// the engine total to be unmoved across the whole walk closes that,
|
|
// at the cost of skipping the check under sustained writes -- which
|
|
// is the right trade, because what it guards against is a code path
|
|
// that updates one level and not the other, and that is
|
|
// deterministic wherever it exists.
|
|
//
|
|
// Both reads go through `counters`, which is the only way the
|
|
// comparison means anything: the sums were gathered under each
|
|
// collection's lock, which orders them against that collection's
|
|
// writer, and an unsynchronized read of the engine's own totals is
|
|
// ordered against nothing at all -- so it could return a figure from
|
|
// before a write the walk had already serialized, and the assertion
|
|
// would abort a server whose accounting was correct.
|
|
const after = self.counters();
|
|
if (after.live_bytes == before.live_bytes) assert_msg(
|
|
sums.live == before.live_bytes,
|
|
"the engine's live-byte total must equal the sum over collections",
|
|
);
|
|
// The same argument, for the total the rebuild trigger reads. This
|
|
// is what makes a drop's accounting checkable: charge the engine for
|
|
// a dropped collection's bytes and the two sides part company here.
|
|
if (after.dead_bytes == before.dead_bytes) assert_msg(
|
|
sums.dead == before.dead_bytes,
|
|
"the engine's dead-byte total must equal the sum over collections",
|
|
);
|
|
const pages: u32 = @intCast((buf.items.len + pgr.page_size - 1) / pgr.page_size);
|
|
const first = self.pager.alloc_pages(pages) catch |err| {
|
|
self.log_lock.unlock(self.io);
|
|
return err;
|
|
};
|
|
@memcpy(self.pager.bytes_mut(@as(u64, first) << pgr.page_shift, buf.items.len), buf.items);
|
|
// The invariant, on the line that would break it: `log_lock` has
|
|
// been held since the check above and `committed_seq` only grows.
|
|
assert_msg(
|
|
snapshot_seq <= self.committed_seq,
|
|
"checkpoint watermark past the durable log tail",
|
|
);
|
|
self.pager.publish(.{
|
|
.seq = snapshot_seq,
|
|
.catalog_page = first,
|
|
.catalog_len = buf.items.len,
|
|
.live_docs = after.live_docs,
|
|
.dead_bytes = after.dead_bytes,
|
|
}) catch |err| {
|
|
self.log_lock.unlock(self.io);
|
|
return err;
|
|
};
|
|
self.pager.release_reservation(&self.hold);
|
|
// The watermark is durable, so every record it covers is now
|
|
// redundant. Strictly after the publish: the other order loses data
|
|
// if a crash lands between them.
|
|
self.log.truncate_to_header() catch |err| {
|
|
// A failed truncation wastes space and costs replay time on the
|
|
// next open; it does not lose anything, because the records are
|
|
// still there and still above no watermark. Not worth failing
|
|
// the checkpoint that already succeeded.
|
|
std.debug.print("multiforadb: WARNING: log truncation failed: {s}\n", .{@errorName(err)});
|
|
};
|
|
self.committed_seq = snapshot_seq;
|
|
self.log_lock.unlock(self.io);
|
|
return;
|
|
}
|
|
std.debug.print("multiforadb: WARNING: checkpoint gave up after {d} attempts under sustained writes\n", .{attempt_max});
|
|
}
|
|
|
|
/// Register an (empty) index from a persisted spec document. A repeated
|
|
/// create record for the same name is an idempotent no-op.
|
|
/// Register an index from a logged spec. Returns the new index, or null when
|
|
/// one of that name was already present (a re-registration is a no-op, not an
|
|
/// error). The caller needs the pointer because an index registered during
|
|
/// replay may have to be built over documents that replay will never see.
|
|
fn register_index_from_spec(
|
|
self: *Engine,
|
|
coll: *Collection,
|
|
spec_doc: *const bson.Document,
|
|
) !?*index.Index {
|
|
const parsed = try index.parse_spec(self.gpa, self.pager, spec_doc);
|
|
const ix = self.gpa.create(index.Index) catch |err| {
|
|
var dead = parsed;
|
|
dead.deinit(self.gpa);
|
|
return err;
|
|
};
|
|
ix.* = parsed;
|
|
var committed = false;
|
|
defer if (!committed) {
|
|
ix.deinit(self.gpa);
|
|
self.gpa.destroy(ix);
|
|
};
|
|
if (coll.find_index(ix.name) != null) return null;
|
|
try coll.indexes.append(self.gpa, ix);
|
|
committed = true;
|
|
return ix;
|
|
}
|
|
};
|
|
|
|
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));
|
|
// 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;
|
|
|
|
// 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 => {
|
|
const registered = self.register_index_from_spec(coll, doc) catch |err| {
|
|
std.debug.print("multiforadb: index create record failed to apply: {s}\n", .{
|
|
@errorName(err),
|
|
});
|
|
return;
|
|
};
|
|
// A checkpointed open replays only what the watermark does not cover,
|
|
// so the documents already in the image never reach this index. Build
|
|
// it over them now, which is what the live `create_index` command
|
|
// does with pre-existing documents.
|
|
//
|
|
// Leaving it to `build_all_indexes` does not work and fails silently:
|
|
// the next upsert in the log puts one entry in, and a non-empty index
|
|
// is skipped by the `count() > 0` guard there -- so the index ends up
|
|
// holding the documents logged after its creation and none of the
|
|
// ones logged before, which is an index that under-approximates.
|
|
//
|
|
// Only for a maintaining replay. A full replay leaves every secondary
|
|
// index empty on purpose and `build_all_indexes` fills them in one
|
|
// pass at the end, which is cheaper than one pass per index here.
|
|
if (self.replay_maintains_indexes) {
|
|
if (registered) |ix| self.rebuild_index(coll, ix) catch |err| {
|
|
// The database must always open (ground rule 4). A failure
|
|
// here leaves the index short, so say so rather than leaving
|
|
// a query to be quietly wrong about it.
|
|
std.debug.print(
|
|
"multiforadb: WARNING: index '{s}' could not be built over existing " ++
|
|
"documents during replay: {s}; drop and re-create it\n",
|
|
.{ ix.name, @errorName(err) },
|
|
);
|
|
};
|
|
}
|
|
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("multiforadb: log record without _id, skipping\n", .{});
|
|
return;
|
|
};
|
|
var id_enc_list: std.ArrayListUnmanaged(u8) = .empty;
|
|
defer id_enc_list.deinit(self.gpa);
|
|
bson.encode_key(id_value, self.gpa, &id_enc_list) catch return;
|
|
const id_enc = id_enc_list.items;
|
|
|
|
// Engine.seq used to restart at 0 on every open, which was harmless while
|
|
// the log was always replayed in full and fatal the moment a watermark
|
|
// exists: the first append after an open would reuse a sequence at or below
|
|
// it, and the *next* open would discard that record as already-checkpointed.
|
|
self.seq = @max(self.seq, record.seq);
|
|
|
|
switch (record.type) {
|
|
storage.record_type_upsert => {
|
|
// A record that supersedes one already present. Worth reporting when
|
|
// the two `_id`s are not byte-identical, because that means the
|
|
// database predates `_id_` being canonical and held two documents
|
|
// whose ids compare equal -- int32 1 and int64 1, say. One of them is
|
|
// being dropped here, which is MongoDB's semantics but is also silent
|
|
// data loss for an existing file (PLAN amendment A4).
|
|
if (coll.id_index.lookup_exact(id_enc)) |old_off| {
|
|
warn_on_equal_id_collision(self.gpa, coll, old_off, doc, record);
|
|
}
|
|
self.evict_doc(coll, id_enc);
|
|
const doc_bytes = try serialize_doc(self.gpa, doc);
|
|
defer self.gpa.free(doc_bytes);
|
|
self.count_slab_skip(try coll.slab_reserve(self.gpa, doc_bytes.len));
|
|
const off = self.publish_doc_bytes(coll, doc_bytes);
|
|
coll.doc_count += 1;
|
|
// The `_id_` entry is added *now*, not after replay: it is the only
|
|
// way the next record can find this document to supersede it. The
|
|
// secondaries can still wait for the bulk build, unless this open
|
|
// came from a checkpoint that already populated them.
|
|
if (self.replay_maintains_indexes) {
|
|
self.index_doc_on_replay(coll, doc_bytes, off);
|
|
} else {
|
|
self.index_one(&coll.id_index, doc_bytes, off);
|
|
}
|
|
self.release_write_reservations(coll);
|
|
// The _id_ entry is added after replay, in build_all_indexes,
|
|
// together with the secondary indexes.
|
|
//
|
|
// Which is why making _id_ unique cannot lose a document here:
|
|
// eviction above goes through the docs map, keyed on
|
|
// serialize_value, so a database holding both {_id: int32 1} and
|
|
// {_id: int64 1} keeps both. The bulk build then finds duplicate
|
|
// canonical keys, tolerates them and warns (rule: the database
|
|
// must always open). The commit that drops the docs map is where
|
|
// that stops being true -- see PLAN amendment A4.
|
|
},
|
|
storage.record_type_delete => self.evict_doc(coll, id_enc),
|
|
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 id_key_for(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", (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();
|
|
}
|
|
|
|
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 engine.commit();
|
|
try testing.expectEqual(@as(u64, 0), engine.dead_docs);
|
|
// The compaction threshold counts data volume (uncompressed bytes), not
|
|
// the compressed on-disk size.
|
|
const after_insert = engine.log.data_bytes;
|
|
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);
|
|
}
|
|
try engine.commit();
|
|
if (engine.log.data_bytes >= after_insert * 2) break;
|
|
}
|
|
// The server runs compaction in a write command's epilogue; the tests
|
|
// drive it directly.
|
|
if (engine.take_compact()) try engine.compact();
|
|
try engine.commit();
|
|
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.data_bytes < after_insert * 2);
|
|
}
|
|
|
|
test "a secondary index stays reachable across checkpoints, churn and a rebuild" {
|
|
// The one path the index unit tests cannot reach: copy-on-write. `test_pager`
|
|
// never publishes a watermark, so `stable_pages` is 0 there and every page is
|
|
// writable in place -- no node page is ever relocated. Through the engine a
|
|
// checkpoint makes the whole image stable, so the next tree mutation copies
|
|
// each node it touches to a fresh page and rewrites the id->page slot.
|
|
//
|
|
// Few distinct keys on purpose: ten values over thousands of documents means
|
|
// each value spans many leaves and most interior separators are duplicates,
|
|
// which is the shape the crash fuzzer fails on.
|
|
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 = 64 * 1024; // rebuild often, like --heavy
|
|
try engine.lock();
|
|
defer engine.unlock();
|
|
|
|
var spec = try index_spec(gpa, "k", "k_1", false, false, null);
|
|
defer spec.deinit();
|
|
_ = try engine.create_index("app", "c", &spec);
|
|
|
|
const n_keys: i32 = 10;
|
|
const n: i32 = 1200;
|
|
var id: i32 = 0;
|
|
while (id < n) : (id += 1) {
|
|
var d = try make_keyed(gpa, id, @mod(id, n_keys));
|
|
defer d.deinit();
|
|
try engine.insert("app", "c", &d, &env.gen);
|
|
|
|
// Checkpoint, churn and rebuild interleaved with the writes, so tree
|
|
// mutations land on pages the last checkpoint froze.
|
|
if (@mod(id, 150) == 0) {
|
|
try engine.commit();
|
|
try engine.checkpoint();
|
|
}
|
|
if (@mod(id, 7) == 0 and id > 20) {
|
|
_ = try engine.remove_by_id("app", "c", .{ .int32 = id - 20 });
|
|
}
|
|
if (engine.take_compact()) try engine.compact();
|
|
}
|
|
try engine.commit();
|
|
try engine.checkpoint();
|
|
|
|
const coll = engine.get_collection("app", "c").?;
|
|
const ix = coll.find_index("k_1").?;
|
|
|
|
// Every entry the leaf chain holds must also be findable by descending from
|
|
// the root, which is the only way a query reaches it.
|
|
try testing.expectEqual(@as(u32, 0), ix.unreachable_key_count());
|
|
try testing.expectEqual(@as(u32, 0), coll.id_index.unreachable_key_count());
|
|
|
|
// And per key, the index must agree with a scan of the documents.
|
|
var k: i32 = 0;
|
|
while (k < n_keys) : (k += 1) {
|
|
var want: usize = 0;
|
|
var scan = coll.id_index.iter();
|
|
while (scan.next()) |e| {
|
|
const kv = try bson.get_at(gpa, coll.doc_bytes(e.off), "k");
|
|
if (kv) |v| if (v.int32 == k) {
|
|
want += 1;
|
|
};
|
|
}
|
|
var out: std.ArrayListUnmanaged(u64) = .empty;
|
|
defer out.deinit(gpa);
|
|
try ix.lookup_eq(gpa, &.{.{ .int32 = k }}, &out);
|
|
testing.expectEqual(want, out.items.len) catch |err| {
|
|
std.debug.print(" key {d}: index {d}, scan {d}, entry_count {d}\n", .{
|
|
k,
|
|
out.items.len,
|
|
want,
|
|
ix.count(),
|
|
});
|
|
return err;
|
|
};
|
|
}
|
|
}
|
|
|
|
test "an index created after the checkpoint indexes the documents that predate it" {
|
|
// Found by tests/fuzz/crash-fuzz.js in --heavy mode, roughly once per 700
|
|
// crash/reopen cycles, as `find({k:v})` returning nothing for a key that has
|
|
// documents. `countDocuments` and `find({})` were right, so the documents
|
|
// were there and only the index's answer about them was wrong -- an index
|
|
// that under-approximates, which is silent by construction: the index only
|
|
// generates candidates and the full filter is re-applied to those, so a
|
|
// missing entry is a missing result and nothing complains.
|
|
//
|
|
// The sequence needs three things at once: a checkpoint, a `createIndex`
|
|
// logged after it, and a write after that.
|
|
//
|
|
// 1. documents exist and a checkpoint puts them in the durable image
|
|
// 2. createIndex is logged *after* the watermark
|
|
// 3. another document is written, also after the watermark
|
|
//
|
|
// On reopen the catalog restores step 1's documents but not the index, so
|
|
// replay starts at the watermark and never sees them. Replay registers the
|
|
// index empty and -- because a checkpointed open maintains indexes as it
|
|
// replays -- step 3's document goes in. The index is now non-empty and
|
|
// incomplete, so `rebuild_index`'s `count() > 0` guard skips it and step 1's
|
|
// documents are never indexed at all.
|
|
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();
|
|
try engine.lock();
|
|
|
|
// 1. Two documents, made durable in the data file.
|
|
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);
|
|
try engine.commit();
|
|
try engine.checkpoint();
|
|
|
|
// 2. The index arrives after the watermark.
|
|
var spec = try index_spec(gpa, "email", "email_1", false, false, null);
|
|
defer spec.deinit();
|
|
_ = try engine.create_index("app", "users", &spec);
|
|
|
|
// 3. And a write after that, which is what makes the index non-empty on
|
|
// replay and so hides the two documents behind the `count() > 0` guard.
|
|
var d3 = try make_user(gpa, 3, "c@x.io");
|
|
defer d3.deinit();
|
|
try engine.insert("app", "users", &d3, &env.gen);
|
|
try engine.commit();
|
|
engine.unlock();
|
|
// No second checkpoint: the watermark still predates the createIndex.
|
|
}
|
|
|
|
var engine2 = try Engine.open(gpa, io, tmp.path);
|
|
defer engine2.deinit();
|
|
const coll = engine2.get_collection("app", "users").?;
|
|
const ix = coll.find_index("email_1").?;
|
|
|
|
// One entry per document. Under-approximation is the whole failure mode, so
|
|
// the count is the assertion that matters.
|
|
try testing.expectEqual(@as(u64, 3), coll.doc_count);
|
|
try testing.expectEqual(@as(usize, 3), ix.count());
|
|
|
|
// And every entry resolves to a document whose email re-encodes to its key,
|
|
// so the entries are the right ones and not merely the right number.
|
|
var seen: [3]bool = .{ false, false, false };
|
|
var it = ix.iter();
|
|
while (it.next()) |entry| {
|
|
const doc_id = (try bson.get_at(gpa, coll.doc_bytes(entry.off), "_id")).?;
|
|
const idx: usize = @intCast(doc_id.int32 - 1);
|
|
try testing.expect(idx < seen.len);
|
|
try testing.expect(!seen[idx]);
|
|
seen[idx] = true;
|
|
}
|
|
try testing.expect(seen[0] and seen[1] and seen[2]);
|
|
}
|
|
|
|
test "reopening without a checkpoint reuses the data file instead of appending to it" {
|
|
// Mutation check: delete the `loaded.generation == 0` reset of `alloc_tail`
|
|
// in `Pager.open`. Red -- each reopen starts allocating above the previous
|
|
// file end, so the file grows by a slab extent every time and no watermark
|
|
// exists to put the abandoned copy on a free list. Unbounded, and it needs no
|
|
// crash: a database too small to reach the checkpoint threshold never
|
|
// publishes a watermark, so every clean reopen took that path. Measured
|
|
// through the server at 20 documents per cycle: +17 MB per 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);
|
|
|
|
// Three rounds of open, write a little, close -- with no checkpoint, so the
|
|
// data file never gets a watermark and replay rebuilds everything each time.
|
|
var tails: [3]u32 = undefined;
|
|
for (0..3) |round| {
|
|
var engine = try Engine.open(gpa, io, tmp.path);
|
|
defer engine.deinit();
|
|
try testing.expectEqual(@as(u64, 0), engine.pager.loaded.generation);
|
|
try engine.lock();
|
|
for (0..5) |i| {
|
|
var d = try make_doc(gpa, @intCast(round * 10 + i), "x");
|
|
defer d.deinit();
|
|
try engine.insert("app", "c", &d, &env.gen);
|
|
}
|
|
try engine.commit();
|
|
engine.unlock();
|
|
tails[round] = engine.pager.alloc_tail;
|
|
// Every document written so far is readable, so reuse is not data loss.
|
|
try testing.expectEqual(@as(u64, (round + 1) * 5), engine.live_docs);
|
|
}
|
|
|
|
// The third open must not have allocated a third copy of the arena. Reuse
|
|
// makes the tail essentially flat; appending makes it grow by a slab extent
|
|
// (2048 pages) per round.
|
|
try testing.expect(tails[2] < tails[0] + slab_extent_pages);
|
|
try testing.expect(tails[1] < tails[0] + slab_extent_pages);
|
|
}
|
|
|
|
test "an append after a checkpoint keeps its extent instead of abandoning it" {
|
|
// Mutation check: delete the `resumed` branch in `slab_reserve`. Red on the
|
|
// extent count -- every checkpoint would take a fresh 8 MiB extent per
|
|
// collection and leave the old one's remaining space stranded, reclaimable
|
|
// only by a rebuild. A pure-insert workload produces no garbage, so no
|
|
// rebuild is ever triggered and nothing gives it back: measured at 40
|
|
// collections, the data file reached 11.8x the live data and grew ~335 MB per
|
|
// checkpoint, on course for DatabaseTooLarge at ~6 GB of real data.
|
|
//
|
|
// Second mutation: round `resumed` to `pgr.page_size` instead of
|
|
// `pgr.map_align`. Red on the frozen-page assertion below on any host whose
|
|
// system page is larger than 4 KiB (16 KiB on Apple Silicon) -- a 4 KiB store
|
|
// dirties the whole system page, so a torn writeback would take the published
|
|
// bytes sharing it.
|
|
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 = std.math.maxInt(u64); // no rebuild may intervene
|
|
try engine.lock();
|
|
defer engine.unlock();
|
|
|
|
var first = try make_doc(gpa, 1, "alice");
|
|
defer first.deinit();
|
|
try engine.insert("app", "users", &first, &env.gen);
|
|
try engine.commit();
|
|
|
|
const coll = engine.get_collection("app", "users").?;
|
|
try testing.expectEqual(@as(usize, 1), coll.slab_runs.items.len);
|
|
const extent_start = coll.slab_runs.items[0].start();
|
|
|
|
try engine.checkpoint();
|
|
const tail_at_checkpoint = coll.slab_tail;
|
|
try testing.expect(tail_at_checkpoint > extent_start);
|
|
const tail_before = engine.pager.alloc_tail;
|
|
|
|
// The next write must land in the same extent, past the frozen page.
|
|
var second = try make_doc(gpa, 2, "bob");
|
|
defer second.deinit();
|
|
try engine.insert("app", "users", &second, &env.gen);
|
|
try engine.commit();
|
|
|
|
try testing.expectEqual(@as(usize, 1), coll.slab_runs.items.len);
|
|
// A page or two for the tree's copy-on-write is expected; a whole slab
|
|
// extent is the regression this guards against.
|
|
try testing.expect(engine.pager.alloc_tail < tail_before + slab_extent_pages);
|
|
try testing.expect(coll.slab_tail > tail_at_checkpoint);
|
|
|
|
// The document itself landed on the next system-page boundary past the
|
|
// frozen tail -- checked at the offset the index recorded, since `slab_tail`
|
|
// has already advanced past it by the document's length.
|
|
const bob_enc = try id_key_for(gpa, bson.Value{ .int32 = 2 });
|
|
defer gpa.free(bob_enc);
|
|
const bob_off = coll.id_index.lookup_exact(bob_enc).?;
|
|
try testing.expectEqual(std.mem.alignForward(u64, tail_at_checkpoint, pgr.map_align), bob_off);
|
|
|
|
// And the page holding the last published byte is still frozen, so the
|
|
// resumed append cannot have shared a page with the durable image.
|
|
try testing.expect(!engine.pager.is_unpublished_at(tail_at_checkpoint - 1));
|
|
|
|
// Both documents readable, and the first one -- which lives below the
|
|
// checkpoint's tail -- unharmed.
|
|
try testing.expectEqual(@as(u64, 2), engine.live_docs);
|
|
for ([_]i32{ 1, 2 }) |id| {
|
|
const id_enc = try id_key_for(gpa, bson.Value{ .int32 = id });
|
|
defer gpa.free(id_enc);
|
|
const off = coll.id_index.lookup_exact(id_enc).?;
|
|
const name = try bson.get_at(gpa, coll.doc_bytes(off), "name");
|
|
try testing.expectEqualStrings(if (id == 1) "alice" else "bob", name.?.string);
|
|
}
|
|
}
|
|
|
|
/// A document of roughly `size` bytes, so a test can fill extents without
|
|
/// writing tens of thousands of records.
|
|
fn make_padded(gpa: std.mem.Allocator, id: i32, size: usize) !bson.Document {
|
|
var arena = std.heap.ArenaAllocator.init(gpa);
|
|
errdefer arena.deinit();
|
|
const a = arena.allocator();
|
|
const pad = try a.alloc(u8, size);
|
|
@memset(pad, 'x');
|
|
const pairs = try a.alloc(bson.Pair, 2);
|
|
pairs[0] = .{ .key = try a.dupe(u8, "_id"), .value = .{ .int32 = id } };
|
|
pairs[1] = .{ .key = try a.dupe(u8, "pad"), .value = .{ .string = pad } };
|
|
return .{ .arena = arena, .pairs = pairs };
|
|
}
|
|
|
|
/// Every dead byte the collection knows about, placed or not.
|
|
fn dead_total(coll: *const Collection) u64 {
|
|
return coll.dead_located() + coll.dead_unlocated;
|
|
}
|
|
|
|
test "every dead slab byte is counted in exactly one place" {
|
|
// The identity the window map rests on:
|
|
//
|
|
// sum of window counters + dead_unlocated == slab_used - live_bytes
|
|
//
|
|
// The left side is where the garbage is, the right side is how much there
|
|
// is; reclamation reads the first and the compaction trigger reads the
|
|
// second, so a drift between them is a rebuild that fires on a clean
|
|
// database or a window that is handed back with a document in it.
|
|
//
|
|
// Both kinds of death are exercised: evicted documents, and the slab the
|
|
// appender writes off when a checkpoint freezes the page its cursor points
|
|
// into.
|
|
//
|
|
// Mutation check: drop the `mark_dead` call from `note_skip`, or the
|
|
// `mark_dead` call from `evict_doc` -- each removes one of the two ways
|
|
// slab dies and the sides part company by that amount. The run *edges* are
|
|
// covered separately, by the test below, because whether a run's start is
|
|
// `map_align`-aligned is up to the allocator and not something an
|
|
// engine-level test can arrange.
|
|
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 = std.math.maxInt(u64); // no rebuild may intervene
|
|
try engine.lock();
|
|
defer engine.unlock();
|
|
|
|
var i: i32 = 0;
|
|
while (i < 40) : (i += 1) {
|
|
var d = try make_padded(gpa, i, 6000);
|
|
defer d.deinit();
|
|
try engine.insert("app", "c", &d, &env.gen);
|
|
// A checkpoint every few documents, so the appender keeps having to
|
|
// round its cursor up to a system page and skipping the bytes between.
|
|
if (@mod(i, 7) == 6) {
|
|
try engine.commit();
|
|
try engine.checkpoint();
|
|
}
|
|
}
|
|
try engine.commit();
|
|
|
|
const coll = engine.get_collection("app", "c").?;
|
|
// Some skipping must actually have happened, or the test proves only the
|
|
// easy half. 6000-byte documents never land flush against a page boundary.
|
|
try testing.expect(coll.slab_used > coll.live_bytes);
|
|
try testing.expectEqual(coll.slab_used - coll.live_bytes, dead_total(coll));
|
|
|
|
// Now the other kind: evictions.
|
|
i = 0;
|
|
while (i < 40) : (i += 2) {
|
|
try testing.expect(try engine.remove_by_id("app", "c", .{ .int32 = i }));
|
|
}
|
|
try engine.commit();
|
|
try testing.expectEqual(coll.slab_used - coll.live_bytes, dead_total(coll));
|
|
// And the evicted bytes are mostly placeable: 6000-byte documents are far
|
|
// smaller than a window, so they fall inside one rather than off its edge.
|
|
try testing.expect(coll.dead_located() > coll.dead_unlocated);
|
|
}
|
|
|
|
test "dead bytes outside a whole window are counted but not placed" {
|
|
// A run is allocated in 4 KiB pages but reclaimed in `map_align` windows,
|
|
// so unless the allocator happens to hand back an aligned run there is a
|
|
// head below its first window boundary and a tail above its last. Bytes
|
|
// that die there can never be given back on their own -- but they are
|
|
// still garbage, and if they were simply dropped the amount of garbage the
|
|
// collection reports would fall short of the amount it has, which is a
|
|
// compaction that never fires.
|
|
//
|
|
// Driven against `mark_dead` directly: the alignment of a real slab extent
|
|
// is the allocator's business and an engine-level test cannot arrange for
|
|
// an unaligned one.
|
|
//
|
|
// Mutation checks, each red on its own: drop the trailing `if (pos <
|
|
// stop)` and the tail bytes go uncounted; drop the leading `if (pos <
|
|
// r.window_first)` and the window index underflows instead, which takes
|
|
// down half the suite.
|
|
var threaded: std.Io.Threaded = .init_single_threaded;
|
|
defer threaded.deinit();
|
|
var env = test_env(&threaded);
|
|
const gpa = testing.allocator;
|
|
|
|
var tmp = try TmpLog.init(gpa);
|
|
defer tmp.deinit(gpa);
|
|
var engine = try Engine.open(gpa, env.io, tmp.path);
|
|
defer engine.deinit();
|
|
try engine.lock();
|
|
defer engine.unlock();
|
|
|
|
var d = try make_doc(gpa, 1, "alice");
|
|
defer d.deinit();
|
|
try engine.insert("app", "c", &d, &env.gen);
|
|
const coll = engine.get_collection("app", "c").?;
|
|
|
|
// A run deliberately starting one 4 KiB page past a window boundary, long
|
|
// enough to hold two whole windows plus a partial one at each end.
|
|
const pages_per_window: u32 = @intCast(pgr.map_align / pgr.page_size);
|
|
if (pages_per_window < 2) return error.SkipZigTest; // no edges to test
|
|
const owned = coll.slab_runs.items[0];
|
|
const aligned = std.mem.alignForward(u32, owned.first + owned.pages + 8, pages_per_window);
|
|
try coll.insert_run(gpa, aligned + 1, 3 * pages_per_window);
|
|
const ri = coll.run_of(@as(u64, aligned + 1) << pgr.page_shift).?;
|
|
const r = coll.slab_runs.items[ri];
|
|
try testing.expectEqual(@as(usize, 2), r.dead.len);
|
|
try testing.expect(r.window_first > r.start());
|
|
try testing.expect(r.window_end() < r.end());
|
|
|
|
const before = coll.dead_unlocated;
|
|
// The head, one whole window, and the tail.
|
|
coll.mark_dead(r.start(), r.window_first - r.start());
|
|
coll.mark_dead(r.window_first, pgr.map_align);
|
|
coll.mark_dead(r.window_end(), r.end() - r.window_end());
|
|
|
|
try testing.expectEqual(@as(u64, pgr.map_align), coll.dead_located());
|
|
try testing.expectEqual(
|
|
before + (r.window_first - r.start()) + (r.end() - r.window_end()),
|
|
coll.dead_unlocated,
|
|
);
|
|
// The whole window is full and the one beside it untouched: the head and
|
|
// tail bytes did not leak into a counter that would hand a window back.
|
|
try testing.expectEqual(@as(WindowDead, pgr.map_align), r.dead[0]);
|
|
try testing.expectEqual(@as(WindowDead, 0), r.dead[1]);
|
|
}
|
|
|
|
test "a slab run holds whole documents" {
|
|
// `mark_dead` charges a document to the run holding its first byte and
|
|
// asserts the rest is in the same run. That is only sound because
|
|
// `slab_reserve` never lets an append cross `slab_end` -- so check it
|
|
// against a collection that owns several runs, including one taken for a
|
|
// single oversized document.
|
|
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 = std.math.maxInt(u64);
|
|
try engine.lock();
|
|
defer engine.unlock();
|
|
|
|
var small = try make_padded(gpa, 1, 1000);
|
|
defer small.deinit();
|
|
try engine.insert("app", "c", &small, &env.gen);
|
|
// Larger than the standard 8 MiB extent, so it gets a run of its own and
|
|
// the next document forces a third.
|
|
var huge = try make_padded(gpa, 2, 9 * 1024 * 1024);
|
|
defer huge.deinit();
|
|
try engine.insert("app", "c", &huge, &env.gen);
|
|
var after = try make_padded(gpa, 3, 1000);
|
|
defer after.deinit();
|
|
try engine.insert("app", "c", &after, &env.gen);
|
|
try engine.commit();
|
|
|
|
const coll = engine.get_collection("app", "c").?;
|
|
try testing.expect(coll.slab_runs.items.len >= 2);
|
|
// Sorted by page number, and non-overlapping.
|
|
for (coll.slab_runs.items[1..], 0..) |r, k| {
|
|
const prev = coll.slab_runs.items[k];
|
|
try testing.expect(prev.first + prev.pages <= r.first);
|
|
}
|
|
var it = coll.id_index.iter();
|
|
while (it.next()) |entry| {
|
|
const ri = coll.run_of(entry.off) orelse return error.TestUnexpectedResult;
|
|
const r = coll.slab_runs.items[ri];
|
|
try testing.expect(entry.off + coll.doc_bytes(entry.off).len <= r.end());
|
|
}
|
|
}
|
|
|
|
test "a slab run recycled to a lower address keeps the list sorted" {
|
|
// Runs used to be held in allocation order, which was fine while an extent
|
|
// could only be appended. Reclamation makes a recycled run arrive at an
|
|
// address *below* one the collection already owns, and `run_of` is a binary
|
|
// search -- so the list has to be ordered by page, not by age.
|
|
//
|
|
// Exercised on the structure directly: producing a lower-addressed
|
|
// allocation through the engine needs the free list to be primed, which is
|
|
// 3.3's business, and this invariant should hold before then.
|
|
var threaded: std.Io.Threaded = .init_single_threaded;
|
|
defer threaded.deinit();
|
|
var env = test_env(&threaded);
|
|
const gpa = testing.allocator;
|
|
|
|
var tmp = try TmpLog.init(gpa);
|
|
defer tmp.deinit(gpa);
|
|
var engine = try Engine.open(gpa, env.io, tmp.path);
|
|
defer engine.deinit();
|
|
try engine.lock();
|
|
defer engine.unlock();
|
|
|
|
var d = try make_doc(gpa, 1, "alice");
|
|
defer d.deinit();
|
|
try engine.insert("app", "c", &d, &env.gen);
|
|
const coll = engine.get_collection("app", "c").?;
|
|
|
|
// Three more runs, arriving out of order and clear of the one the insert
|
|
// took. They are never written to, so no pages need to exist.
|
|
const base: u32 = coll.slab_runs.items[0].first + coll.slab_runs.items[0].pages + 16;
|
|
try coll.insert_run(gpa, base + 200, 8);
|
|
try coll.insert_run(gpa, base, 8);
|
|
try coll.insert_run(gpa, base + 100, 8);
|
|
try testing.expectEqual(@as(usize, 4), coll.slab_runs.items.len);
|
|
for (coll.slab_runs.items[1..], 0..) |r, k| {
|
|
try testing.expect(coll.slab_runs.items[k].first < r.first);
|
|
}
|
|
// And every one of them is findable at its own address, which is the point
|
|
// of the ordering.
|
|
for ([_]u32{ base, base + 100, base + 200 }) |first| {
|
|
const off = @as(u64, first) << pgr.page_shift;
|
|
const ri = coll.run_of(off) orelse return error.TestUnexpectedResult;
|
|
try testing.expectEqual(first, coll.slab_runs.items[ri].first);
|
|
}
|
|
try testing.expect(coll.run_of(@as(u64, base + 8) << pgr.page_shift) == null);
|
|
}
|
|
|
|
test "a restart forgets where the garbage is, not that there is any" {
|
|
// The window map is deliberately not persisted: it would be a new catalog
|
|
// field, a new version, and a second read path, to save re-deriving
|
|
// something the collection can live without. What must survive is the
|
|
// *amount*, because that is what arms compaction -- so an open puts the
|
|
// whole of it into `dead_unlocated` and the identity still holds with every
|
|
// window counter at zero.
|
|
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 dead_before: u64 = 0;
|
|
{
|
|
var engine = try Engine.open(gpa, io, tmp.path);
|
|
defer engine.deinit();
|
|
engine.compact_threshold = std.math.maxInt(u64);
|
|
try engine.lock();
|
|
defer engine.unlock();
|
|
var i: i32 = 0;
|
|
while (i < 20) : (i += 1) {
|
|
var d = try make_padded(gpa, i, 4000);
|
|
defer d.deinit();
|
|
try engine.insert("app", "c", &d, &env.gen);
|
|
}
|
|
i = 0;
|
|
while (i < 20) : (i += 2) {
|
|
_ = try engine.remove_by_id("app", "c", .{ .int32 = i });
|
|
}
|
|
try engine.commit();
|
|
const coll = engine.get_collection("app", "c").?;
|
|
try testing.expect(coll.dead_located() > 0);
|
|
dead_before = coll.slab_used - coll.live_bytes;
|
|
try engine.checkpoint();
|
|
}
|
|
|
|
var engine2 = try Engine.open(gpa, io, tmp.path);
|
|
defer engine2.deinit();
|
|
const coll = engine2.get_collection("app", "c").?;
|
|
try testing.expectEqual(dead_before, coll.slab_used - coll.live_bytes);
|
|
try testing.expectEqual(@as(u64, 0), coll.dead_located());
|
|
try testing.expectEqual(dead_before, coll.dead_unlocated);
|
|
try testing.expectEqual(dead_before, dead_total(coll));
|
|
// The runs came back too, and in a shape `run_of` can use.
|
|
var it = coll.id_index.iter();
|
|
while (it.next()) |entry| try testing.expect(coll.run_of(entry.off) != null);
|
|
}
|
|
|
|
/// Whether `page` falls in one of the pager's free-list generations.
|
|
fn in_extents(list: []const pgr.Extent, page: u32) bool {
|
|
for (list) |e| if (page >= e.first and page < e.first + e.pages) return true;
|
|
return false;
|
|
}
|
|
|
|
test "a slab window with one live document in it is never given back" {
|
|
// The load-bearing test of window reclamation. A window goes back when its
|
|
// counter reaches `map_align`, which is a statement about *bytes*, not
|
|
// about documents -- so the thing that must never happen is a window handed
|
|
// to the pager while a document still sits in it. The document would still
|
|
// read, because a freed page is only on a list, so the failure would be
|
|
// silent until the pages were handed out again and overwritten.
|
|
//
|
|
// Mutation check: relax the fullness test in `reclaim_windows` to
|
|
// `r.dead[i] + 2048 < pgr.map_align`, so a window 2 KiB short of empty
|
|
// qualifies. On its own that aborts the whole suite on the append-cursor
|
|
// assertion instead -- the appender's own window is the first thing a
|
|
// loosened test reaches, which is worth knowing. Drop that assertion too
|
|
// and this is the test that goes red, 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 = std.math.maxInt(u64); // no rebuild may intervene
|
|
try engine.lock();
|
|
defer engine.unlock();
|
|
|
|
var i: i32 = 0;
|
|
while (i < 200) : (i += 1) {
|
|
var d = try make_padded(gpa, i, 2000);
|
|
defer d.deinit();
|
|
try engine.insert("app", "c", &d, &env.gen);
|
|
}
|
|
try engine.commit();
|
|
const coll = engine.get_collection("app", "c").?;
|
|
|
|
// Everything dies but one document, roughly in the middle of the slab.
|
|
i = 0;
|
|
while (i < 200) : (i += 1) {
|
|
if (i == 100) continue;
|
|
try testing.expect(try engine.remove_by_id("app", "c", .{ .int32 = i }));
|
|
}
|
|
try engine.commit();
|
|
const survivor_enc = try id_key_for(gpa, bson.Value{ .int32 = 100 });
|
|
defer gpa.free(survivor_enc);
|
|
const survivor = coll.id_index.lookup_exact(survivor_enc).?;
|
|
|
|
try engine.checkpoint();
|
|
// Most of the slab went back...
|
|
try testing.expect(coll.reclaimed_bytes > 100 * 2000);
|
|
// ...but not the window the survivor is in, and it still reads.
|
|
try testing.expect(coll.run_of(survivor) != null);
|
|
try testing.expect(std.mem.indexOf(u8, coll.doc_bytes(survivor), "xxxx") != null);
|
|
// The accounting followed the pages: what is left is what is left.
|
|
try testing.expectEqual(coll.slab_used - coll.live_bytes, engine.dead_bytes);
|
|
try testing.expectEqual(
|
|
coll.slab_used - coll.live_bytes,
|
|
coll.dead_located() + coll.dead_unlocated,
|
|
);
|
|
|
|
// And once the survivor is gone, its window goes too.
|
|
try testing.expect(try engine.remove_by_id("app", "c", .{ .int32 = 100 }));
|
|
try engine.commit();
|
|
try engine.checkpoint();
|
|
try testing.expect(coll.run_of(survivor) == null);
|
|
}
|
|
|
|
test "a reclaimed slab window is not reusable until two publishes later" {
|
|
// Reclamation hands pages to `free_pages`, which withholds them for two
|
|
// generations -- and it has to, because the image one generation back is
|
|
// still the fallback a crash would open, and its catalog still claims them.
|
|
// Handing them straight out would let a write land on pages the recovery
|
|
// path is about to read as documents.
|
|
//
|
|
// Asserted on the pager's own lists rather than on `free_ready_pages()`,
|
|
// whose total also moves for copy-on-write victims and the catalog stream.
|
|
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 = std.math.maxInt(u64);
|
|
try engine.lock();
|
|
defer engine.unlock();
|
|
|
|
var i: i32 = 0;
|
|
while (i < 200) : (i += 1) {
|
|
var d = try make_padded(gpa, i, 2000);
|
|
defer d.deinit();
|
|
try engine.insert("app", "c", &d, &env.gen);
|
|
}
|
|
try engine.commit();
|
|
const coll = engine.get_collection("app", "c").?;
|
|
const owned_before = coll.slab_runs.items[0];
|
|
|
|
i = 0;
|
|
while (i < 200) : (i += 1) _ = try engine.remove_by_id("app", "c", .{ .int32 = i });
|
|
try engine.commit();
|
|
try engine.checkpoint();
|
|
try testing.expect(coll.reclaimed_bytes > 0);
|
|
|
|
// A page from the first window given back: the run's first window is all
|
|
// dead now, so its first page is no longer the collection's.
|
|
const gone = @as(u32, @intCast(owned_before.window_first >> pgr.page_shift));
|
|
try testing.expect(coll.run_of(@as(u64, gone) << pgr.page_shift) == null);
|
|
try testing.expect(gone >= owned_before.first);
|
|
|
|
// One publish has happened, so it is held, not ready.
|
|
try testing.expect(!in_extents(engine.pager.free_ready.items, gone));
|
|
try testing.expect(in_extents(engine.pager.free_hold.items, gone));
|
|
|
|
// The second publish is what makes it allocatable.
|
|
try engine.checkpoint();
|
|
try testing.expect(in_extents(engine.pager.free_ready.items, gone));
|
|
}
|
|
|
|
test "a churning collection reuses its slab instead of growing the file" {
|
|
// The one that decides whether any of this was worth doing. Reclamation can
|
|
// be working perfectly -- windows counted, pages handed back,
|
|
// `reclaimed_bytes` climbing -- and the file still grow by the whole write
|
|
// volume, because nothing asks for the pages in the shape they come back
|
|
// in. That is what `alloc_slab_run` is for, and this is what says so.
|
|
//
|
|
// Delete-and-refill in rounds, with checkpoints per round so reclamation
|
|
// gets to run and what it frees becomes allocatable. The first round has to
|
|
// grow the file; the ones after it must not.
|
|
//
|
|
// Measured here: 2068 pages after the first round, 2083 after three more of
|
|
// the same volume -- 15 pages of growth against 4800 pages written.
|
|
//
|
|
// Mutation check: raise `slab_run_min_pages` to a whole extent, so no
|
|
// reclaimed run is ever long enough to qualify and every extent request
|
|
// bumps the tail. Red -- which is also the measurement that says the floor
|
|
// has to stay well below an extent to be any use.
|
|
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 = std.math.maxInt(u64); // reuse, not rebuild
|
|
try engine.lock();
|
|
defer engine.unlock();
|
|
|
|
// Documents a whole window wide, which is the case the design is built for
|
|
// -- see the note on small documents in the churn gate.
|
|
const doc_size = pgr.map_align;
|
|
const per_round = 400;
|
|
var round: i32 = 0;
|
|
var tail_after_first: u32 = 0;
|
|
while (round < 4) : (round += 1) {
|
|
var i: i32 = 0;
|
|
while (i < per_round) : (i += 1) {
|
|
var d = try make_padded(gpa, round * per_round + i, doc_size);
|
|
defer d.deinit();
|
|
try engine.insert("app", "c", &d, &env.gen);
|
|
}
|
|
try engine.commit();
|
|
i = 0;
|
|
while (i < per_round) : (i += 1) {
|
|
_ = try engine.remove_by_id("app", "c", .{ .int32 = round * per_round + i });
|
|
}
|
|
try engine.commit();
|
|
// Two, so what this round freed is allocatable in the next one.
|
|
try engine.checkpoint();
|
|
try engine.checkpoint();
|
|
if (round == 0) tail_after_first = engine.pager.alloc_tail;
|
|
}
|
|
|
|
const coll = engine.get_collection("app", "c").?;
|
|
try testing.expect(coll.reclaimed_bytes > 0);
|
|
// Three more rounds of the same volume after the first. Anything left is
|
|
// fragmentation the windows could not cover, not the write volume.
|
|
const grew = engine.pager.alloc_tail - tail_after_first;
|
|
try testing.expect(grew < 3 * per_round * doc_size / pgr.page_size / 4);
|
|
}
|
|
|
|
test "a rebuild copies only the collections that have garbage" {
|
|
// `compact` walked every collection unconditionally, so garbage in one paid
|
|
// for a full copy of all the others -- and a copy is not free even when it
|
|
// reclaims nothing: it rewrites every document and every index, and it
|
|
// bumps the layout epoch, which kills every open cursor on a collection
|
|
// that had no reason to be touched.
|
|
//
|
|
// The epoch is the observable, and it is also the user-visible harm: the
|
|
// clean collection's cursors survive.
|
|
//
|
|
// Mutation check: delete the `wants_rebuild` guard. Red -- the clean
|
|
// collection's epoch moves too.
|
|
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 = std.math.maxInt(u64); // rebuild only when told to
|
|
try engine.lock();
|
|
|
|
var i: i32 = 0;
|
|
while (i < 40) : (i += 1) {
|
|
var d = try make_padded(gpa, i, 3000);
|
|
defer d.deinit();
|
|
try engine.insert("app", "dirty", &d, &env.gen);
|
|
var c = try make_padded(gpa, i, 3000);
|
|
defer c.deinit();
|
|
try engine.insert("app", "clean", &c, &env.gen);
|
|
}
|
|
// Only one of them loses anything.
|
|
i = 0;
|
|
while (i < 40) : (i += 2) _ = try engine.remove_by_id("app", "dirty", .{ .int32 = i });
|
|
try engine.commit();
|
|
|
|
const dirty = engine.get_collection("app", "dirty").?;
|
|
const clean = engine.get_collection("app", "clean").?;
|
|
const dirty_epoch = dirty.layout_epoch;
|
|
const clean_epoch = clean.layout_epoch;
|
|
const clean_used = clean.slab_used;
|
|
engine.unlock();
|
|
|
|
try engine.compact();
|
|
|
|
try testing.expect(dirty.layout_epoch != dirty_epoch);
|
|
try testing.expectEqual(clean_epoch, clean.layout_epoch);
|
|
// And it was not rewritten: a repack starts the slab over, so its byte
|
|
// count would not survive one unchanged.
|
|
try testing.expectEqual(clean_used, clean.slab_used);
|
|
try testing.expectEqual(@as(u64, 0), clean.slab_used - clean.live_bytes);
|
|
}
|
|
|
|
test "a replace that changes nothing is not a write" {
|
|
// Mutation check: delete the byte comparison in `upsert`'s `.replace` arm.
|
|
// Red on all three: the log grows, the document is superseded so the engine
|
|
// counts garbage that does not exist, and `replace` claims `.modified` --
|
|
// which is what `nModified` reports to the client.
|
|
//
|
|
// MongoDB counts a document as modified only if the update altered it, and
|
|
// writes no oplog entry when it did not. `$set: {x: 11}` on a document
|
|
// already holding `x: 11` is matched and not modified.
|
|
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 = std.math.maxInt(u64);
|
|
try engine.lock();
|
|
defer engine.unlock();
|
|
|
|
var d = try make_doc(gpa, 1, "alice");
|
|
defer d.deinit();
|
|
try engine.insert("app", "users", &d, &env.gen);
|
|
try engine.commit();
|
|
const log_after_insert = engine.log.data_bytes;
|
|
|
|
// The same document again, byte for byte.
|
|
var same = try make_doc(gpa, 1, "alice");
|
|
defer same.deinit();
|
|
try testing.expectEqual(Engine.Written.unchanged, try engine.replace("app", "users", &same, &env.gen));
|
|
try engine.commit();
|
|
try testing.expectEqual(log_after_insert, engine.log.data_bytes);
|
|
try testing.expectEqual(@as(u64, 0), engine.dead_docs);
|
|
try testing.expectEqual(@as(u64, 0), engine.dead_bytes);
|
|
try testing.expectEqual(@as(u64, 1), engine.live_docs);
|
|
|
|
// A different one is a write, and is reported as one.
|
|
var changed = try make_doc(gpa, 1, "bob");
|
|
defer changed.deinit();
|
|
try testing.expectEqual(Engine.Written.modified, try engine.replace("app", "users", &changed, &env.gen));
|
|
try engine.commit();
|
|
try testing.expect(engine.log.data_bytes > log_after_insert);
|
|
try testing.expectEqual(@as(u64, 1), engine.dead_docs);
|
|
try testing.expectEqual(@as(u64, 1), engine.live_docs);
|
|
|
|
// And the skipped write left the document readable and correctly indexed.
|
|
const id_enc = try id_key_for(gpa, bson.Value{ .int32 = 1 });
|
|
defer gpa.free(id_enc);
|
|
const coll = engine.get_collection("app", "users").?;
|
|
const off = coll.id_index.lookup_exact(id_enc).?;
|
|
const stored = try bson.get_at(gpa, coll.doc_bytes(off), "name");
|
|
try testing.expectEqualStrings("bob", stored.?.string);
|
|
}
|
|
|
|
test "compaction still triggers after a checkpoint has truncated the log" {
|
|
// Mutation: gate `note_compact` on `self.log.data_bytes` (what it read
|
|
// before the checkpoint existed) instead of `self.dead_bytes`. Red, because
|
|
// `truncate_to_header` zeroes that counter at every checkpoint -- the trigger
|
|
// then never fires and the doc slab grows without bound. This test exists
|
|
// because the churn gate measured exactly that: 4.1x live data.
|
|
//
|
|
// Second mutation: drop the `dead_bytes` accumulation from `read_catalog`.
|
|
// Red in "reopen carries the garbage counter across a restart", which is
|
|
// where the counter has to survive a restart.
|
|
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();
|
|
// 200 documents of a few dozen bytes each, so the volume gate has to be
|
|
// small enough for their garbage to clear it.
|
|
engine.compact_threshold = 1024;
|
|
try engine.lock();
|
|
defer engine.unlock();
|
|
|
|
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 engine.commit();
|
|
|
|
// A checkpoint, which is what truncates the log. From here on the log says
|
|
// nothing about how much garbage the database holds.
|
|
try engine.checkpoint();
|
|
try testing.expectEqual(@as(u64, 0), engine.log.data_bytes);
|
|
const live_after_load = engine.live_bytes;
|
|
try testing.expect(live_after_load > 0);
|
|
|
|
// Now make garbage. Every replace supersedes a document, so its slab bytes
|
|
// are dead: the data file holds them and only a rebuild reclaims them.
|
|
_ = engine.take_compact();
|
|
for (0..200) |i| {
|
|
var d = try make_doc(gpa, @intCast(i), "yy");
|
|
defer d.deinit();
|
|
_ = try engine.replace("app", "c", &d, &env.gen);
|
|
}
|
|
try engine.commit();
|
|
|
|
try testing.expectEqual(live_after_load + 200, engine.live_bytes);
|
|
// Every superseded document, plus what the first replace after the
|
|
// checkpoint had to skip: that checkpoint froze the page the append cursor
|
|
// pointed into, so the cursor moved up to the next system page and the gap
|
|
// it stepped over is garbage too. Both are in the same total, which is the
|
|
// point -- the trigger reads one number.
|
|
const superseded = live_after_load;
|
|
try testing.expect(engine.dead_bytes >= superseded);
|
|
try testing.expect(engine.dead_bytes - superseded < pgr.map_align);
|
|
try testing.expect(engine.dead_bytes >= engine.compact_threshold);
|
|
try testing.expect(engine.take_compact());
|
|
|
|
// And the rebuild actually clears the garbage it was called for.
|
|
try engine.compact();
|
|
try testing.expectEqual(@as(u64, 0), engine.dead_bytes);
|
|
try testing.expectEqual(@as(u64, 200), engine.live_docs);
|
|
// The engine's live total is the sum over collections, both after a rebuild
|
|
// and after the catalog round trip below.
|
|
const coll = engine.get_collection("app", "c").?;
|
|
try testing.expectEqual(engine.live_bytes, coll.live_bytes);
|
|
try testing.expectEqual(coll.slab_used, coll.live_bytes);
|
|
}
|
|
|
|
test "a rebuild leaves the space it reclaimed ready to reuse" {
|
|
// Mutation check: delete the second `checkpoint()` at the end of `compact`.
|
|
// Red -- one publish only moves the abandoned extents from `pending` to
|
|
// `hold`, so nothing is reusable and the next rebuild grows the file instead.
|
|
// Measured on the churn gate as ~1.2x of extra steady-state size (3.58x live
|
|
// data against 2.47x) under sustained update churn.
|
|
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 = 1024;
|
|
try engine.lock();
|
|
defer engine.unlock();
|
|
|
|
for (0..300) |i| {
|
|
var d = try make_doc(gpa, @intCast(i), "x");
|
|
defer d.deinit();
|
|
try engine.insert("app", "c", &d, &env.gen);
|
|
}
|
|
for (0..300) |i| {
|
|
var d = try make_doc(gpa, @intCast(i), "yy");
|
|
defer d.deinit();
|
|
_ = try engine.replace("app", "c", &d, &env.gen);
|
|
}
|
|
try engine.commit();
|
|
try testing.expect(engine.take_compact());
|
|
try engine.compact();
|
|
|
|
// The rebuild abandoned the old slab extent and every page the old trees
|
|
// occupied. Those must be handed back, not merely queued.
|
|
try testing.expect(engine.pager.free_ready_pages() > 0);
|
|
|
|
// And the next allocation actually uses them rather than the tail.
|
|
const tail_before = engine.pager.alloc_tail;
|
|
for (0..300) |i| {
|
|
var d = try make_doc(gpa, @intCast(i), "zzz");
|
|
defer d.deinit();
|
|
_ = try engine.replace("app", "c", &d, &env.gen);
|
|
}
|
|
try engine.commit();
|
|
try testing.expect(engine.pager.alloc_tail < tail_before + engine.pager.free_ready_pages() + 64);
|
|
try testing.expectEqual(@as(u64, 300), engine.live_docs);
|
|
}
|
|
|
|
test "reopen carries the garbage counter across a restart" {
|
|
// It carries it by *recomputing* it: `read_catalog` sums
|
|
// `slab_used - live_bytes` over the collections the catalog lists, rather
|
|
// than trusting the watermark's cached copy. That is a stronger claim than
|
|
// the hint was -- a collection dropped since the last checkpoint is simply
|
|
// not in the sum, where the hint kept charging the engine for it.
|
|
//
|
|
// Mutation: drop the accumulation in `read_catalog`; `engine2.dead_bytes`
|
|
// reads zero below.
|
|
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 dead_before: u64 = 0;
|
|
var live_before: u64 = 0;
|
|
{
|
|
var engine = try Engine.open(gpa, io, tmp.path);
|
|
defer engine.deinit();
|
|
engine.compact_threshold = std.math.maxInt(u64); // never rebuild here
|
|
try engine.lock();
|
|
for (0..100) |i| {
|
|
var d = try make_doc(gpa, @intCast(i), "x");
|
|
defer d.deinit();
|
|
try engine.insert("app", "c", &d, &env.gen);
|
|
}
|
|
for (0..50) |i| {
|
|
var d = try make_doc(gpa, @intCast(i), "yy");
|
|
defer d.deinit();
|
|
_ = try engine.replace("app", "c", &d, &env.gen);
|
|
}
|
|
try engine.commit();
|
|
try engine.checkpoint();
|
|
dead_before = engine.dead_bytes;
|
|
live_before = engine.live_bytes;
|
|
engine.unlock();
|
|
try testing.expect(dead_before > 0);
|
|
}
|
|
|
|
var engine2 = try Engine.open(gpa, io, tmp.path);
|
|
defer engine2.deinit();
|
|
try testing.expectEqual(dead_before, engine2.dead_bytes);
|
|
try testing.expectEqual(live_before, engine2.live_bytes);
|
|
// And it is the sum over collections on both sides of the restart, not a
|
|
// number kept beside them.
|
|
const reopened = engine2.get_collection("app", "c").?;
|
|
try testing.expectEqual(reopened.slab_used - reopened.live_bytes, engine2.dead_bytes);
|
|
}
|
|
|
|
test "the slab counts what the appender skips" {
|
|
// `slab_used` only ever grew by a document's length, so the two places the
|
|
// appender writes slab off went uncounted: the gap left when a checkpoint
|
|
// freezes the page the cursor points into and the cursor moves up to the
|
|
// next system page, and the tail of an extent abandoned for a document that
|
|
// no longer fits. Real garbage -- only a rebuild gets it back -- and
|
|
// invisible to the trigger that decides whether a rebuild is worth doing.
|
|
//
|
|
// Mutation: drop either `note_skip` call in `slab_reserve`. Red on the
|
|
// matching half below. Dropping the one in `slab_append` is not covered
|
|
// here: it needs a publish between the reservation and the append, which is
|
|
// what "a checkpoint runs alongside writers on several collections"
|
|
// arranges, and the accounting assertion in `checkpoint` is what catches it.
|
|
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 = std.math.maxInt(u64); // no rebuild mid-test
|
|
try engine.lock();
|
|
defer engine.unlock();
|
|
|
|
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 engine.commit();
|
|
const coll = engine.get_collection("app", "c").?;
|
|
// Nothing is dead yet: every document inserted is still live and the slab
|
|
// has been walked straight through.
|
|
try testing.expectEqual(@as(u64, 0), engine.dead_bytes);
|
|
try testing.expectEqual(coll.slab_used, coll.live_bytes);
|
|
|
|
// 1. The round-up. The checkpoint freezes the page the cursor is in, so the
|
|
// next append resumes at the next system page and the bytes in between
|
|
// are never written.
|
|
try engine.checkpoint();
|
|
const tail_before = coll.slab_tail;
|
|
const gap = std.mem.alignForward(u64, tail_before, pgr.map_align) - tail_before;
|
|
try testing.expect(gap > 0);
|
|
{
|
|
var d = try make_doc(gpa, 1000, "x");
|
|
defer d.deinit();
|
|
try engine.insert("app", "c", &d, &env.gen);
|
|
}
|
|
try engine.commit();
|
|
try testing.expectEqual(gap, engine.dead_bytes);
|
|
try testing.expectEqual(coll.slab_used - coll.live_bytes, engine.dead_bytes);
|
|
|
|
// 2. The abandoned tail. A document bigger than the standard extent takes
|
|
// one of its own, and everything left in the extent being walked away
|
|
// from is unreachable -- the extent stays allocated to this collection.
|
|
const abandoned = coll.slab_end - coll.slab_tail;
|
|
try testing.expect(abandoned > 4 * 1024 * 1024);
|
|
{
|
|
const big = try gpa.alloc(u8, 9 * 1024 * 1024);
|
|
defer gpa.free(big);
|
|
@memset(big, 'z');
|
|
var d = try make_doc(gpa, 1001, big);
|
|
defer d.deinit();
|
|
try engine.insert("app", "c", &d, &env.gen);
|
|
}
|
|
try engine.commit();
|
|
try testing.expectEqual(gap + abandoned, engine.dead_bytes);
|
|
try testing.expectEqual(coll.slab_used - coll.live_bytes, engine.dead_bytes);
|
|
|
|
// And the next checkpoint gives most of it straight back. An abandoned
|
|
// extent tail is whole windows with nothing live in them, which is exactly
|
|
// what window reclamation is for -- so counting it was not bookkeeping for
|
|
// its own sake, it is what made this reclaimable at all.
|
|
//
|
|
// What stays is the edges: the round-up gap, which shares its window with
|
|
// the live documents below it, and the bytes of the run outside any whole
|
|
// window.
|
|
try engine.checkpoint();
|
|
try testing.expect(coll.reclaimed_bytes > 4 * 1024 * 1024);
|
|
try testing.expectEqual(gap + abandoned - coll.reclaimed_bytes, engine.dead_bytes);
|
|
try testing.expectEqual(coll.slab_used - coll.live_bytes, engine.dead_bytes);
|
|
try testing.expect(engine.dead_bytes < gap + 2 * pgr.map_align);
|
|
}
|
|
|
|
test "a rebuild leaves behind what its own copying skipped" {
|
|
// `compact` used to set `dead_bytes = 0`, on the reasoning that a repack
|
|
// holds only live bytes. It does not: the repack appends through the same
|
|
// slab, so it abandons an extent tail whenever the next document no longer
|
|
// fits. Zeroing the counter there broke the identity the checkpoint asserts
|
|
// -- and understated the garbage, so a collection that fragments on every
|
|
// rebuild would never be rebuilt again.
|
|
//
|
|
// Mutation: put `self.dead_bytes = 0;` back. Red below, and the checkpoint
|
|
// inside `compact` aborts on the accounting assertion before it gets there.
|
|
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 = std.math.maxInt(u64); // rebuild only when told to
|
|
try engine.lock();
|
|
defer engine.unlock();
|
|
|
|
// Documents big enough that two of them do not fit in one 8 MiB extent, so
|
|
// the copying itself has to abandon a tail.
|
|
const big = try gpa.alloc(u8, 5 * 1024 * 1024);
|
|
defer gpa.free(big);
|
|
@memset(big, 'z');
|
|
for (0..3) |i| {
|
|
var d = try make_doc(gpa, @intCast(i), big);
|
|
defer d.deinit();
|
|
try engine.insert("app", "c", &d, &env.gen);
|
|
}
|
|
try engine.commit();
|
|
|
|
// One of them becomes garbage, which is what the rebuild is for.
|
|
try testing.expect(try engine.remove_by_id("app", "c", .{ .int32 = 1 }));
|
|
try engine.commit();
|
|
|
|
try engine.compact();
|
|
|
|
const coll = engine.get_collection("app", "c").?;
|
|
try testing.expectEqual(@as(u64, 2), coll.doc_count);
|
|
// The deleted document is gone from the slab, and the gap the copy left
|
|
// between the two survivors is now mostly gone too -- `compact` ends in a
|
|
// checkpoint, and a checkpoint reclaims whole windows. What survives is the
|
|
// edges of that gap, which is a smaller number than this test used to
|
|
// assert but the same statement: the counter is *not* zeroed, and it equals
|
|
// what the collection actually has.
|
|
try testing.expectEqual(coll.slab_used - coll.live_bytes, engine.dead_bytes);
|
|
try testing.expect(coll.reclaimed_bytes > 2 * 1024 * 1024);
|
|
try testing.expect(engine.dead_bytes > 0);
|
|
try testing.expect(engine.dead_bytes < 4 * pgr.map_align);
|
|
}
|
|
|
|
test "dropping a collection does not arm compaction" {
|
|
// `free_collection` charged the engine's `dead_bytes` with the dropped
|
|
// collection's *live* bytes, having just handed every page it owned back to
|
|
// the pager on the following line. A drop of a large collection therefore
|
|
// asked for a rebuild -- a full copy of every collection that was left --
|
|
// to reclaim space that had already been reclaimed. Its own garbage was
|
|
// wrong the other way: it stayed on the engine's books after the pages
|
|
// holding it were gone.
|
|
//
|
|
// Mutation: restore `self.dead_bytes += coll.live_bytes;`, or delete the
|
|
// subtraction of `coll.slab_used - coll.live_bytes`. Either one is red on
|
|
// the equality below, and the first also re-arms the trigger.
|
|
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 = std.math.maxInt(u64); // no rebuild while setting up
|
|
try engine.lock();
|
|
defer engine.unlock();
|
|
|
|
// A small collection that survives, and a large one that does not. Both
|
|
// hold garbage, so the drop has to keep one collection's and discard the
|
|
// other's.
|
|
for (0..40) |i| {
|
|
var d = try make_doc(gpa, @intCast(i), "x");
|
|
defer d.deinit();
|
|
try engine.insert("app", "keep", &d, &env.gen);
|
|
}
|
|
for (0..200) |i| {
|
|
var d = try make_doc(gpa, @intCast(i), "x");
|
|
defer d.deinit();
|
|
try engine.insert("app", "gone", &d, &env.gen);
|
|
}
|
|
for (0..10) |i| {
|
|
var d = try make_doc(gpa, @intCast(i), "yy");
|
|
defer d.deinit();
|
|
_ = try engine.replace("app", "keep", &d, &env.gen);
|
|
}
|
|
for (0..200) |i| {
|
|
var d = try make_doc(gpa, @intCast(i), "yy");
|
|
defer d.deinit();
|
|
_ = try engine.replace("app", "gone", &d, &env.gen);
|
|
}
|
|
try engine.commit();
|
|
|
|
const keep = engine.get_collection("app", "keep").?;
|
|
const keep_dead = keep.slab_used - keep.live_bytes;
|
|
const keep_live = keep.live_bytes;
|
|
try testing.expect(keep_dead > 0);
|
|
try testing.expect(engine.dead_bytes > keep_dead);
|
|
|
|
// Arm the trigger for what the two of them hold between them.
|
|
engine.compact_threshold = keep_dead + 1;
|
|
engine.note_compact();
|
|
try testing.expect(engine.take_compact());
|
|
|
|
try testing.expect(try engine.drop_collection("app", "gone"));
|
|
try testing.expectEqual(keep_dead, engine.dead_bytes);
|
|
try testing.expectEqual(keep_live, engine.live_bytes);
|
|
|
|
// The next write reconsiders the trigger and finds nothing worth a rebuild:
|
|
// what the drop reclaimed is not garbage, it is free.
|
|
engine.note_compact();
|
|
try testing.expect(!engine.take_compact());
|
|
|
|
// The catalog agrees, which is what the reopened engine will read.
|
|
try engine.checkpoint();
|
|
try testing.expectEqual(keep_dead, engine.dead_bytes);
|
|
}
|
|
|
|
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 id_key_for(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 id_key_for(gpa, bson.Value{ .int32 = 1 });
|
|
defer gpa.free(id_key1);
|
|
try testing.expectEqualStrings("alice", (try bson.get_at(gpa, engine2.get_doc("app", "users", id_key1).?, "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.id_index.iter();
|
|
var count: usize = 0;
|
|
while (it.next()) |entry| {
|
|
count += 1;
|
|
const b = coll.doc_bytes(entry.off);
|
|
try testing.expect((try bson.get_at(gpa, b, "_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);
|
|
}
|
|
try engine.commit();
|
|
// threshold 1 makes every write want a compaction; run it.
|
|
if (engine.take_compact()) try engine.compact();
|
|
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);
|
|
try engine2.commit();
|
|
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 id_key_for(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 "a checkpoint runs alongside writers on several collections" {
|
|
// `write_catalog` reads each collection's slab extents, indexes and byte
|
|
// counters while holding only the *shared catalog* lock -- and a writer
|
|
// holds that same lock shared, taking the collection's lock exclusively.
|
|
// So the snapshot walked structures its owner was free to mutate, and
|
|
// `slab_runs` is an ArrayList a new extent inserts into: a reallocation
|
|
// mid-walk leaves the serializer reading freed memory.
|
|
//
|
|
// Several collections rather than one, because the interesting overlap is a
|
|
// writer on collection B while the catalog is serializing collection A.
|
|
//
|
|
// Mutation check: drop the `lockShared` from `write_catalog`'s collection
|
|
// loop. Not reliably red -- a data race never is -- but it runs under
|
|
// ReleaseSafe, where the reads it makes are bounds-checked.
|
|
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 colls = [_][]const u8{ "a", "b", "c", "d" };
|
|
const per_coll: i32 = 150;
|
|
var done = std.atomic.Value(usize).init(colls.len);
|
|
|
|
const Worker = struct {
|
|
fn writer(
|
|
e: *Engine,
|
|
name: []const u8,
|
|
left: *std.atomic.Value(usize),
|
|
alloc: std.mem.Allocator,
|
|
) error{Canceled}!void {
|
|
defer _ = left.fetchSub(1, .release);
|
|
for (1..per_coll + 1) |i| {
|
|
var doc = make_doc(alloc, @intCast(i), "user") catch return error.Canceled;
|
|
defer doc.deinit();
|
|
{
|
|
e.lock_catalog(false) catch return error.Canceled;
|
|
defer e.unlock_catalog(false);
|
|
const coll = (e.lock_collection("app", name, true, true) catch
|
|
return error.Canceled) orelse return error.Canceled;
|
|
defer e.unlock_collection(coll, true);
|
|
e.insert("app", name, &doc, undefined) catch return error.Canceled;
|
|
}
|
|
// As the dispatch epilogue does (commands.zig): the append bumps
|
|
// `seq`, the commit is what makes it durable, and a checkpoint
|
|
// may only describe what is durable.
|
|
e.commit() catch return error.Canceled;
|
|
}
|
|
}
|
|
|
|
fn checkpointer(e: *Engine, left: *std.atomic.Value(usize)) error{Canceled}!void {
|
|
while (left.load(.acquire) > 0) {
|
|
// Errors are the point of the retry loop inside `checkpoint`,
|
|
// not a failure of this test; a checkpoint that gives up under
|
|
// sustained writes has still not corrupted anything.
|
|
e.checkpoint() catch {};
|
|
}
|
|
}
|
|
};
|
|
|
|
var group: std.Io.Group = .init;
|
|
defer group.cancel(io);
|
|
for (colls) |name| group.async(io, Worker.writer, .{ &engine, name, &done, gpa });
|
|
group.async(io, Worker.checkpointer, .{ &engine, &done });
|
|
try group.await(io);
|
|
|
|
// Every write is still there, and the catalog the checkpoints wrote agrees
|
|
// with the engine -- the second half is what `write_catalog`'s own assertion
|
|
// checks on the way through.
|
|
try engine.checkpoint();
|
|
try engine.lock_read();
|
|
defer engine.unlock_read();
|
|
var live_sum: u64 = 0;
|
|
var dead_sum: u64 = 0;
|
|
var docs_sum: u64 = 0;
|
|
for (colls) |name| {
|
|
const coll = engine.get_collection("app", name) orelse return error.TestUnexpectedResult;
|
|
try testing.expectEqual(@as(usize, @intCast(per_coll)), coll.id_index.count());
|
|
live_sum += coll.live_bytes;
|
|
dead_sum += coll.slab_used - coll.live_bytes;
|
|
docs_sum += coll.doc_count;
|
|
}
|
|
// The engine's counters are the sums over collections, checked once
|
|
// everything is quiet rather than left to `checkpoint`'s own assertion --
|
|
// which only runs on a checkpoint that happened to fall in a gap between
|
|
// writes, so under sustained load it can go a whole run without firing.
|
|
// Every writer here held nothing but its own collection's lock while moving
|
|
// these, so a lost update lands exactly as a mismatch on one of these three.
|
|
try testing.expectEqual(live_sum, engine.live_bytes);
|
|
try testing.expectEqual(dead_sum, engine.dead_bytes);
|
|
try testing.expectEqual(docs_sum, engine.live_docs);
|
|
}
|
|
|
|
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.id_index.iter();
|
|
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.id_index.count());
|
|
for (1..total + 1) |i| {
|
|
const id_key = try id_key_for(gpa, bson.Value{ .int32 = @intCast(i) });
|
|
defer gpa.free(id_key);
|
|
try testing.expect(engine.get_doc("app", "users", id_key) != null);
|
|
}
|
|
}
|
|
|
|
test "compact yields to a compaction already in flight" {
|
|
// The guard's contract, checked deterministically. Two compactions at once
|
|
// share one tmp path and each ends in a rename onto the log, so the second
|
|
// truncates and rewrites the file the first is about to publish -- and the
|
|
// first then renames whatever the second left there over the live log.
|
|
//
|
|
// The real interleaving is hard to force: `compact_snapshot_coll` holds each
|
|
// collection's write lock while writing its snapshot, so two compactions
|
|
// serialize there, and an insert cannot re-arm `compact_pending` while that
|
|
// lock is held either. The overlap window is only between the end of one
|
|
// snapshot and its rename. Rather than race for it, drive the flag directly
|
|
// and pin what the guard promises.
|
|
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;
|
|
|
|
// Leave real garbage behind, so a compaction that ran would be visible:
|
|
// `compact` resets dead_docs to zero and nothing else does.
|
|
try engine.lock();
|
|
var doc = try make_doc(gpa, 1, "alice");
|
|
defer doc.deinit();
|
|
try engine.insert("app", "users", &doc, &env.gen);
|
|
var doc2 = try make_doc(gpa, 1, "alice-again");
|
|
defer doc2.deinit();
|
|
// A replace supersedes the first record, leaving it behind as garbage.
|
|
_ = try engine.replace("app", "users", &doc2, &env.gen);
|
|
try engine.commit();
|
|
engine.unlock();
|
|
try testing.expect(engine.dead_docs > 0);
|
|
const dead_before = engine.dead_docs;
|
|
|
|
// With a compaction "in flight", compact must return without rewriting.
|
|
engine.compacting.store(true, .release);
|
|
try engine.compact();
|
|
try testing.expectEqual(dead_before, engine.dead_docs);
|
|
|
|
// With the slot free, the same call does the work -- proving the assertion
|
|
// above came from the guard and not from there being nothing to do.
|
|
engine.compacting.store(false, .release);
|
|
try engine.compact();
|
|
try testing.expectEqual(@as(u64, 0), engine.dead_docs);
|
|
}
|
|
|
|
test "concurrent writers compacting: the log survives a reopen" {
|
|
// Real worker threads driving compaction while other writers append, each
|
|
// following the lock sequence the server's dispatch uses (catalog ->
|
|
// collection -> release both -> commit -> compact). Every other compaction
|
|
// test is single-threaded, so this is the only coverage of the whole write
|
|
// path under genuine contention.
|
|
//
|
|
// What it proves: concurrent compaction leaves a log that replays to
|
|
// exactly the right documents. The count is checked exactly -- too few
|
|
// means a rewrite was published half-written, too many means a stale tmp
|
|
// tail was replayed as live data.
|
|
//
|
|
// What it does not prove: that either specific race is fixed. Both windows
|
|
// are too narrow to hit reliably (see the test above), and this test passes
|
|
// with the `compacting` guard removed. It is a smoke test for the path, not
|
|
// a regression test for the guard; the deterministic tests above and in
|
|
// storage.zig are what pin those two invariants.
|
|
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);
|
|
|
|
const writers = 4;
|
|
const per_writer: i32 = 60;
|
|
const total: i32 = writers * per_writer;
|
|
|
|
{
|
|
var engine = try Engine.open(gpa, io, tmp.path);
|
|
defer engine.deinit();
|
|
// Every write wants a compaction, so writers pile into compact() with
|
|
// maximum overlap -- the point of the test.
|
|
engine.compact_threshold = 1;
|
|
|
|
var next_id = std.atomic.Value(i32).init(1);
|
|
|
|
const Worker = struct {
|
|
/// The in-lock half of a write command: the collection lock is
|
|
/// taken under the catalog lock, and both are released on return --
|
|
/// so the caller's commit runs holding neither, exactly as the
|
|
/// server's dispatch epilogue does.
|
|
fn insert_locked(e: *Engine, doc: *bson.Document) !void {
|
|
try e.lock_catalog(false);
|
|
defer e.unlock_catalog(false);
|
|
const coll = try e.lock_collection("app", "users", true, true);
|
|
if (coll) |c| {
|
|
defer e.unlock_collection(c, true);
|
|
try e.insert("app", "users", doc, undefined);
|
|
}
|
|
}
|
|
|
|
fn writer(
|
|
e: *Engine,
|
|
id_counter: *std.atomic.Value(i32),
|
|
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();
|
|
// Ids come from the shared counter, so no insert here can
|
|
// legitimately fail; any error is a real defect.
|
|
insert_locked(e, &doc) catch return error.Canceled;
|
|
e.commit() catch return error.Canceled;
|
|
if (e.take_compact()) e.compact() catch return error.Canceled;
|
|
}
|
|
}
|
|
};
|
|
|
|
var group: std.Io.Group = .init;
|
|
defer group.cancel(io);
|
|
for (0..writers) |_| group.async(io, Worker.writer, .{ &engine, &next_id, gpa });
|
|
try group.await(io);
|
|
}
|
|
|
|
// Reopen from disk: this replays the log that compaction left behind, which
|
|
// is the only place the races above are observable.
|
|
var reopened = try Engine.open(gpa, io, tmp.path);
|
|
defer reopened.deinit();
|
|
try reopened.lock_read();
|
|
defer reopened.unlock_read();
|
|
const coll = reopened.get_collection("app", "users") orelse return error.TestUnexpectedResult;
|
|
try testing.expectEqual(@as(usize, @intCast(total)), coll.id_index.count());
|
|
for (1..total + 1) |i| {
|
|
const id_key = try id_key_for(gpa, bson.Value{ .int32 = @intCast(i) });
|
|
defer gpa.free(id_key);
|
|
try testing.expect(reopened.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(u64) = .empty;
|
|
defer out.deinit(gpa);
|
|
try ix.lookup_eq(gpa, &.{key_value}, &out);
|
|
return out.items.len;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
fn make_keyed(gpa: std.mem.Allocator, id: i32, k: i32) !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, "k"), .value = .{ .int32 = k } };
|
|
return .{ .arena = arena, .pairs = pairs };
|
|
}
|
|
|
|
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.get_collection("app", "users").?.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 "a checkpoint lets the next open skip the log it covers" {
|
|
// The point of the whole milestone: an open that finds a watermark loads the
|
|
// data file and replays only what happened after it, instead of rebuilding
|
|
// everything from the log.
|
|
//
|
|
// Mutation checks, red: publishing a watermark seq of 0; and removing the
|
|
// index maintenance in apply_record, which leaves a replayed document
|
|
// present in the collection and absent from `_id_` -- which, once the
|
|
// hashmap goes, means simply absent.
|
|
//
|
|
// Not covered, and worth stating rather than implying: removing
|
|
// `self.seq = @max(self.seq, record.seq)` from apply_record leaves this
|
|
// green. The sequence is seeded from the watermark on a checkpointed open,
|
|
// so it only drifts by the records replayed on top -- and every sequence
|
|
// reachable from here has the catalog carrying those same records, which
|
|
// masks the drift. Observing it needs a crash between a duplicate-sequence
|
|
// append and the checkpoint that would have captured it, which is the
|
|
// crash-injection harness's job, not this test's. The line stays because a
|
|
// log whose sequences are not monotonic has no total order, and
|
|
// `committed_seq <= seq` is asserted on every commit.
|
|
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);
|
|
const data_path = try std.fmt.allocPrint(gpa, "{s}.data", .{tmp.path});
|
|
defer gpa.free(data_path);
|
|
defer std.Io.Dir.cwd().deleteFile(io, data_path) catch {};
|
|
|
|
{
|
|
var engine = try Engine.open(gpa, io, tmp.path);
|
|
defer engine.deinit();
|
|
try engine.lock();
|
|
var i: i32 = 0;
|
|
while (i < 40) : (i += 1) {
|
|
var d = try make_user(gpa, i, "a@x.io");
|
|
defer d.deinit();
|
|
try engine.insert("app", "users", &d, &env.gen);
|
|
}
|
|
engine.unlock();
|
|
try engine.checkpoint();
|
|
try testing.expect(engine.pager.loaded.generation >= 1);
|
|
try testing.expectEqual(engine.seq, engine.pager.loaded.seq);
|
|
|
|
// Writes after the checkpoint are the ones a reopen must replay.
|
|
try engine.lock();
|
|
i = 100;
|
|
while (i < 105) : (i += 1) {
|
|
var d = try make_user(gpa, i, "b@x.io");
|
|
defer d.deinit();
|
|
try engine.insert("app", "users", &d, &env.gen);
|
|
}
|
|
engine.unlock();
|
|
}
|
|
|
|
// Reopen: the checkpoint is loaded, so only the five later records apply.
|
|
{
|
|
var engine = try Engine.open(gpa, io, tmp.path);
|
|
defer engine.deinit();
|
|
try engine.lock();
|
|
defer engine.unlock();
|
|
try testing.expect(engine.pager.loaded.generation >= 1);
|
|
const coll = engine.get_collection("app", "users").?;
|
|
try testing.expectEqual(@as(usize, 45), coll.id_index.count());
|
|
try testing.expectEqual(@as(usize, 45), coll.id_index.count());
|
|
// And the sequence continued from the watermark rather than restarting.
|
|
try testing.expect(engine.seq >= engine.pager.loaded.seq);
|
|
}
|
|
|
|
// A second reopen, to catch a sequence that restarted: the writes made after
|
|
// the first reopen must survive it.
|
|
{
|
|
var engine = try Engine.open(gpa, io, tmp.path);
|
|
defer engine.deinit();
|
|
try engine.lock();
|
|
var d = try make_user(gpa, 500, "c@x.io");
|
|
defer d.deinit();
|
|
try engine.insert("app", "users", &d, &env.gen);
|
|
engine.unlock();
|
|
try engine.checkpoint();
|
|
}
|
|
{
|
|
var engine = try Engine.open(gpa, io, tmp.path);
|
|
defer engine.deinit();
|
|
try engine.lock();
|
|
defer engine.unlock();
|
|
const coll = engine.get_collection("app", "users").?;
|
|
try testing.expectEqual(@as(usize, 46), coll.id_index.count());
|
|
const id_key = try id_key_for(gpa, .{ .int32 = 500 });
|
|
defer gpa.free(id_key);
|
|
try testing.expect(engine.get_doc("app", "users", id_key) != null);
|
|
}
|
|
}
|
|
|
|
test "a checkpoint reclaims the log and the data survives" {
|
|
// The payoff of a lagging checkpoint: once the data file holds the effect of
|
|
// a record, the record is redundant and the log can be reclaimed. Without
|
|
// this the log only ever grows and every open pays for every write ever made.
|
|
//
|
|
// Mutation check, red: skipping the truncation.
|
|
//
|
|
// Not covered: moving the truncation *before* the publish. That is still
|
|
// correct in the absence of a crash -- the publish follows immediately -- and
|
|
// the hazard is precisely a crash landing between the two, with the records
|
|
// gone from the log and not yet in any image. Catching it needs process-level
|
|
// crash injection, which the milestone's gates cover; an in-process test
|
|
// cannot express "stop here and die". The order stays because it is the
|
|
// whole reason a lagging checkpoint is safe.
|
|
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);
|
|
const data_path = try std.fmt.allocPrint(gpa, "{s}.data", .{tmp.path});
|
|
defer gpa.free(data_path);
|
|
defer std.Io.Dir.cwd().deleteFile(io, data_path) catch {};
|
|
|
|
var log_after_checkpoint: u64 = 0;
|
|
{
|
|
var engine = try Engine.open(gpa, io, tmp.path);
|
|
defer engine.deinit();
|
|
try engine.lock();
|
|
var i: i32 = 0;
|
|
while (i < 200) : (i += 1) {
|
|
var d = try make_user(gpa, i, "a@x.io");
|
|
defer d.deinit();
|
|
try engine.insert("app", "users", &d, &env.gen);
|
|
}
|
|
engine.unlock();
|
|
|
|
// Commit first, so the records are actually on disk: appends buffer in
|
|
// the log's open block, and only a commit seals and writes it. Measuring
|
|
// before that reads a file that is still just its header.
|
|
try engine.commit();
|
|
const before = try engine.log.file.length(io);
|
|
try testing.expect(before > storage.file_header_len);
|
|
|
|
try engine.checkpoint();
|
|
log_after_checkpoint = try engine.log.file.length(io);
|
|
// The log is back to just its header.
|
|
try testing.expect(log_after_checkpoint < before);
|
|
try testing.expectEqual(@as(u64, storage.file_header_len), log_after_checkpoint);
|
|
// And writing still works afterwards, at a sequence above the watermark.
|
|
try engine.lock();
|
|
var d = try make_user(gpa, 999, "z@x.io");
|
|
defer d.deinit();
|
|
try engine.insert("app", "users", &d, &env.gen);
|
|
engine.unlock();
|
|
try testing.expect(engine.seq > engine.pager.loaded.seq);
|
|
}
|
|
|
|
// Everything is still there after a reopen: 200 from the image, 1 from the
|
|
// log records written after the truncation.
|
|
{
|
|
var engine = try Engine.open(gpa, io, tmp.path);
|
|
defer engine.deinit();
|
|
try engine.lock();
|
|
defer engine.unlock();
|
|
const coll = engine.get_collection("app", "users").?;
|
|
try testing.expectEqual(@as(usize, 201), coll.id_index.count());
|
|
try testing.expectEqual(@as(usize, 201), coll.id_index.count());
|
|
const id_key = try id_key_for(gpa, .{ .int32 = 999 });
|
|
defer gpa.free(id_key);
|
|
try testing.expect(engine.get_doc("app", "users", id_key) != null);
|
|
const first_key = try id_key_for(gpa, .{ .int32 = 0 });
|
|
defer gpa.free(first_key);
|
|
try testing.expect(engine.get_doc("app", "users", first_key) != null);
|
|
}
|
|
}
|
|
|
|
test "a rebuild reclaims dead document bytes and keeps every index valid" {
|
|
// What a checkpoint cannot do. A replaced document leaves its old bytes
|
|
// behind, and they cannot be reclaimed in place because every index leaf
|
|
// holds a physical offset -- so the rebuild has to move the documents *and*
|
|
// repack the indexes against the new offsets, together.
|
|
//
|
|
// The assertion that matters is not the size but the second half: after the
|
|
// rebuild every document is still findable through both the _id_ index and a
|
|
// secondary one. A rebuild that moved documents and left one stale entry
|
|
// behind would shrink the file and return wrong answers.
|
|
//
|
|
// Mutation checks: skip the repack and the lookups go red (stale offsets);
|
|
// skip the slab reset and the file never shrinks.
|
|
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);
|
|
const data_path = try std.fmt.allocPrint(gpa, "{s}.data", .{tmp.path});
|
|
defer gpa.free(data_path);
|
|
defer std.Io.Dir.cwd().deleteFile(io, data_path) catch {};
|
|
|
|
var engine = try Engine.open(gpa, io, tmp.path);
|
|
defer engine.deinit();
|
|
|
|
try engine.lock();
|
|
var spec = try index_spec(gpa, "email", "email_1", false, false, null);
|
|
defer spec.deinit();
|
|
_ = try engine.create_index("app", "users", &spec);
|
|
var i: i32 = 0;
|
|
while (i < 60) : (i += 1) {
|
|
var d = try make_user(gpa, i, "a@x.io");
|
|
defer d.deinit();
|
|
try engine.insert("app", "users", &d, &env.gen);
|
|
}
|
|
// Replace every one of them, which is what makes the old bytes garbage.
|
|
i = 0;
|
|
while (i < 60) : (i += 1) {
|
|
var d = try make_user(gpa, i, "b@x.io");
|
|
defer d.deinit();
|
|
_ = try engine.replace("app", "users", &d, &env.gen);
|
|
}
|
|
engine.unlock();
|
|
|
|
const coll = engine.get_collection("app", "users").?;
|
|
const before_used = coll.slab_used;
|
|
try engine.compact();
|
|
|
|
// 60 live documents occupy less than the 120 writes that produced them.
|
|
try testing.expect(coll.slab_used < before_used);
|
|
try testing.expect(coll.slab_used > 0);
|
|
try testing.expectEqual(@as(usize, 60), coll.id_index.count());
|
|
try testing.expectEqual(@as(usize, 60), coll.id_index.count());
|
|
|
|
// The assertions that matter. Content alone proves nothing here: the old
|
|
// extents are only handed to the free list, not overwritten, so a stale
|
|
// offset still reads a plausible document. What distinguishes a repacked
|
|
// index from a stale one is *where* the offset points -- every live offset
|
|
// must fall inside an extent the collection currently owns.
|
|
try engine.lock();
|
|
defer engine.unlock();
|
|
i = 0;
|
|
while (i < 60) : (i += 1) {
|
|
const id_key = try id_key_for(gpa, .{ .int32 = i });
|
|
defer gpa.free(id_key);
|
|
|
|
var enc: std.ArrayListUnmanaged(u8) = .empty;
|
|
defer enc.deinit(gpa);
|
|
try bson.encode_key(.{ .int32 = i }, gpa, &enc);
|
|
const from_index = coll.id_index.lookup_exact(enc.items) orelse return error.TestUnexpectedResult;
|
|
// The offset must be in the new slab. With the hashmap gone this is the
|
|
// whole check: there is no second structure to disagree with, so a
|
|
// rebuild that left stale offsets behind shows up here and nowhere else.
|
|
try testing.expect(offset_in_extents(coll, from_index));
|
|
try testing.expect(std.mem.indexOf(u8, coll.doc_bytes(from_index), "b@x.io") != null);
|
|
}
|
|
// The secondary index too, by the same standard.
|
|
var found: std.ArrayListUnmanaged(u64) = .empty;
|
|
defer found.deinit(gpa);
|
|
const email_ix = coll.find_index("email_1").?;
|
|
try email_ix.lookup_eq(gpa, &.{.{ .string = "b@x.io" }}, &found);
|
|
try testing.expectEqual(@as(usize, 60), found.items.len);
|
|
for (found.items) |off| try testing.expect(offset_in_extents(coll, off));
|
|
}
|
|
|
|
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 "dropping an index does not move its siblings" {
|
|
// Indexes used to be stored by value, so `orderedRemove` memmoved the
|
|
// whole list and every `*Index` already handed out -- notably a query
|
|
// plan's `index` field -- silently referred to a *different* index
|
|
// afterwards. Nothing caught it: the collection's own bookkeeping stayed
|
|
// consistent, so only a caller holding a pointer across a drop would see
|
|
// it, and none of the tests did.
|
|
//
|
|
// Mutation check: restore `indexes` to ArrayListUnmanaged(index.Index)
|
|
// (with the by-value append/remove that goes with it) and the b_1
|
|
// assertion below reads "c_1", because slot 1 now holds what used to be in
|
|
// slot 2.
|
|
var threaded: std.Io.Threaded = .init_single_threaded;
|
|
defer threaded.deinit();
|
|
const 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();
|
|
try engine.lock();
|
|
defer engine.unlock();
|
|
|
|
for ([_][]const u8{ "a", "b", "c" }) |field| {
|
|
const name = try std.fmt.allocPrint(gpa, "{s}_1", .{field});
|
|
defer gpa.free(name);
|
|
var spec = try index_spec(gpa, field, name, false, false, null);
|
|
defer spec.deinit();
|
|
_ = try engine.create_index("app", "users", &spec);
|
|
}
|
|
|
|
const coll = engine.get_collection("app", "users").?;
|
|
// Hold pointers across the drop, which is the whole point.
|
|
const b_ix = coll.find_index("b_1").?;
|
|
const c_ix = coll.find_index("c_1").?;
|
|
|
|
try testing.expect(try engine.drop_index("app", "users", "a_1"));
|
|
|
|
try testing.expectEqual(@as(usize, 2), coll.indexes.items.len);
|
|
try testing.expectEqualStrings("b_1", b_ix.name);
|
|
try testing.expectEqualStrings("c_1", c_ix.name);
|
|
// And they are still the collection's own indexes, not detached copies.
|
|
try testing.expectEqual(b_ix, coll.find_index("b_1").?);
|
|
try testing.expectEqual(c_ix, coll.find_index("c_1").?);
|
|
}
|
|
|
|
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 id_key_for(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.id_index.count());
|
|
try testing.expectEqual(@as(usize, 6), coll.indexes.items[0].count());
|
|
|
|
// 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.id_index.count());
|
|
try testing.expectEqual(@as(usize, 4), coll.indexes.items[0].count());
|
|
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.id_index.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.id_index.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].count());
|
|
for ([_]i32{ 1, 2, 3 }) |id| {
|
|
const id_key = try id_key_for(gpa, bson.Value{ .int32 = id });
|
|
defer gpa.free(id_key);
|
|
try testing.expect(engine2.get_doc("app", "sessions", id_key) == null);
|
|
}
|
|
const alive = try id_key_for(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").?.doc_count);
|
|
try testing.expectEqual(@as(usize, 0), engine.get_collection("app", "events").?.doc_count);
|
|
try testing.expectEqual(@as(usize, 1), engine.get_collection("other", "plain").?.doc_count);
|
|
}
|
|
|
|
/// Report a replay eviction whose `_id` bytes differ from the incoming record's.
|
|
///
|
|
/// `_id_` is keyed on the canonical `bson.encode_key`, under which int32 1,
|
|
/// int64 1 and double 1.0 are one key -- as they are in MongoDB. A database
|
|
/// written before that could legitimately hold two such documents, and replaying
|
|
/// it now drops one. That is the intended semantics and a one-way migration, so
|
|
/// it has to be said out loud rather than discovered.
|
|
///
|
|
/// Best effort by design: this runs during replay, where nothing may refuse to
|
|
/// start.
|
|
fn warn_on_equal_id_collision(
|
|
gpa: std.mem.Allocator,
|
|
coll: *const Collection,
|
|
old_off: u64,
|
|
doc: *const bson.Document,
|
|
record: storage.Record,
|
|
) void {
|
|
var arena = std.heap.ArenaAllocator.init(gpa);
|
|
defer arena.deinit();
|
|
const a = arena.allocator();
|
|
const old_id = (bson.get_at(a, coll.doc_bytes(old_off), "_id") catch return) orelse return;
|
|
const new_id = doc.get("_id") orelse return;
|
|
const old_bytes = bson.serialize_value(a, old_id) catch return;
|
|
const new_bytes = bson.serialize_value(a, new_id) catch return;
|
|
if (std.mem.eql(u8, old_bytes, new_bytes)) return; // an ordinary replace
|
|
std.debug.print(
|
|
"multiforadb: WARNING: {s}.{s} holds two documents whose _id values compare " ++
|
|
"equal but were stored differently; keeping the later one. This is a " ++
|
|
"one-way migration -- _id uniqueness is canonical now, as in MongoDB.\n",
|
|
.{ record.db, record.coll },
|
|
);
|
|
}
|
|
|
|
/// The canonical `_id` key the engine looks documents up by, owned by the caller.
|
|
/// Tests used `bson.serialize_value` when a hashmap keyed on it; `_id_` is keyed
|
|
/// on `bson.encode_key`, which is the canonical encoding.
|
|
fn id_key_for(gpa: std.mem.Allocator, v: bson.Value) ![]u8 {
|
|
var enc: std.ArrayListUnmanaged(u8) = .empty;
|
|
errdefer enc.deinit(gpa);
|
|
try bson.encode_key(v, gpa, &enc);
|
|
return enc.toOwnedSlice(gpa);
|
|
}
|
|
|
|
/// Whether an offset falls inside one of the collection's current slab extents.
|
|
/// After a rebuild every live offset must, and that is what tells a repacked
|
|
/// index from one still holding pre-rebuild offsets -- the old bytes are on the
|
|
/// free list rather than overwritten, so reading them still succeeds.
|
|
fn offset_in_extents(coll: *const Collection, off: u64) bool {
|
|
return coll.run_of(off) != null;
|
|
}
|
|
|
|
/// Document bytes at an absolute file offset, without needing the Collection.
|
|
/// The rebuild works from offsets while the collection's own slab cursors are
|
|
/// being replaced under it.
|
|
fn doc_bytes_in(pager: *const pgr.Pager, off: u64) []const u8 {
|
|
const len: usize = std.mem.readInt(u32, pager.bytes(off, 4)[0..4], .little);
|
|
return pager.bytes(off, len);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Catalog encoding helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn put_u32(gpa: std.mem.Allocator, out: *std.ArrayListUnmanaged(u8), v: u32) !void {
|
|
var b: [4]u8 = undefined;
|
|
std.mem.writeInt(u32, &b, v, .little);
|
|
try out.appendSlice(gpa, &b);
|
|
}
|
|
|
|
fn put_u64(gpa: std.mem.Allocator, out: *std.ArrayListUnmanaged(u8), v: u64) !void {
|
|
var b: [8]u8 = undefined;
|
|
std.mem.writeInt(u64, &b, v, .little);
|
|
try out.appendSlice(gpa, &b);
|
|
}
|
|
|
|
fn put_bytes(gpa: std.mem.Allocator, out: *std.ArrayListUnmanaged(u8), v: []const u8) !void {
|
|
try put_u32(gpa, out, @intCast(v.len));
|
|
try out.appendSlice(gpa, v);
|
|
}
|
|
|
|
/// A bounds-checked cursor over the catalog. Every read is checked because the
|
|
/// bytes come off disk: a truncated or scrambled catalog must produce an error
|
|
/// the caller can fall back from, never a read past the end.
|
|
const Reader = struct {
|
|
b: []const u8,
|
|
at: usize = 0,
|
|
|
|
fn take(self: *Reader, n: usize) ![]const u8 {
|
|
if (self.at + n > self.b.len) return error.CorruptCatalog;
|
|
defer self.at += n;
|
|
return self.b[self.at..][0..n];
|
|
}
|
|
|
|
fn read_byte(self: *Reader) !u8 {
|
|
return (try self.take(1))[0];
|
|
}
|
|
|
|
fn read_u32(self: *Reader) !u32 {
|
|
return std.mem.readInt(u32, (try self.take(4))[0..4], .little);
|
|
}
|
|
|
|
fn read_u64(self: *Reader) !u64 {
|
|
return std.mem.readInt(u64, (try self.take(8))[0..8], .little);
|
|
}
|
|
|
|
fn read_bytes(self: *Reader) ![]const u8 {
|
|
const n = try self.read_u32();
|
|
return self.take(n);
|
|
}
|
|
};
|
|
|
|
test "the epochs that invalidate a cursor move exactly when they must" {
|
|
// Three separate promises, each one load-bearing for an open cursor:
|
|
//
|
|
// - a rebuild moves every document, so a saved slab offset is stale;
|
|
// - a drop-and-recreate under the same name is a different collection,
|
|
// which a cursor holding only namespace strings cannot otherwise see;
|
|
// - `Index.reset_tree` re-creates node ids 0 and 1 as different nodes, so a
|
|
// saved (leaf, slot) position becomes valid-and-wrong rather than absent.
|
|
//
|
|
// A cursor's whole safety story is these three bumps, so assert them here
|
|
// rather than inferring them from cursor behaviour later.
|
|
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();
|
|
|
|
try engine.lock();
|
|
var i: i32 = 0;
|
|
while (i < 40) : (i += 1) {
|
|
var d = try make_doc(gpa, i, "payload");
|
|
defer d.deinit();
|
|
try engine.insert("app", "c", &d, &env.gen);
|
|
}
|
|
// Garbage, so the collection is worth rewriting: `compact` skips a
|
|
// collection with nothing to reclaim.
|
|
i = 0;
|
|
while (i < 20) : (i += 1) _ = try engine.remove_by_id("app", "c", .{ .int32 = i });
|
|
try engine.commit();
|
|
const before = engine.get_collection("app", "c").?.layout_epoch;
|
|
engine.unlock();
|
|
try testing.expect(before != 0);
|
|
|
|
// A rebuild moves documents, so the epoch must move with them.
|
|
try engine.compact();
|
|
try engine.lock();
|
|
const after_rebuild = engine.get_collection("app", "c").?.layout_epoch;
|
|
engine.unlock();
|
|
try testing.expect(after_rebuild != before);
|
|
|
|
// A recreated collection must not be mistaken for the one that was
|
|
// dropped. Starting each collection's epoch at zero would fail here.
|
|
try engine.lock();
|
|
try testing.expect(try engine.drop_collection("app", "c"));
|
|
var fresh_doc = try make_doc(gpa, 1, "fresh");
|
|
defer fresh_doc.deinit();
|
|
try engine.insert("app", "c", &fresh_doc, &env.gen);
|
|
const after_recreate = engine.get_collection("app", "c").?.layout_epoch;
|
|
engine.unlock();
|
|
try testing.expect(after_recreate != after_rebuild);
|
|
try testing.expect(after_recreate != before);
|
|
|
|
// Reclamation does not move a live document, so a cursor's *live* offsets
|
|
// stay good -- but the pages it gives back can be handed out again, and a
|
|
// cursor's saved offset list may name one of them. Same remedy, and the
|
|
// same token.
|
|
//
|
|
// Both halves matter. A checkpoint that reclaims nothing must leave the
|
|
// epoch alone, or every open cursor on a busy collection dies on the
|
|
// checkpoint cadence for nothing.
|
|
try engine.lock();
|
|
var j: i32 = 0;
|
|
while (j < 200) : (j += 1) {
|
|
var d = try make_padded(gpa, 1000 + j, 2000);
|
|
defer d.deinit();
|
|
try engine.insert("app", "c", &d, &env.gen);
|
|
}
|
|
try engine.commit();
|
|
const quiet_before = engine.get_collection("app", "c").?.layout_epoch;
|
|
try engine.checkpoint();
|
|
try testing.expectEqual(quiet_before, engine.get_collection("app", "c").?.layout_epoch);
|
|
|
|
j = 0;
|
|
while (j < 200) : (j += 1) _ = try engine.remove_by_id("app", "c", .{ .int32 = 1000 + j });
|
|
try engine.commit();
|
|
try engine.checkpoint();
|
|
const after_reclaim = engine.get_collection("app", "c").?.layout_epoch;
|
|
try testing.expect(engine.get_collection("app", "c").?.reclaimed_bytes > 0);
|
|
try testing.expect(after_reclaim != quiet_before);
|
|
|
|
// And the index-level token, which guards the position hint.
|
|
const coll = engine.get_collection("app", "c").?;
|
|
const index_before = coll.id_index.epoch;
|
|
try coll.id_index.reset_tree(gpa);
|
|
try testing.expect(coll.id_index.epoch != index_before);
|
|
engine.unlock();
|
|
}
|