index/db/commands/server: TTL indexes
createIndex({expireAt: 1}, {expireAfterSeconds: 60}) now deletes a
document once its indexed date is that many seconds old.
index.zig carries the option: Index.ttl (?i64), parsed from
expireAfterSeconds (int32/int64/integral double, within MongoDB's
[0, 2147483647]; 0 means "expire at the stored instant"), emitted by
spec_pairs and so persisted through the log and reported by listIndexes,
and compared by spec_equal — a same-name re-create with a different
expiry stays IndexOptionsConflict, as in MongoDB, since collMod does not
exist here. The bound keeps the value an int32 on the wire and makes the
emission cast unconditional. TTL is single-field only (a compound key is
error.TtlOnCompoundIndex); an absent option parses to null, so every
existing log record reparses unchanged.
db.zig sweeps: Engine.ttl_sweep(now_ms) walks each TTL index's entries
and deletes through the ordinary remove path, so an expiry is logged and
fsynced like any other write and holds across a restart. Expired ids are
duped before removal — remove frees the docs-map key that Entry.id
aliases — then sorted and deduped, because one document can be expired
by several entries (an array of dates expires on its earliest member,
which multikey expansion gives for free) or by several TTL indexes.
Selecting entries is a type test rather than a range lookup: bson
compare order ranks datetime above null, numbers and strings, so a
datetime upper bound would also select every value of a lesser type. A
sweep that deleted something checks the compaction threshold, since a
TTL-only workload never reaches the one in upsert.
commands.zig maps the new spec errors: CannotCreateIndex (67) for a
compound key or an out-of-range expiry, and InvalidIndexSpecificationOption
(197) for an expiry on {_id: 1}, which the idempotent _id no-op would
otherwise swallow.
server.zig runs the monitor as a member of the connection group, so the
existing group.cancel tears it down; it sleeps first, then sweeps under
the write lock, and logs rather than dies on a sweep failure.
--ttl-sweep-secs sets the interval (default 60, 0 leaves it unspawned).
Expiry is coarse by design, as in MongoDB: a document stays visible
until the next sweep, and a non-date value at the indexed path never
expires. Unit tests cover the spec round-trip and every rejection, the
sweep (inclusive cutoff, string/missing/future values untouched, second
sweep a no-op) and its survival of a reopen, and two TTL indexes over
one collection. e2e4.js exercises it through the Node driver.
This commit is contained in:
110
src/commands.zig
110
src/commands.zig
@@ -32,6 +32,8 @@ pub const ErrorCode = enum(i32) {
|
||||
internal_error = 1,
|
||||
invalid_pipeline_operator = 40324,
|
||||
index_options_conflict = 85,
|
||||
cannot_create_index = 67,
|
||||
invalid_index_specification_option = 197,
|
||||
};
|
||||
|
||||
/// Which lock (if any) a command needs on the engine. Contract: only
|
||||
@@ -344,7 +346,19 @@ fn cmd_create_indexes(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !vo
|
||||
const is_id_index = key_pairs.len == 1 and ((only == .int32 and only.int32 == 1) or
|
||||
(only == .int64 and only.int64 == 1) or
|
||||
(only == .double and only.double == 1.0));
|
||||
if (is_id_index) continue;
|
||||
if (is_id_index) {
|
||||
// The no-op must not swallow options that would change what
|
||||
// the index does — MongoDB rejects a TTL _id index rather
|
||||
// than quietly ignoring the expiry.
|
||||
if (bson.get_pair(spec, "expireAfterSeconds") != null) {
|
||||
return reply.put_error(
|
||||
@intFromEnum(ErrorCode.invalid_index_specification_option),
|
||||
"InvalidIndexSpecificationOption",
|
||||
"the field 'expireAfterSeconds' is not valid for an _id index specification",
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
return bad_value(reply, "cannot create a secondary index on the _id field");
|
||||
}
|
||||
const name = bson.get_pair(spec, "name") orelse bson.Value.null;
|
||||
@@ -355,6 +369,16 @@ fn cmd_create_indexes(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !vo
|
||||
const spec_doc = bson.Document{ .arena = undefined, .pairs = spec };
|
||||
_ = ctx.engine.create_index(db_name, coll_name, &spec_doc) catch |err| switch (err) {
|
||||
error.InvalidIndexSpec => return bad_value(reply, "invalid index spec"),
|
||||
error.TtlOnCompoundIndex => return reply.put_error(
|
||||
@intFromEnum(ErrorCode.cannot_create_index),
|
||||
"CannotCreateIndex",
|
||||
"TTL indexes are single-field indexes, compound indexes do not support TTL",
|
||||
),
|
||||
error.InvalidExpireAfterSeconds => return reply.put_error(
|
||||
@intFromEnum(ErrorCode.cannot_create_index),
|
||||
"CannotCreateIndex",
|
||||
"TTL index 'expireAfterSeconds' option must be a whole number between 0 and 2147483647",
|
||||
),
|
||||
error.IndexOptionsConflict => return reply.put_error(@intFromEnum(ErrorCode.index_options_conflict), "IndexOptionsConflict", "index already exists with a different specification"),
|
||||
error.DuplicateKeyIndex => {
|
||||
const ix_name = if (name == .string) name.string else "index";
|
||||
@@ -1642,6 +1666,90 @@ test "createIndexes, listIndexes, dropIndexes, and idempotent re-create" {
|
||||
}
|
||||
}
|
||||
|
||||
test "TTL index round-trips through createIndexes/listIndexes; bad specs give 67" {
|
||||
var threaded: std.Io.Threaded = .init_single_threaded;
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
var tdb = try TestDb.init(io);
|
||||
defer tdb.deinit();
|
||||
|
||||
// A driver sends expireAfterSeconds as a double.
|
||||
try dispatch_create_index(&tdb, io, "sessions", .{ .doc = &.{
|
||||
.{ .key = "key", .value = .{ .doc = &.{.{ .key = "expireAt", .value = .{ .int32 = 1 } }} } },
|
||||
.{ .key = "name", .value = .{ .string = "expireAt_1" } },
|
||||
.{ .key = "expireAfterSeconds", .value = .{ .double = 60.0 } },
|
||||
} });
|
||||
try testing.expectEqual(@as(?i64, 60), tdb.engine.get_collection("test", "sessions").?.indexes.items[0].ttl);
|
||||
|
||||
// listIndexes reports it back.
|
||||
{
|
||||
var ctx = tdb.ctx(io);
|
||||
var msg = try parse_fake_msg("listIndexes", .{ .string = "sessions" }, &.{});
|
||||
defer msg.deinit();
|
||||
var reply = wire.Reply.init(testing.allocator);
|
||||
defer reply.deinit();
|
||||
try dispatch(&ctx, &msg, &reply);
|
||||
const cursor = bson.get_pair(reply.pairs.items, "cursor").?;
|
||||
const batch = bson.get_pair(cursor.doc, "firstBatch").?.array;
|
||||
try testing.expectEqual(@as(usize, 2), batch.len);
|
||||
try testing.expectEqual(@as(i32, 60), bson.get_pair(batch[1].doc, "expireAfterSeconds").?.int32);
|
||||
// The _id_ entry never carries one.
|
||||
try testing.expect(bson.get_pair(batch[0].doc, "expireAfterSeconds") == null);
|
||||
}
|
||||
|
||||
// Same name and key, different expiry: IndexOptionsConflict, as in
|
||||
// MongoDB (changing it is a collMod, which this server does not have).
|
||||
const bad = [_]struct { spec: bson.Value, code: i32 }{
|
||||
.{ .code = 85, .spec = .{ .doc = &.{
|
||||
.{ .key = "key", .value = .{ .doc = &.{.{ .key = "expireAt", .value = .{ .int32 = 1 } }} } },
|
||||
.{ .key = "name", .value = .{ .string = "expireAt_1" } },
|
||||
.{ .key = "expireAfterSeconds", .value = .{ .int32 = 90 } },
|
||||
} } },
|
||||
// TTL on a compound key.
|
||||
.{ .code = 67, .spec = .{ .doc = &.{
|
||||
.{ .key = "key", .value = .{ .doc = &.{
|
||||
.{ .key = "a", .value = .{ .int32 = 1 } },
|
||||
.{ .key = "b", .value = .{ .int32 = 1 } },
|
||||
} } },
|
||||
.{ .key = "expireAfterSeconds", .value = .{ .int32 = 60 } },
|
||||
} } },
|
||||
// Negative and non-numeric expiries.
|
||||
.{ .code = 67, .spec = .{ .doc = &.{
|
||||
.{ .key = "key", .value = .{ .doc = &.{.{ .key = "a", .value = .{ .int32 = 1 } }} } },
|
||||
.{ .key = "expireAfterSeconds", .value = .{ .int32 = -1 } },
|
||||
} } },
|
||||
.{ .code = 67, .spec = .{ .doc = &.{
|
||||
.{ .key = "key", .value = .{ .doc = &.{.{ .key = "a", .value = .{ .int32 = 1 } }} } },
|
||||
.{ .key = "expireAfterSeconds", .value = .{ .string = "60" } },
|
||||
} } },
|
||||
// Past MongoDB's 2147483647 bound.
|
||||
.{ .code = 67, .spec = .{ .doc = &.{
|
||||
.{ .key = "key", .value = .{ .doc = &.{.{ .key = "a", .value = .{ .int32 = 1 } }} } },
|
||||
.{ .key = "expireAfterSeconds", .value = .{ .int64 = index.max_expire_after_seconds + 1 } },
|
||||
} } },
|
||||
// {_id: 1} is otherwise an idempotent no-op, but an expiry on it
|
||||
// would be silently dropped, so it is rejected instead.
|
||||
.{ .code = 197, .spec = .{ .doc = &.{
|
||||
.{ .key = "key", .value = .{ .doc = &.{.{ .key = "_id", .value = .{ .int32 = 1 } }} } },
|
||||
.{ .key = "expireAfterSeconds", .value = .{ .int32 = 60 } },
|
||||
} } },
|
||||
};
|
||||
for (bad) |case| {
|
||||
var ctx = tdb.ctx(io);
|
||||
const specs = [_]bson.Value{case.spec};
|
||||
var msg = try parse_fake_msg("createIndexes", .{ .string = "sessions" }, &.{
|
||||
.{ .key = "indexes", .value = .{ .array = &specs } },
|
||||
});
|
||||
defer msg.deinit();
|
||||
var reply = wire.Reply.init(testing.allocator);
|
||||
defer reply.deinit();
|
||||
try dispatch(&ctx, &msg, &reply);
|
||||
try testing.expectEqual(case.code, bson.get_pair(reply.pairs.items, "code").?.int32);
|
||||
}
|
||||
// Nothing partial was registered by the rejected specs.
|
||||
try testing.expectEqual(@as(usize, 1), tdb.engine.get_collection("test", "sessions").?.indexes.items.len);
|
||||
}
|
||||
|
||||
test "unique index constraint returns 11000 through insert and update" {
|
||||
var threaded: std.Io.Threaded = .init_single_threaded;
|
||||
defer threaded.deinit();
|
||||
|
||||
Reference in New Issue
Block a user