db: a slab window with nothing live in it goes back to the pager

The reclamation itself. A checkpoint now begins by handing back every
`map_align` window whose dead-byte counter has reached `map_align`, splitting
the runs around what is kept.

Counting is the entire liveness test, and that is what makes this cheap.
`evict_doc` removes a document's index entries before marking its bytes dead,
so a window reaches `map_align` only once every document with a byte in it is
unreachable -- "no live bytes" and "no reference to these bytes" are the same
statement, established without scanning anything.

Inside `checkpoint` rather than a hook after it, because reclamation changes
two things that must agree: `slab_runs`, which the catalog describes, and the
pager's free list. One `publish` makes both durable. 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. No new record type, no
replay path, no ordering in between to get wrong. It also means the write path
pays only for a counter update and the cadence of returning pages is the
checkpoint threshold.

`full_windows` counts windows that have reached `map_align`, so a collection
with nothing to give back is not scanned at all. Without it every checkpoint
would walk every window of every collection -- O(slab) regardless of workload,
and the workload this design is known not to help (small documents on 16 KiB
pages) is exactly the one that would pay it for nothing.

A failure changes nothing: the replacement run list is built whole before the
old one is touched, so out of memory means the garbage stays and the next
checkpoint tries again. Past the last fallible step the list is swapped in
first and the pages handed over second; a `free_pages` that fails there leaks
the run, which costs space. The other order would leave pages owned twice.

`slab_used` changes meaning from "bytes ever appended" to "slab consumed and
not yet given back". That is what keeps `slab_used - live_bytes` equal to the
garbage the collection still has, with no new persistent field -- both halves
are already in the catalog.

`layout_epoch` is bumped when, and only when, a collection actually gave
something back. Reclamation does not move a live document, so a cursor's live
offsets stay good; but a saved offset list can name a page now on the free
list, and reading it would succeed and return plausible garbage rather than
fail. `cursor_still_valid` kills such a cursor with QueryPlanKilled, as a
rebuild already does. Not bumping it otherwise matters just as much: every
cursor on a busy collection would die on the checkpoint cadence for nothing.

Three tests. The survivor: 199 of 200 documents deleted, and the window
holding the last one is not given back, still reads, and goes back only once
it is empty too. The deferral: a reclaimed page is in `free_hold` after the
publish that freed it and in `free_ready` only after the next one -- asserted
on the pager's lists, since `free_ready_pages()` also moves for copy-on-write
victims. And the epoch, added to the existing three-promise cursor test:
unmoved by a checkpoint that reclaims nothing, moved by one that does.

Two Stage 0.6 tests changed, in the direction that was the point. "the slab
counts what the appender skips" asserted that an abandoned extent tail
survives a checkpoint, because only a rebuild could reclaim it; now the
checkpoint gives 4 MiB of it straight back and what stays is the edges. "a
rebuild leaves behind what its own copying skipped" asserted 2-4 MiB left
after a rebuild; the checkpoint inside `compact` reclaims most of that now, so
it asserts the same thing about a smaller number -- the counter is not zeroed
and equals what the collection has.

Mutations: relax the fullness test to accept a window 2 KiB short of empty and
the suite aborts on the append-cursor assertion (the appender's own window is
the first thing a loosened test reaches); drop that assertion too and the
survivor test goes red alone. Remove the `reclaim_slabs` call, 5 tests fail.
Remove the epoch bump, 1. Invert the `full_windows` shortcut, 5.

182/182 unit tests in ReleaseFast and ReleaseSafe, 82/82 fuzz, e2e 49, e2e2
concurrent 2 and the crash pair, e2e3 16, e2e4 17, e2e6 72, e2e7 86 (the
cursor suite, which the epoch bump was most likely to redden), crash-fuzz 60
cycles.
This commit is contained in:
A.Shakhmatov
2026-08-09 16:50:33 +03:00
parent 23b283ff44
commit afdb44fe90

View File

