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:
56
src/db.zig
56
src/db.zig
@@ -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 => {},
|
||||
|
||||
Reference in New Issue
Block a user