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:
@@ -1944,6 +1944,16 @@ fn dispatch_insert(
|
||||
defer reply.deinit();
|
||||
try dispatch(&ctx, &msg, &reply);
|
||||
try testing.expectEqual(@as(f64, 1.0), bson.get_pair(reply.pairs.items, "ok").?.double);
|
||||
// `ok: 1` is not success for an insert batch: a rejected document comes
|
||||
// back as a writeError alongside it. Asserting only `ok` let a corpus
|
||||
// silently shrink -- when _id_ became a unique index, the mixed-type
|
||||
// corpus below lost its int64 1 document and every test over it still
|
||||
// passed, over nine documents instead of ten.
|
||||
if (bson.get_pair(reply.pairs.items, "writeErrors")) |we| {
|
||||
std.debug.print("dispatch_insert: unexpected writeErrors: {any}\n", .{we});
|
||||
return error.TestUnexpectedResult;
|
||||
}
|
||||
try testing.expectEqual(@as(i32, @intCast(docs.len)), bson.get_pair(reply.pairs.items, "n").?.int32);
|
||||
}
|
||||
|
||||
/// Dispatch createIndexes for one spec.
|
||||
@@ -2341,6 +2351,73 @@ test "count_only_pipeline accepts only shapes a count can answer" {
|
||||
try testing.expect(try count_only_pipeline(&reply, &.{}) == null);
|
||||
}
|
||||
|
||||
test "compare-equal _id encodings collide under the unique _id_ index" {
|
||||
// _id uniqueness moved from a docs-map probe keyed on serialize_value to
|
||||
// the _id_ B+tree, keyed on the canonical bson.encode_key (PLAN A3/A4).
|
||||
// That changes observable behavior, in MongoDB's direction: int32 1,
|
||||
// int64 1 and double 1.0 are one _id, not three.
|
||||
//
|
||||
// Mutation check: setting `unique = false` back on id_index makes these
|
||||
// collisions vanish (this test and "insert counts only successful inserts"
|
||||
// both go red).
|
||||
//
|
||||
// The other half of the change is covered elsewhere, which is worth
|
||||
// knowing so nobody re-checks it here: passing `id_key` instead of null as
|
||||
// check_unique's exclude on the insert path is caught by "insert counts
|
||||
// only successful inserts", not by this test. Two documents with the
|
||||
// *same* encoding share an id_key, so exclude-self hides the collision;
|
||||
// int32 1 and int64 1 have different id_keys, so this test survives that
|
||||
// mutation. The two tests are complementary, not redundant.
|
||||
var threaded: std.Io.Threaded = .init_single_threaded;
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
var tdb = try TestDb.init(io);
|
||||
defer tdb.deinit();
|
||||
|
||||
try dispatch_insert(&tdb, io, "ids", &.{
|
||||
.{ .doc = &.{.{ .key = "_id", .value = .{ .int32 = 1 } }} },
|
||||
});
|
||||
|
||||
// Each of these is the same _id as int32 1.
|
||||
for ([_]bson.Value{
|
||||
.{ .doc = &.{.{ .key = "_id", .value = .{ .int64 = 1 } }} },
|
||||
.{ .doc = &.{.{ .key = "_id", .value = .{ .double = 1.0 } }} },
|
||||
}) |dup| {
|
||||
var ctx = tdb.ctx(io);
|
||||
const one = [_]bson.Value{dup};
|
||||
var msg = try parse_fake_msg("insert", .{ .string = "ids" }, &.{
|
||||
.{ .key = "documents", .value = .{ .array = &one } },
|
||||
});
|
||||
defer msg.deinit();
|
||||
var reply = wire.Reply.init(testing.allocator);
|
||||
defer reply.deinit();
|
||||
try dispatch(&ctx, &msg, &reply);
|
||||
const we = bson.get_pair(reply.pairs.items, "writeErrors") orelse return error.TestUnexpectedResult;
|
||||
const first = we.array[0];
|
||||
try testing.expectEqual(@as(i32, 11000), bson.get_pair(first.doc, "code").?.int32);
|
||||
// The index named in the message is the one MongoDB names.
|
||||
const errmsg = bson.get_pair(first.doc, "errmsg").?.string;
|
||||
try testing.expect(std.mem.indexOf(u8, errmsg, "_id_") != null);
|
||||
}
|
||||
|
||||
// A different rank is a different _id: "1" is not 1.
|
||||
try dispatch_insert(&tdb, io, "ids", &.{
|
||||
.{ .doc = &.{.{ .key = "_id", .value = .{ .string = "1" } }} },
|
||||
});
|
||||
|
||||
// And the collision is not merely rejection: a lookup by any of the
|
||||
// equivalent encodings finds the one stored document.
|
||||
for ([_]bson.Value{ .{ .int32 = 1 }, .{ .int64 = 1 }, .{ .double = 1.0 } }) |probe| {
|
||||
var ids: std.ArrayListUnmanaged([]u8) = .empty;
|
||||
defer {
|
||||
for (ids.items) |id| testing.allocator.free(id);
|
||||
ids.deinit(testing.allocator);
|
||||
}
|
||||
try dispatch_find_ids(&tdb, io, "ids", &.{.{ .key = "_id", .value = probe }}, &ids);
|
||||
try testing.expectEqual(@as(usize, 1), ids.items.len);
|
||||
}
|
||||
}
|
||||
|
||||
test "indexed queries are equivalent to scans over a mixed corpus" {
|
||||
var threaded: std.Io.Threaded = .init_single_threaded;
|
||||
defer threaded.deinit();
|
||||
@@ -2348,9 +2425,16 @@ test "indexed queries are equivalent to scans over a mixed corpus" {
|
||||
var tdb = try TestDb.init(io);
|
||||
defer tdb.deinit();
|
||||
|
||||
// Corpus deliberately mixes numeric _id encodings (int32 1 and int64 1
|
||||
// compare equal but hash differently), arrays, nested docs, missing
|
||||
// fields, explicit nulls, and duplicate values.
|
||||
// Corpus deliberately mixes numeric _id encodings, arrays, nested docs,
|
||||
// missing fields, explicit nulls, and duplicate values.
|
||||
//
|
||||
// It used to carry int32 1 and int64 1 as two documents, to exercise the
|
||||
// old docs-map fast path (they compare equal but serialize differently).
|
||||
// _id_ is a unique index now, keyed on the canonical bson.encode_key, so
|
||||
// those two *are* the same _id and the second is rejected -- which is
|
||||
// MongoDB's behavior. The int64 encoding still appears below, on a
|
||||
// distinct value; the collision itself is asserted in "compare-equal _id
|
||||
// encodings collide under the unique _id_ index".
|
||||
const corpus = [_]bson.Value{
|
||||
.{ .doc = &.{
|
||||
.{ .key = "_id", .value = .{ .int32 = 1 } },
|
||||
@@ -2359,7 +2443,7 @@ test "indexed queries are equivalent to scans over a mixed corpus" {
|
||||
.{ .key = "tags", .value = .{ .array = &.{ .{ .string = "a" }, .{ .string = "b" } } } },
|
||||
} },
|
||||
.{ .doc = &.{
|
||||
.{ .key = "_id", .value = .{ .int64 = 1 } },
|
||||
.{ .key = "_id", .value = .{ .int64 = 2 } },
|
||||
.{ .key = "a", .value = .{ .int32 = 20 } },
|
||||
.{ .key = "b", .value = .{ .string = "y" } },
|
||||
.{ .key = "tags", .value = .{ .array = &.{} } },
|
||||
|
||||
Reference in New Issue
Block a user