cursors: server-side cursors for find, aggregate and the listing commands

Every reply came back in a single batch with `cursor.id = 0`, `getMore` was a
stub answering an empty `nextBatch` on the literal namespace `test.$cmd`, and
nothing read `batchSize`. That caps the useful collection size at what fits in
one 48 MiB message, which is the opposite of the tens-of-GB target and the
reason M0 made whole-index scans stream: the streaming candidate generator
existed with no consumer that could suspend.

## What a cursor is allowed to remember

A cursor holds no lock between requests, so everything it saves has to survive
arbitrary concurrent mutation. Nothing here is a pointer, and the two things
that look like stable addresses are not: `reset_tree` re-creates node ids 0 and
1 as different nodes, and `rebuild_collection` moves every document. Three
sources, chosen by query shape, each with a different memory contract:

- **stream** -- an index-ordered walk resumed from a `(key, off)` anchor plus a
  `(leaf, slot)` hint. O(key) state, so this is what lets a cursor walk a
  collection larger than memory. Survives a rebuild, because a repack changes
  no key.
- **offsets** -- the matched slab offsets a narrowed plan already materialized,
  8 bytes each. Killed by a rebuild with `QueryPlanKilled`, because those
  offsets now name unrelated bytes.
- **buffered** -- canonical BSON copies, for a sort no index provides and for
  aggregate/listing output. Depends on nothing, which is what lets a listing
  hold a cursor over a `$cmd.*` namespace no collection backs.

`Collection.layout_epoch` and `Index.epoch` are the invalidation tokens, both
checked as error returns rather than assertions since a client reaches them by
keeping a cursor open across maintenance.

## Resume

`resume_forward`/`resume_reverse` are O(1) while the hint holds and fall back to
an exact-order band walk bounded by `resume_walk_max`. Without the hint, `seek`
lands at the *start* of an equal-key band, so `sort({status: 1})` over three
distinct values across 10M documents would cost ~5e10 comparisons to drain.

Two hazards found by draining a collection while writing to it, neither
predictable from reading the code:

- A deleted anchor must resume at its *band position*, or the rest of an
  equal-key band is silently dropped -- most of the collection on a
  low-cardinality index. Hence `band_index`.
- On a **unique** index a same-key entry can only be the anchor rewritten, so
  resuming at it returned updated documents twice. Observed as duplicate `_id`s
  while updating underneath a drain.

## Protocol

Measured against mongod 8.3.7 rather than recalled, which corrected three
assumptions: a bare `getMore` does *not* inherit the find's `batchSize` (4998 of
5000 documents come back), a namespace mismatch is `Unauthorized` (13) not
`CursorNotFound`, and `CursorInUse` is 143 not 12051.
`internalQueryFindCommandBatchSize` is 101, `cursorTimeoutMillis` 600000,
`clientCursorMonitorFrequencySecs` 4.

The rule everything follows is **never look ahead**: a batch that met its target
leaves the cursor open even when the source is in fact exhausted, so four
documents at `batchSize: 2` take three commands. `limit` acts as an EOF source,
which is what makes `batchSize == limit` close in one round trip. `skip` is
consumed once. `batchSize: 0` returns an empty batch with a live cursor.

Cursor ids are `(nonce << 20) | slot`, always positive. The nonce is not
decoration: without it a recycled slot serves one client another's documents.
Cursors are not connection-pinned, since the driver spec allows a `getMore` on
any connection to the same server; they end at exhaustion, `killCursors`, or the
idle sweep (a second monitor fiber, separate from the TTL one because the
cadences differ by an order of magnitude and a TTL failure must not stop
reclamation). The registry is fixed-capacity and evicts the least recently used
cursor, whose client sees the same 43 an idle timeout gives.

Fixed alongside, because cursors are what expose them:

- `listCollections` reported `"<db>."` with an *empty* collection part, which
  makes the driver throw client-side -- so it would have broken the moment its
  cursor stopped being id 0. Now `<db>.$cmd.listCollections`, as mongod uses.
- `count` ignored `skip` and `limit` entirely.
- `wire.end_message` now bounds a reply by the 48 MiB we advertise rather than
  by `maxInt(u32)`; a reply past what we told the client to expect is not a large
  reply, it is a desynchronized connection.
- Two `codeName` strings were wrong: 72 is `InvalidOptions` (MongoDB has no
  `InvalidArgument`), and 40324 reports as `Location40324`.

## Verification

