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:
127
src/db.zig
127
src/db.zig
@@ -22,6 +22,7 @@ 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;
|
||||
@@ -97,8 +98,21 @@ pub const Collection = struct {
|
||||
/// 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) !Collection {
|
||||
fn init(gpa: std.mem.Allocator, pager: *pgr.Pager, layout_epoch: u64) !Collection {
|
||||
var self: Collection = .{
|
||||
.doc_count = 0,
|
||||
.pager = pager,
|
||||
@@ -110,6 +124,7 @@ pub const Collection = struct {
|
||||
.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
|
||||
@@ -290,6 +305,15 @@ pub const Engine = struct {
|
||||
/// 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
|
||||
@@ -324,6 +348,12 @@ pub const Engine = struct {
|
||||
/// 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();
|
||||
@@ -346,8 +376,10 @@ pub const Engine = struct {
|
||||
.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);
|
||||
}
|
||||
@@ -393,6 +425,17 @@ pub const Engine = struct {
|
||||
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| {
|
||||
@@ -400,6 +443,11 @@ pub const Engine = struct {
|
||||
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();
|
||||
@@ -936,6 +984,11 @@ pub const Engine = struct {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -943,6 +996,7 @@ pub const Engine = struct {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1156,7 +1210,8 @@ pub const Engine = struct {
|
||||
errdefer self.gpa.free(coll_key);
|
||||
const new_coll = try self.gpa.create(Collection);
|
||||
errdefer self.gpa.destroy(new_coll);
|
||||
new_coll.* = try Collection.init(self.gpa, self.pager);
|
||||
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;
|
||||
@@ -1389,6 +1444,13 @@ pub const Engine = struct {
|
||||
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;
|
||||
}
|
||||
|
||||
fn repack_index(
|
||||
@@ -3886,3 +3948,64 @@ const Reader = struct {
|
||||
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);
|
||||
}
|
||||
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);
|
||||
|
||||
// And the index-level token, which guards the position hint.
|
||||
try engine.lock();
|
||||
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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user