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:
138
tests/e2e/e2e4.js
Normal file
138
tests/e2e/e2e4.js
Normal file
@@ -0,0 +1,138 @@
|
||||
// E2E part 4: TTL indexes through the official Node driver.
|
||||
// createIndex({expireAfterSeconds}), the option round-tripping through
|
||||
// listIndexes, background expiry of past-dated documents, and the specs the
|
||||
// server must reject with CannotCreateIndex (67).
|
||||
//
|
||||
// The server must run with a short sweep interval, e.g.
|
||||
// zig-out/bin/mongo-light --port 27020 --db /tmp/ml-e2e.log --ttl-sweep-secs 1
|
||||
const { MongoClient } = require('mongodb');
|
||||
|
||||
const URL = 'mongodb://127.0.0.1:27020';
|
||||
const results = [];
|
||||
function check(name, cond, detail = '') {
|
||||
results.push({ name, ok: !!cond, detail: String(detail) });
|
||||
if (!cond) console.error(` ✗ ${name} ${detail}`);
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/// Poll until `fn()` is true or the deadline passes; expiry is a background
|
||||
/// sweep, so the exact moment a document disappears is not fixed.
|
||||
async function waitFor(fn, timeoutMs = 15000, stepMs = 250) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (await fn()) return true;
|
||||
await sleep(stepMs);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function expectCode(fn, code, name) {
|
||||
let err = null;
|
||||
try {
|
||||
await fn();
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
check(name, err && err.code === code, err ? `code ${err.code}: ${err.message}` : 'no error');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const client = new MongoClient(URL, { serverSelectionTimeoutMS: 5000 });
|
||||
await client.connect();
|
||||
const db = client.db('e2e4');
|
||||
const coll = db.collection('sessions');
|
||||
await coll.drop().catch(() => {});
|
||||
|
||||
// --- createIndex + listIndexes round-trip ---
|
||||
await coll.createIndex({ expireAt: 1 }, { expireAfterSeconds: 1 });
|
||||
const idxs = await coll.indexes();
|
||||
const ttlIdx = idxs.find((i) => i.name === 'expireAt_1');
|
||||
check('TTL index listed', !!ttlIdx, JSON.stringify(idxs));
|
||||
check('expireAfterSeconds reported', ttlIdx && Number(ttlIdx.expireAfterSeconds) === 1, JSON.stringify(ttlIdx));
|
||||
check('_id_ has no expireAfterSeconds', idxs.find((i) => i.name === '_id_').expireAfterSeconds === undefined);
|
||||
|
||||
// Idempotent re-create of the same spec.
|
||||
await coll.createIndex({ expireAt: 1 }, { expireAfterSeconds: 1 });
|
||||
check('idempotent re-create', (await coll.indexes()).filter((i) => i.name === 'expireAt_1').length === 1);
|
||||
|
||||
// --- expiry ---
|
||||
const now = Date.now();
|
||||
await coll.insertMany([
|
||||
{ _id: 'past', expireAt: new Date(now - 60_000) },
|
||||
{ _id: 'future', expireAt: new Date(now + 3_600_000) },
|
||||
{ _id: 'string', expireAt: 'tomorrow' },
|
||||
{ _id: 'missing' },
|
||||
// An array of dates expires on its earliest member.
|
||||
{ _id: 'array', expireAt: [new Date(now - 60_000), new Date(now + 3_600_000)] },
|
||||
]);
|
||||
check('all five inserted', (await coll.countDocuments({})) === 5);
|
||||
|
||||
const gone = await waitFor(async () => (await coll.countDocuments({ _id: 'past' })) === 0);
|
||||
check('past-dated doc expired', gone);
|
||||
check('array doc expired on earliest date', (await coll.countDocuments({ _id: 'array' })) === 0);
|
||||
const survivors = (await coll.find({}).toArray()).map((d) => d._id).sort();
|
||||
check('future/string/missing survive', JSON.stringify(survivors) === '["future","missing","string"]', JSON.stringify(survivors));
|
||||
|
||||
// Expiry is a real delete: it holds after the sweeper has run again.
|
||||
await sleep(1500);
|
||||
check('expired docs stay deleted', (await coll.countDocuments({})) === 3);
|
||||
|
||||
// --- rejected specs ---
|
||||
const bad = db.collection('bad');
|
||||
await expectCode(
|
||||
() => bad.createIndex({ a: 1, b: 1 }, { expireAfterSeconds: 60 }),
|
||||
67,
|
||||
'compound TTL rejected with 67',
|
||||
);
|
||||
await expectCode(
|
||||
() => bad.createIndex({ a: 1 }, { expireAfterSeconds: -1 }),
|
||||
67,
|
||||
'negative expireAfterSeconds rejected with 67',
|
||||
);
|
||||
await expectCode(
|
||||
() => bad.createIndex({ a: 1 }, { expireAfterSeconds: 'soon' }),
|
||||
67,
|
||||
'non-numeric expireAfterSeconds rejected with 67',
|
||||
);
|
||||
await expectCode(
|
||||
() => bad.createIndex({ a: 1 }, { expireAfterSeconds: 2147483648 }),
|
||||
67,
|
||||
'expireAfterSeconds past 2147483647 rejected with 67',
|
||||
);
|
||||
await expectCode(
|
||||
() => bad.createIndex({ _id: 1 }, { expireAfterSeconds: 60 }),
|
||||
197,
|
||||
'TTL on _id rejected with 197',
|
||||
);
|
||||
// Same name and key with a different expiry: IndexOptionsConflict (85),
|
||||
// exactly as MongoDB does (the change belongs to collMod).
|
||||
await expectCode(
|
||||
() => coll.createIndex({ expireAt: 1 }, { expireAfterSeconds: 90 }),
|
||||
85,
|
||||
'changed expiry gives IndexOptionsConflict',
|
||||
);
|
||||
|
||||
// expireAfterSeconds 0 is legal: expire at exactly the stored instant.
|
||||
const zero = db.collection('zero');
|
||||
await zero.drop().catch(() => {});
|
||||
await zero.createIndex({ at: 1 }, { expireAfterSeconds: 0 });
|
||||
await zero.insertOne({ _id: 'now', at: new Date(Date.now() - 1000) });
|
||||
check('expireAfterSeconds 0 accepted', (await zero.indexes()).some((i) => Number(i.expireAfterSeconds) === 0));
|
||||
check('expireAfterSeconds 0 expires', await waitFor(async () => (await zero.countDocuments({})) === 0));
|
||||
|
||||
await client.close();
|
||||
|
||||
const failed = results.filter((r) => !r.ok);
|
||||
console.log(`\n${results.length - failed.length}/${results.length} checks passed`);
|
||||
if (failed.length) {
|
||||
console.log('FAILED:', failed.map((f) => f.name).join(', '));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('E2E4_OK');
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('E2E4_FAIL', e);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user