@@ -121,9 +121,16 @@ pub const Collection = struct {
/// extent it falls in. /// extent it falls in.
slab_tail: u64, slab_tail: u64,
slab_end: u64, slab_end: u64,
/// Document bytes written into this collection's slab since the last /// Slab this collection has consumed and not yet given back. `slab_tail`
/// rebuild. `slab_tail` cannot answer that -- it is an absolute file offset, /// cannot answer that -- it is an absolute file offset, so it jumps forward
/// so it jumps forward whenever a fresh extent is taken. /// 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, slab_used: u64,
/// This collection's outstanding page promise, for the document slab. Per /// This collection's outstanding page promise, for the document slab. Per
/// collection because concurrent writers must not release each other's -- /// collection because concurrent writers must not release each other's --
@@ -148,6 +155,15 @@ pub const Collection = struct {
/// update. What it costs is only that garbage from before a restart is not /// update. What it costs is only that garbage from before a restart is not
/// reclaimed window-wise; it still arms compaction like any other. /// reclaimed window-wise; it still arms compaction like any other.
dead_unlocated: u64, 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 /// Slab handed back to the pager by window reclamation, cumulative for the
/// life of the process. Purely an observation: it is what distinguishes /// life of the process. Purely an observation: it is what distinguishes
/// "the ratio improved because reclamation worked" from "the ratio improved /// "the ratio improved because reclamation worked" from "the ratio improved
@@ -203,6 +219,7 @@ pub const Collection = struct {
.slab_used = 0, .slab_used = 0,
.live_bytes = 0, .live_bytes = 0,
.dead_unlocated = 0, .dead_unlocated = 0,
.full_windows = 0,
.reclaimed_bytes = 0, .reclaimed_bytes = 0,
.hold = .{}, .hold = .{},
.indexes = .empty, .indexes = .empty,
@@ -316,7 +333,9 @@ pub const Collection = struct {
// this means the same range was marked twice -- a double eviction, // this means the same range was marked twice -- a double eviction,
// or a recycled offset marked against the previous owner's map. // 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"); 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); r.dead[w] += @intCast(n);
if (!was_full and r.dead[w] == pgr.map_align) self.full_windows += 1;
pos += n; pos += n;
} }
if (pos < stop) self.dead_unlocated += stop - pos; if (pos < stop) self.dead_unlocated += stop - pos;
@@ -338,6 +357,122 @@ pub const Collection = struct {
fn free_runs(self: *Collection, gpa: std.mem.Allocator) void { fn free_runs(self: *Collection, gpa: std.mem.Allocator) void {
for (self.slab_runs.items) |r| gpa.free(r.dead); for (self.slab_runs.items) |r| gpa.free(r.dead);
self.slab_runs.clearRetainingCapacity(); 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 /// Append `bytes` to the slab, returning its flat offset. The last
@@ -2314,6 +2449,62 @@ pub const Engine = struct {
self.committed_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. /// Publish the current state as a checkpoint.
/// ///
/// The watermark equals the sequence the log has already made durable, never /// The watermark equals the sequence the log has already made durable, never
@@ -2323,6 +2514,7 @@ pub const Engine = struct {
/// D6) reduced to an ordering. /// D6) reduced to an ordering.
pub fn checkpoint(self: *Engine) !void { pub fn checkpoint(self: *Engine) !void {
try self.commit(); try self.commit();
self.reclaim_slabs();
var buf: std.ArrayListUnmanaged(u8) = .empty; var buf: std.ArrayListUnmanaged(u8) = .empty;
defer buf.deinit(self.gpa); defer buf.deinit(self.gpa);
@@ -3350,6 +3542,134 @@ test "a restart forgets where the garbage is, not that there is any" {
while (it.next()) |entry| try testing.expect(coll.run_of(entry.off) != null); 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 replace that changes nothing is not a write" { test "a replace that changes nothing is not a write" {
// Mutation check: delete the byte comparison in `upsert`'s `.replace` arm. // 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 // Red on all three: the log grows, the document is superseded so the engine
@@ -3656,9 +3976,19 @@ test "the slab counts what the appender skips" {
try testing.expectEqual(gap + abandoned, engine.dead_bytes); try testing.expectEqual(gap + abandoned, engine.dead_bytes);
try testing.expectEqual(coll.slab_used - coll.live_bytes, engine.dead_bytes); try testing.expectEqual(coll.slab_used - coll.live_bytes, engine.dead_bytes);
// And it survives the round trip, because it is in `slab_used`. // 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 engine.checkpoint();
try testing.expectEqual(gap + abandoned, engine.dead_bytes); 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" { test "a rebuild leaves behind what its own copying skipped" {
@@ -3706,11 +4036,16 @@ test "a rebuild leaves behind what its own copying skipped" {
const coll = engine.get_collection("app", "c").?; const coll = engine.get_collection("app", "c").?;
try testing.expectEqual(@as(u64, 2), coll.doc_count); try testing.expectEqual(@as(u64, 2), coll.doc_count);
// The deleted document is gone from the slab, but the gap the copy left // The deleted document is gone from the slab, and the gap the copy left
// between the two survivors is not -- and the engine says so. // 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.expectEqual(coll.slab_used - coll.live_bytes, engine.dead_bytes);
try testing.expect(engine.dead_bytes > 2 * 1024 * 1024); try testing.expect(coll.reclaimed_bytes > 2 * 1024 * 1024);
try testing.expect(engine.dead_bytes < 4 * 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" { test "dropping a collection does not arm compaction" {
@@ -5119,8 +5454,35 @@ test "the epochs that invalidate a cursor move exactly when they must" {
try testing.expect(after_recreate != after_rebuild); try testing.expect(after_recreate != after_rebuild);
try testing.expect(after_recreate != before); try testing.expect(after_recreate != before);
// And the index-level token, which guards the position hint. // 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(); 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 coll = engine.get_collection("app", "c").?;
const index_before = coll.id_index.epoch; const index_before = coll.id_index.epoch;
try coll.id_index.reset_tree(gpa); try coll.id_index.reset_tree(gpa);