index/db: enforce _id uniqueness through the _id_ index

_id uniqueness was a `coll.docs.contains` probe. The docs hashmap is going
away (PLAN A3), so it has to move to the _id_ tree -- and the tree answers
better, because it is keyed on bson.encode_key, which is canonical where
serialize_value is not. int32 1, int64 1 and double 1.0 are now one _id, as
they are in MongoDB (A4).

_id_ is built and checked first, so a write violating both it and a unique
secondary reports _id_, which is what MongoDB reports. It returns
error.DuplicateKey with `dup_index` left null, which is exactly what
commands.zig's E11000 rendering already treats as "the _id_ index", so the
wire-visible message is unchanged and that file needed no edit.

check_unique's exclude-self became optional and is null on an insert. That was
a latent bug of its own: a replace must ignore its own existing entries, but an
insert has none, and passing the document's id there hides a collision whose
entry carries that same id -- precisely the case _id_ exists to catch. Only
_id_ could reach it, since a secondary collision is between different
documents.

Two corrections found while doing this, both worth reading:

PLAN A4 claimed a database already holding {_id: int32 1} and {_id: int64 1}
loses one on reopen. It does not. Replay evicts through the docs map, keyed on
serialize_value, so both survive; the tree is bulk-built afterwards with
enforcement off, which tolerates duplicate keys and warns. The loss arrives
only with the commit that drops the map, and that is where it needs a
pre-flight scan. Amended.

dispatch_insert asserted only `ok: 1`, but a rejected document comes back as a
writeError alongside it -- so the mixed-type corpus silently shrank from ten
documents to nine when _id_ became unique, and every test over it still passed.
The helper now rejects writeErrors and asserts the inserted count; it caught
the shrink immediately. The corpus keeps an int64 _id on a distinct value, and
the collision it used to stand in for is asserted directly.

Also adds Index.lookup_exact, which the commands that currently probe the docs
map will need. Exact byte equality rather than cmp_prefix, because {a: 1}'s
encoding is a proper prefix of {a: 1, b: 2}'s and a prefix match would claim a
document is present when it is not.

Mutation-checked, all three red: unique=false on id_index; exclude=id_key on
insert; eql -> cmp_prefix in lookup_exact.
This commit is contained in:
2026-08-03 18:15:21 +03:00
parent f61416f44a
commit aee23cb028
4 changed files with 206 additions and 26 deletions

View File

@@ -65,7 +65,12 @@ pub const Collection = struct {
fn init(gpa: std.mem.Allocator) !Collection {
var self: Collection = .{ .docs = .empty, .slab = .empty, .seg_starts = .empty, .indexes = .empty, .id_index = undefined };
const keys = [_]index.IndexKey{.{ .path = "_id", .descending = false }};
self.id_index = try index.Index.init(gpa, "_id_", &keys, false, false, null);
// 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, "_id_", &keys, true, false, null);
return self;
}
@@ -554,6 +559,17 @@ pub const Engine = struct {
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, id_key);
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, id_key);
built_list.append(self.gpa, .{ .built = built, .ix = ix }) catch |err| {
@@ -561,24 +577,24 @@ pub const Engine = struct {
return err;
};
}
{
// The implicit _id_ index, through the same protocol: reserved
// before the log append, inserted infallibly after it.
var built = try coll.id_index.build_entries(self.gpa, doc_bytes, id_key);
built_list.append(self.gpa, .{ .built = built, .ix = &coll.id_index }) catch |err| {
built.deinit(self.gpa);
return err;
};
}
// 2. The _id check, mirroring the pre-index behavior.
if (mode == .insert and coll.docs.contains(id_key)) return error.DuplicateKey;
// 3. Unique secondary-index checks; a rejected write never reaches
// the log.
// 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 id would hide precisely the
// same-_id collision this must catch.
const exclude: ?[]const u8 = if (mode == .replace) id_key else null;
for (built_list.items) |*b| {
if (!b.ix.unique) continue;
b.ix.check_unique(b.built.entries.items, id_key) catch {
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;
};
@@ -1261,6 +1277,14 @@ fn apply_record(ctx: *anyopaque, record: storage.Record, doc: *bson.Document) an
key_owned = true;
// 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_key),
else => {},