Unit 160/160 in ReleaseFast and ReleaseSafe; `tests/e2e/e2e7.js` adds 86 cursor
checks across five phases (batching/lifecycle/errors, streaming across churn,
aggregate+listings+count, expiry+capacity, restart) and is self-contained
because cursor behaviour is only observable with non-default flags. No
regressions: e2e 49, e2e3 16, e2e4 17, e2e2 2, e2e6 72. Spec 168 pass / 124
fail, +5 against the previous scorecard.

Mutation-checked, per the repo's second ground rule: `hint_slot + 1`, the
`band_index` off-by-one, both epoch bumps, the id nonce, the at-least-one-
document rule, and `stream_shape` returning null each turn the intended test
red. One claim was withdrawn rather than kept -- swapping `std.mem.order` for
`cmp_prefix` in the band walk changes nothing observable, so the comment now
says so instead of asserting a check that does not hold.
This commit is contained in:
2026-08-04 14:54:27 +03:00
parent cd88e1a4d1
commit f2844e7894
15 changed files with 3489 additions and 176 deletions

View File

@@ -106,7 +106,9 @@ const page_size = 4096;
/// Bytes of node payload: a 32-byte header plus the slotted region.
const page_data = page_size - 32;
/// Records longer than a quarter of a node spill to the overflow slab.
const inline_limit = page_size / 4;
/// Public because it is also the bound a resumable cursor anchors within: a key
/// past it has already spilled, so the tree itself treats it as exceptional.
pub const inline_limit = page_size / 4;
/// Upper bound on the slots one node can hold, since every slot costs at
/// least its own size. Bounds the split scratch.
const max_slots = page_data / slot_size;
@@ -234,6 +236,19 @@ pub const Index = struct {
depth: u32,
/// Total entries, maintained incrementally.
entry_count: usize,
/// Bumped whenever a node id stops meaning what it meant, which is the one
/// thing that makes a saved `(leaf, slot)` position dangerous rather than
/// merely stale. Node ids are otherwise append-only (`alloc_node`, and
/// `drop_child` abandons a page without recycling its id), and `page()`
/// resolves ids through `node_pages`, so copy-on-write and checkpoints move
/// pages without disturbing ids. Only `reset_tree` and
/// `replace_root_with_leaf` reuse an id for different contents.
///
/// A resumable cursor keeps a position hint to avoid walking an equal-key
/// band on every `getMore`; it must compare this first. Without it the hint
/// would address a live but unrelated leaf after a compaction and the cursor
/// would iterate a tree that no longer exists.
epoch: u64,
/// Repack scratch: any single node's record bytes fit here.
scratch: [page_data]u8,
/// Promoted-key scratch: inline keys being propagated up a split are
@@ -268,6 +283,7 @@ pub const Index = struct {
.leaf_count = 0,
.depth = 0,
.entry_count = 0,
.epoch = 0,
.scratch = undefined,
.promo = undefined,
};
@@ -614,6 +630,10 @@ pub const Index = struct {
self.depth = 0;
self.entry_count = 0;
self.multikey = false;
// Node ids 0 and 1 were just re-created as different nodes, so every
// position anyone saved into the old tree now points somewhere valid
// and wrong. This is the bump that tells them apart.
self.epoch += 1;
}
/// Remove every entry for `id`, in one pass over the leaves. Infallible.
@@ -777,6 +797,14 @@ pub const Index = struct {
leaf: u32,
slot: u32,
/// `next`, plus the position of the entry it yielded. Exact because
/// `next` leaves `leaf` alone on the call that yields and has already
/// incremented `slot` past the entry.
pub fn positioned(self: *Iter) ?Positioned {
const e = self.next() orelse return null;
return .{ .key = e.key, .off = e.off, .leaf = self.leaf, .slot = self.slot - 1 };
}
pub fn next(self: *Iter) ?EntryRef {
const ix = self.ix;
while (self.leaf != 0) {
@@ -872,6 +900,13 @@ pub const Index = struct {
/// One past the slot to yield next, so 0 means this leaf is done.
slot: u32,
/// As `Iter.positioned`, but `RevIter.next` decrements *onto* the entry
/// it yields, so the slot needs no adjustment.
pub fn positioned(self: *RevIter) ?Positioned {
const e = self.next() orelse return null;
return .{ .key = e.key, .off = e.off, .leaf = self.leaf, .slot = self.slot };
}
pub fn next(self: *RevIter) ?EntryRef {
const ix = self.ix;
while (self.leaf != 0) {
@@ -919,6 +954,174 @@ pub const Index = struct {
return .{ .ix = self, .leaf = b.leaf, .slot = b.slot };
}
// -- resuming an interrupted scan ---------------------------------------
/// Entries a resume will walk past before giving up and reporting `capped`.
/// A bound rather than a hope: `seek` lands at the *start* of an equal-key
/// band, so without one a key with millions of duplicates would make every
/// batch cost O(band) and a full drain quadratic.
pub const resume_walk_max: u32 = 1 << 16;
/// One entry, with enough of its position to resume after it next time.
pub const Positioned = struct {
key: []const u8,
off: u64,
leaf: u32,
slot: u32,
};
/// A resumed forward walk. `capped` means the anchor could not be located
/// within `resume_walk_max` steps, so the position is not trustworthy and
/// the caller must fail rather than return documents from the wrong place.
pub const Resumed = struct { it: Iter, capped: bool = false };
pub const ResumedRev = struct { it: RevIter, capped: bool = false };
/// Does `(leaf, slot)` still hold exactly `(key, off)`?
///
/// A hint is never believed, only checked, and the checks are ordered so the
/// cheap structural ones run first: `off_of` asserts `is_leaf` with
/// `std.debug.assert`, which in ReleaseFast is a promise to the optimizer
/// rather than a check, so `is_leaf` must be tested for real beforehand.
///
/// Node ids are append-only, so a stale id is always in bounds; what makes a
/// hint dangerous rather than merely wrong is `reset_tree` re-creating ids 0
/// and 1 as different nodes, and `Index.epoch` is what the caller compares
/// for that.
fn hint_holds(self: *const Index, leaf: u32, slot: u32, key: []const u8, off: u64) bool {
if (leaf == 0 or leaf >= self.node_pages.items.len) return false;
const node = self.page(leaf);
if (node.is_leaf != 1) return false;
if (slot >= node.count) return false;
if (self.off_of(leaf, slot) != off) return false;
return std.mem.eql(u8, self.key_of(leaf, slot), key);
}
/// Locate the anchor `(key, off)` by walking its equal-key band.
///
/// Comparison is `std.mem.order`, not `cmp_prefix`, because the band is
/// defined as the entries whose key is byte-equal to the anchor's and prefix
/// semantics would call `"ab"` and `"abc"` equal. In fairness the two happen
/// to agree on where this function resumes -- the fallback is positional, and
/// the first out-of-band entry is the same entry either way -- so this is a
/// clarity choice, not a bug fix; an attempted mutation to `cmp_prefix` does
/// not change any observable result. What does matter is that `lower_bound`
/// uses prefix semantics and therefore errs *before* the band, never past it,
/// so the walk cannot start beyond the anchor and skip it.
///
/// When the anchor is gone, "gone" turns out to mean two different things and
/// they want opposite answers:
///
/// - **Deleted.** A sibling has moved up into the anchor's band position, and
/// that sibling has not been returned yet. Resume *at* band position
/// `band_index`. Resuming after the whole band instead would silently drop
/// every remaining member, which on a three-value index is most of the
/// collection.
/// - **Updated.** The document was rewritten, so its key is unchanged but its
/// offset moved. The entry at the anchor's band position *is* the anchor,
/// already returned. Resume *after* it.
///
/// The index cannot tell these apart in general -- both look like "same key,
/// different offset". On a **unique** index it can: two entries cannot share a
/// key, so a same-key entry is necessarily the same document, hence the update
/// case, hence resume past the band. That covers `_id_` and so every unsorted
/// scan and every `_id` sort, which is where an update-during-drain otherwise
/// returns a document twice -- observed as duplicate `_id`s draining a
/// collection that was being updated underneath.
///
/// On a non-unique index the positional fallback stands, so an updated document
/// may come back a second time. That is legal: MongoDB documents that a
/// non-snapshot cursor may return a document more than once if an intervening
/// write moves it.
fn band_resume(self: *const Index, key: []const u8, off: u64, band_index: u64) Resumed {
var it = self.seek(key);
var fallback: ?Iter = null;
var pos: u64 = 0;
var steps: u32 = 0;
while (steps < resume_walk_max) : (steps += 1) {
// The iterator state that would yield the entry we are about to
// look at, i.e. "resume *at* this entry".
const before = it;
const e = it.next() orelse break;
if (std.mem.order(u8, e.key, key) != .eq) {
// Past the band. Prefer the fallback if the band held one.
return .{ .it = fallback orelse before };
}
if (e.off == off) return .{ .it = it }; // resume just after the anchor
// On a unique index a same-key entry can only be the anchor itself,
// rewritten, so there is no sibling to fall back to.
if (!self.unique and pos == band_index and fallback == null) fallback = before;
pos += 1;
}
if (steps == resume_walk_max) return .{ .it = it, .capped = true };
return .{ .it = fallback orelse it };
}
/// A forward walk positioned just after `(key, off)`.
///
/// O(1) whenever the hint still holds, which is the case unless something
/// wrote to that exact leaf between batches. The band walk is the fallback,
/// and it is what the walk bound exists to contain.
pub fn resume_forward(
self: *const Index,
key: []const u8,
off: u64,
band_index: u64,
hint_leaf: u32,
hint_slot: u32,
hint_trusted: bool,
) Resumed {
if (hint_trusted and self.hint_holds(hint_leaf, hint_slot, key, off)) {
return .{ .it = .{ .ix = self, .leaf = hint_leaf, .slot = hint_slot + 1 } };
}
return self.band_resume(key, off, band_index);
}
/// A reverse walk positioned just before `(key, off)` in key order, i.e. at
/// the next entry a descending scan owes.
///
/// `RevIter` decrements before yielding, so slot `s` yields `s - 1` -- the
/// entry immediately below the anchor -- and crosses into `prev` when the
/// anchor sat at slot 0.
///
/// Known limitation, and it is a deliberate trade. When the anchor is gone
/// *and* it had duplicates, this resumes below the whole band rather than at
/// the anchor's position within it, so the band's remaining members are not
/// returned. Placing a reverse fallback exactly would need the band's length,
/// which is only known after walking it, hence a second walk on a path that
/// requires a descending scan over a duplicate-heavy index whose anchor was
/// deleted mid-cursor. Forward resumes -- every unsorted scan and every
/// ascending sort -- use `band_index` and have no such gap.
pub fn resume_reverse(
self: *const Index,
key: []const u8,
off: u64,
hint_leaf: u32,
hint_slot: u32,
hint_trusted: bool,
) ResumedRev {
if (hint_trusted and self.hint_holds(hint_leaf, hint_slot, key, off)) {
return .{ .it = .{ .ix = self, .leaf = hint_leaf, .slot = hint_slot } };
}
// Find the anchor by walking forward, then turn around on it.
var it = self.seek(key);
var steps: u32 = 0;
while (steps < resume_walk_max) : (steps += 1) {
const e = it.next() orelse break;
if (std.mem.order(u8, e.key, key) != .eq) break; // past the band
if (e.off == off) {
// `it` has already stepped past the anchor, so the anchor sat at
// `it.slot - 1` and a RevIter there yields the entry below it.
return .{ .it = .{ .ix = self, .leaf = it.leaf, .slot = it.slot - 1 } };
}
}
if (steps == resume_walk_max) {
return .{ .it = .{ .ix = self, .leaf = 0, .slot = 0 }, .capped = true };
}
// Anchor gone: resume below the band.
const b = self.lower_bound(key);
return .{ .it = .{ .ix = self, .leaf = b.leaf, .slot = b.slot } };
}
// -- serialization ------------------------------------------------------
/// The canonical spec document bytes
@@ -1710,6 +1913,9 @@ pub const Index = struct {
self.first_leaf = self.root;
self.leaf_count = 1;
self.depth = 0;
// The root's id is unchanged but it is a leaf now, so a saved position
// that named it as an internal node describes a different tree shape.
self.epoch += 1;
}
/// Pack the sorted staging array into a fresh tree: leaves filled in
@@ -3672,3 +3878,258 @@ test "planner picks eq run, ranges, and bails on sparse null" {
try testing.expect((try plan(gpa, null, &.{&ix}, &f, &.{})) == null);
}
}
/// Drain `ix` by resuming every `stride` entries, the way a cursor with that
/// batch size would, and return the offsets in the order they came out.
fn drain_resuming(
gpa: std.mem.Allocator,
ix: *const Index,
stride: u32,
backward: bool,
out: *std.ArrayListUnmanaged(u64),
) !void {
var started = false;
var anchor: std.ArrayListUnmanaged(u8) = .empty;
defer anchor.deinit(gpa);
var anchor_off: u64 = 0;
var band_index: u64 = 0;
var hint_leaf: u32 = 0;
var hint_slot: u32 = 0;
while (true) {
// One "batch": open a walk where the last one stopped.
var fwd: Index.Iter = undefined;
var rev: Index.RevIter = undefined;
if (!started) {
if (backward) rev = ix.iter_reverse() else fwd = ix.iter();
} else if (backward) {
const r = ix.resume_reverse(anchor.items, anchor_off, hint_leaf, hint_slot, true);
try testing.expect(!r.capped);
rev = r.it;
} else {
const r = ix.resume_forward(
anchor.items,
anchor_off,
band_index,
hint_leaf,
hint_slot,
true,
);
try testing.expect(!r.capped);
fwd = r.it;
}
var n: u32 = 0;
while (n < stride) : (n += 1) {
const e = if (backward)
rev.positioned()
else
fwd.positioned();
const got = e orelse return;
try out.append(gpa, got.off);
if (started and std.mem.eql(u8, anchor.items, got.key)) {
band_index += 1;
} else {
band_index = 0;
}
anchor.clearRetainingCapacity();
try anchor.appendSlice(gpa, got.key);
anchor_off = got.off;
hint_leaf = got.leaf;
hint_slot = got.slot;
started = true;
}
}
}
test "a resumed walk yields exactly what an uninterrupted one does" {
// The property the whole streaming cursor rests on: stopping and restarting
// a scan changes nothing about what it returns, in either direction, at any
// batch size, including one entry at a time.
//
// Mutation-checked: changing `resume_forward`'s `hint_slot + 1` to
// `hint_slot` makes every batch boundary repeat an entry, and this test goes
// red. (A third mutation was tried and rejected as meaningless: swapping
// `std.mem.order` for `cmp_prefix` inside `band_resume` changes nothing
// observable, so no test can catch it -- see the note there.)
//
// Note this test always resumes from a *valid* hint, since nothing mutates
// the tree between its batches. The band walk is covered by the two tests
// below, which invalidate the hint on purpose.
const gpa = testing.allocator;
// Three corpora, each hard for a different reason: distinct keys spanning
// several leaves and an interior level; a low-cardinality index whose bands
// span leaves; and *variable-length string keys in prefix relationships*
// ("a" < "ab" < "abc"), which is the only shape that can tell `std.mem.order`
// apart from `cmp_prefix` -- fixed-width integer keys never differ, so an
// integer-only corpus cannot catch that mistake at all.
const Shape = enum { distinct, duplicates, prefixes };
for ([_]Shape{ .distinct, .duplicates, .prefixes }) |shape| {
var ix = try simple_index(gpa, test_pager(), &.{"a"}, false, false);
defer ix.deinit(gpa);
const n = 400;
var key_buf: [40]u8 = undefined;
for (0..n) |i| {
const value: bson.Value = switch (shape) {
.distinct => .{ .int32 = @intCast(i + 1) },
.duplicates => .{ .int32 = @intCast(i % 3) },
// Every key is a prefix of the next in its group of eight, so
// each band start is also a proper prefix of later keys.
.prefixes => blk: {
const written = try std.fmt.bufPrint(&key_buf, "k{d}", .{i / 8});
const depth = (i % 8) + 1;
@memset(key_buf[written.len .. written.len + depth], 'x');
break :blk .{ .string = key_buf[0 .. written.len + depth] };
},
};
const d = try bytes_of(gpa, &.{
.{ .key = "_id", .value = .{ .int32 = @intCast(i + 1) } },
.{ .key = "a", .value = value },
});
defer gpa.free(d);
_ = try ix.add_doc(gpa, d, @intCast(i + 1), false);
}
try testing.expect(ix.depth >= 1);
for ([_]bool{ false, true }) |backward| {
var whole: std.ArrayListUnmanaged(u64) = .empty;
defer whole.deinit(gpa);
if (backward) {
var it = ix.iter_reverse();
while (it.next()) |e| try whole.append(gpa, e.off);
} else {
var it = ix.iter();
while (it.next()) |e| try whole.append(gpa, e.off);
}
try testing.expectEqual(@as(usize, n), whole.items.len);
for ([_]u32{ 1, 2, 7, 101, 399, 400, 1000 }) |stride| {
var resumed: std.ArrayListUnmanaged(u64) = .empty;
defer resumed.deinit(gpa);
try drain_resuming(gpa, &ix, stride, backward, &resumed);
try testing.expectEqualSlices(u64, whole.items, resumed.items);
}
}
}
}
test "a resume survives a split between batches" {
// A cursor holds no lock, so the tree it comes back to is not the tree it
// left. Inserting mid-drain moves entries between leaves and invalidates the
// position hint, which is exactly what the anchor is for.
const gpa = testing.allocator;
var ix = try simple_index(gpa, test_pager(), &.{"a"}, false, false);
defer ix.deinit(gpa);
const n = 200;
for (0..n) |i| {
// Even keys only, so the inserts below land between existing entries.
const d = try bytes_of(gpa, &.{
.{ .key = "_id", .value = .{ .int32 = @intCast(i + 1) } },
.{ .key = "a", .value = .{ .int32 = @intCast((i + 1) * 2) } },
});
defer gpa.free(d);
_ = try ix.add_doc(gpa, d, @intCast(i + 1), false);
}
var seen: std.ArrayListUnmanaged(u64) = .empty;
defer seen.deinit(gpa);
var anchor: std.ArrayListUnmanaged(u8) = .empty;
defer anchor.deinit(gpa);
var anchor_off: u64 = 0;
var hint_leaf: u32 = 0;
var hint_slot: u32 = 0;
var started = false;
var next_id: i32 = 10_000;
while (true) {
var it = if (!started) ix.iter() else blk: {
const r = ix.resume_forward(anchor.items, anchor_off, 0, hint_leaf, hint_slot, true);
try testing.expect(!r.capped);
break :blk r.it;
};
var n_in_batch: u32 = 0;
while (n_in_batch < 5) : (n_in_batch += 1) {
const got = it.positioned() orelse break;
try seen.append(gpa, got.off);
anchor.clearRetainingCapacity();
try anchor.appendSlice(gpa, got.key);
anchor_off = got.off;
hint_leaf = got.leaf;
hint_slot = got.slot;
started = true;
}
if (n_in_batch < 5) break;
// Between batches, insert odd keys across the whole range: guaranteed to
// split leaves and to appear both before and after the anchor.
for (0..20) |k| {
next_id += 1;
const d = try bytes_of(gpa, &.{
.{ .key = "_id", .value = .{ .int32 = next_id } },
.{ .key = "a", .value = .{ .int32 = @intCast(k * 19 + 1) } },
});
defer gpa.free(d);
_ = try ix.add_doc(gpa, d, @intCast(next_id), false);
}
}
// The 200 originals must each appear exactly once. Entries inserted behind
// the cursor may or may not show up -- that is ordinary non-snapshot cursor
// behaviour -- but nothing may be duplicated or lost.
var originals: u32 = 0;
var counts = std.AutoHashMap(u64, u32).init(gpa);
defer counts.deinit();
for (seen.items) |off| {
const e = try counts.getOrPutValue(off, 0);
e.value_ptr.* += 1;
try testing.expectEqual(@as(u32, 1), e.value_ptr.*); // no duplicates
if (off <= n) originals += 1;
}
try testing.expectEqual(@as(u32, n), originals);
}
test "a resume whose anchor was deleted keeps the rest of its band" {
// The failure this guards against is silent and large: with the anchor gone,
// resuming after the whole equal-key band drops every remaining member, and
// on a low-cardinality index that is most of the collection.
//
// Mutation-checked: `pos == band_index + 1` in `band_resume` shifts the
// resume by one entry and this test goes red.
const gpa = testing.allocator;
var ix = try simple_index(gpa, test_pager(), &.{"a"}, false, false);
defer ix.deinit(gpa);
// One key, 50 documents: a single band.
for (0..50) |i| {
const d = try bytes_of(gpa, &.{
.{ .key = "_id", .value = .{ .int32 = @intCast(i + 1) } },
.{ .key = "a", .value = .{ .int32 = 7 } },
});
defer gpa.free(d);
_ = try ix.add_doc(gpa, d, @intCast(i + 1), false);
}
// Yield three, then delete the third -- the anchor itself.
var it = ix.iter();
var third: Index.Positioned = undefined;
var band_index: u64 = 0;
for (0..3) |i| {
third = it.positioned().?;
if (i > 0) band_index += 1;
}
const anchor_key = try gpa.dupe(u8, third.key);
defer gpa.free(anchor_key);
ix.remove_off(third.off);
const r = ix.resume_forward(anchor_key, third.off, band_index, third.leaf, third.slot, true);
try testing.expect(!r.capped);
var rest: u32 = 0;
var walk = r.it;
while (walk.next()) |_| rest += 1;
// 50 inserted, 1 deleted, 2 already returned before the anchor: 47 left.
try testing.expectEqual(@as(u32, 47), rest);
}