index/db: heap-allocate secondary indexes

Collection.indexes held Index by value, so orderedRemove memmoved the whole
~5 KB struct and every *Index already handed out referred to a different index
afterwards -- a query plan's `index` field, or a slice into an index's
promoted-key buffer. The collection's own bookkeeping stayed consistent, which
is why nothing noticed: only a caller holding a pointer across a drop could
see it, and no test did.

The new test does, and it is mutation-checked against the by-value code that
this commit replaces: holding pointers to b_1 and c_1, then dropping a_1, the
b_1 pointer reads "c_1". Now orderedRemove moves 8-byte pointers, the
surviving indexes do not move, and only the removed one is freed.

M0 needs this independently: an Index will own a file mapping once the node
arena moves into the data file, and copying one by value would duplicate that
ownership.

Not done, though the milestone plan listed it: moving Index's inline scratch
and promo buffers out of the struct. Their stated purpose was to keep those
5 KB out of a file-resident Index and to stop the memmove -- but only the node
arena and overflow slab become file-resident, not the Index metadata, and
boxing already fixed the memmove. Moving them would be churn with nothing left
to buy.
This commit is contained in:
2026-08-03 17:21:33 +03:00
parent 411a380d38
commit f61416f44a
3 changed files with 117 additions and 33 deletions

View File

@@ -1489,8 +1489,8 @@ pub const Index = struct {
/// The index whose key pattern is exactly `key_pairs` (same paths, same
/// order, same directions), or null. Keeps the IndexKey layout — and what
/// counts as a match — inside this module.
pub fn find_by_key_pattern(indexes: []const Index, key_pairs: []const bson.Pair) ?*const Index {
for (indexes) |*ix| {
pub fn find_by_key_pattern(indexes: []const *Index, key_pairs: []const bson.Pair) ?*const Index {
for (indexes) |ix| {
if (ix.keys.len != key_pairs.len) continue;
var match = true;
for (ix.keys, key_pairs) |k, kp| {
@@ -1838,7 +1838,7 @@ pub const Plan = struct {
pub fn plan(
gpa: std.mem.Allocator,
id_ix: ?*const Index,
indexes: []const Index,
indexes: []const *Index,
filter: []const bson.Pair,
sort: []const query.SortKey,
) !?Plan {
@@ -1853,7 +1853,7 @@ pub fn plan(
best = cand;
}
}
for (indexes) |*ix| {
for (indexes) |ix| {
var cand = (try evaluate_index(gpa, ix, clauses.items, sort)) orelse continue;
if (best) |b| {
if (plan_better(&cand, &b)) {
@@ -2883,7 +2883,7 @@ test "planner picks eq run, ranges, and bails on sparse null" {
.{ .key = "a", .value = .{ .int32 = 1 } },
.{ .key = "b", .value = .{ .int32 = 2 } },
};
var p = (try plan(gpa, null, &.{ix}, &f, &.{})).?;
var p = (try plan(gpa, null, &.{&ix}, &f, &.{})).?;
defer p.deinit(gpa);
try testing.expectEqual(@as(usize, 2), p.key_len());
try testing.expect(p.lo == null and p.hi == null);
@@ -2894,7 +2894,7 @@ test "planner picks eq run, ranges, and bails on sparse null" {
.{ .key = "a", .value = .{ .int32 = 1 } },
.{ .key = "b", .value = .{ .doc = &.{.{ .key = "$gt", .value = .{ .int32 = 2 } }} } },
};
var p = (try plan(gpa, null, &.{ix}, &f, &.{})).?;
var p = (try plan(gpa, null, &.{&ix}, &f, &.{})).?;
defer p.deinit(gpa);
try testing.expectEqual(@as(usize, 1), p.key_len());
try testing.expect(p.hi == null and p.lo != null and !p.lo_incl);
@@ -2902,14 +2902,14 @@ test "planner picks eq run, ranges, and bails on sparse null" {
// {a: 1} only → prefix run of 1.
{
const f = [_]bson.Pair{.{ .key = "a", .value = .{ .int32 = 1 } }};
var p = (try plan(gpa, null, &.{ix}, &f, &.{})).?;
var p = (try plan(gpa, null, &.{&ix}, &f, &.{})).?;
defer p.deinit(gpa);
try testing.expectEqual(@as(usize, 1), p.key_len());
}
// Pure range on the first key → key_len 0 with a bound.
{
const f = [_]bson.Pair{.{ .key = "a", .value = .{ .doc = &.{.{ .key = "$gte", .value = .{ .int32 = 1 } }} } }};
var p = (try plan(gpa, null, &.{ix}, &f, &.{})).?;
var p = (try plan(gpa, null, &.{&ix}, &f, &.{})).?;
defer p.deinit(gpa);
try testing.expectEqual(@as(usize, 0), p.key_len());
try testing.expect(p.lo != null and p.lo_incl);
@@ -2917,23 +2917,23 @@ test "planner picks eq run, ranges, and bails on sparse null" {
// Unusable filter → no plan.
{
const f = [_]bson.Pair{.{ .key = "a", .value = .{ .doc = &.{.{ .key = "$regex", .value = .{ .string = "^x" } }} } }};
try testing.expect((try plan(gpa, null, &.{ix}, &f, &.{})) == null);
try testing.expect((try plan(gpa, null, &.{&ix}, &f, &.{})) == null);
const or_f = [_]bson.Pair{.{ .key = "$or", .value = .{ .array = &.{
.{ .doc = &.{.{ .key = "a", .value = .{ .int32 = 1 } }} },
} } }};
try testing.expect((try plan(gpa, null, &.{ix}, &or_f, &.{})) == null);
try testing.expect((try plan(gpa, null, &.{&ix}, &or_f, &.{})) == null);
}
// Sparse index bails on a null component.
{
const f = [_]bson.Pair{.{ .key = "a", .value = .null }};
try testing.expect((try plan(gpa, null, &.{sp}, &f, &.{})) == null);
try testing.expect((try plan(gpa, null, &.{&sp}, &f, &.{})) == null);
// Non-sparse is fine with null.
var p = (try plan(gpa, null, &.{ix}, &f, &.{})).?;
var p = (try plan(gpa, null, &.{&ix}, &f, &.{})).?;
defer p.deinit(gpa);
try testing.expect(p.key_len() == 1);
// A null inside $in bails too.
const fin = [_]bson.Pair{.{ .key = "a", .value = .{ .doc = &.{.{ .key = "$in", .value = .{ .array = &.{ .{ .int32 = 1 }, .null } } }} } }};
try testing.expect((try plan(gpa, null, &.{sp}, &fin, &.{})) == null);
try testing.expect((try plan(gpa, null, &.{&sp}, &fin, &.{})) == null);
}
// $in cartesian product is capped.
{
@@ -2944,6 +2944,6 @@ test "planner picks eq run, ranges, and bails on sparse null" {
.{ .key = "b", .value = .{ .doc = &.{.{ .key = "$in", .value = .{ .array = &members } }} } },
};
// 20 * 20 = 400 > 100 → fall back to a scan.
try testing.expect((try plan(gpa, null, &.{ix}, &f, &.{})) == null);
try testing.expect((try plan(gpa, null, &.{&ix}, &f, &.{})) == null);
}
}