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:
@@ -541,20 +541,38 @@ pub const Index = struct {
|
||||
/// Reject when any of `new_entries` has a key already present under a
|
||||
/// different id. Entries with `exclude_id` (the replacing document's
|
||||
/// own old entries) are allowed.
|
||||
/// `exclude_id` is the document whose own existing entries do not count as
|
||||
/// duplicates -- a replace re-inserts its entries, so its old ones must be
|
||||
/// ignored. It must be **null for an insert**, where the document has no
|
||||
/// entries yet: passing its id there would make a colliding entry with the
|
||||
/// *same* id invisible, which is exactly the case the implicit `_id_` index
|
||||
/// has to catch.
|
||||
pub fn check_unique(
|
||||
self: *const Index,
|
||||
new_entries: []const Entry,
|
||||
exclude_id: []const u8,
|
||||
exclude_id: ?[]const u8,
|
||||
) error{DuplicateKeyIndex}!void {
|
||||
for (new_entries) |e| {
|
||||
var it = self.seek(e.key);
|
||||
while (it.next()) |have| {
|
||||
if (cmp_prefix(e.key, have.key) != .eq) break;
|
||||
if (!std.mem.eql(u8, have.id, exclude_id)) return error.DuplicateKeyIndex;
|
||||
const is_self = exclude_id != null and std.mem.eql(u8, have.id, exclude_id.?);
|
||||
if (!is_self) return error.DuplicateKeyIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The id stored under exactly `key`, or null. Exact byte equality rather
|
||||
/// than `cmp_prefix`, because this answers "is this document present"
|
||||
/// rather than "what is in this key band". On a non-unique index it
|
||||
/// returns the first entry of the band.
|
||||
pub fn lookup_exact(self: *const Index, key: []const u8) ?[]const u8 {
|
||||
var it = self.seek(key);
|
||||
const e = it.next() orelse return null;
|
||||
if (!std.mem.eql(u8, e.key, key)) return null;
|
||||
return e.id;
|
||||
}
|
||||
|
||||
// -- search -------------------------------------------------------------
|
||||
|
||||
/// All ids whose key equals `key` (component-wise). For a partial key
|
||||
@@ -2201,6 +2219,46 @@ test "unique conflict across documents, replace of own entries allowed" {
|
||||
try expect_ids(gpa, &ix, &.{.{ .int32 = 10 }}, &.{});
|
||||
}
|
||||
|
||||
test "lookup_exact matches whole keys only" {
|
||||
// The point of exact byte equality rather than cmp_prefix: this answers
|
||||
// "is this document present", which is what the engine will ask once the
|
||||
// docs hashmap is gone, and a prefix match would answer yes for a longer
|
||||
// key that merely starts the same way. Compound keys make that concrete --
|
||||
// the encoding of {a: 1} is a prefix of the encoding of {a: 1, b: 2}.
|
||||
const gpa = testing.allocator;
|
||||
var ix = try simple_index(gpa, &.{ "a", "b" }, false, false);
|
||||
defer ix.deinit(gpa);
|
||||
|
||||
const d = try bytes_of(gpa, &.{
|
||||
.{ .key = "_id", .value = .{ .int32 = 1 } },
|
||||
.{ .key = "a", .value = .{ .int32 = 1 } },
|
||||
.{ .key = "b", .value = .{ .int32 = 2 } },
|
||||
});
|
||||
defer gpa.free(d);
|
||||
_ = try ix.add_doc(gpa, d, "e1", false);
|
||||
|
||||
var enc_full: std.ArrayListUnmanaged(u8) = .empty;
|
||||
defer enc_full.deinit(gpa);
|
||||
try bson.encode_key(.{ .int32 = 1 }, gpa, &enc_full);
|
||||
var enc_prefix: std.ArrayListUnmanaged(u8) = .empty;
|
||||
defer enc_prefix.deinit(gpa);
|
||||
try enc_prefix.appendSlice(gpa, enc_full.items);
|
||||
try bson.encode_key(.{ .int32 = 2 }, gpa, &enc_full);
|
||||
|
||||
// The full two-column key hits.
|
||||
try testing.expect(ix.lookup_exact(enc_full.items) != null);
|
||||
try testing.expectEqualStrings("e1", ix.lookup_exact(enc_full.items).?);
|
||||
// Its proper prefix must not, even though seek lands on the same entry.
|
||||
// Mutation check: swapping the eql for cmp_prefix == .eq makes this line
|
||||
// return "e1".
|
||||
try testing.expect(ix.lookup_exact(enc_prefix.items) == null);
|
||||
// And an absent key misses.
|
||||
var enc_other: std.ArrayListUnmanaged(u8) = .empty;
|
||||
defer enc_other.deinit(gpa);
|
||||
try bson.encode_key(.{ .int32 = 9 }, gpa, &enc_other);
|
||||
try testing.expect(ix.lookup_exact(enc_other.items) == null);
|
||||
}
|
||||
|
||||
test "range bounds inclusive and exclusive" {
|
||||
const gpa = testing.allocator;
|
||||
var ix = try simple_index(gpa, &.{"a"}, false, false);
|
||||
|
||||
Reference in New Issue
Block a user