Advertising topologyVersion in the hello reply is what tells a driver the server speaks the streaming (awaitable) hello protocol — in the Node driver it is the only condition checked. From the second heartbeat on, the driver then monitored with an exhaust hello (exhaustAllowed + maxAwaitTimeMS) and waited for a stream of replies carrying moreToCome. We answered once with the flag clear and went back to reading, so every heartbeat failed with "Server ended moreToCome unexpectedly", destroying the connection and clearing the pool. MongoDB Compass showed this as a connect/disconnect loop once per heartbeat. We do not implement streaming hello, so we must not claim to. Omitting the field keeps monitoring on the polling path, and agrees with the maxWireVersion 8 we report: streaming hello arrived in wire version 9. The existing e2e files all passed against the broken server — they issue their commands and exit before the second heartbeat — so e2e5 watches SDAM heartbeats on an idle connection instead. Also renames mongo-light to mongo-lite throughout (binary, log messages, docs, gitVersion). Unrelated to the fix above, but squashed in at request rather than left as a commit whose message described only the fix.
139 lines
5.2 KiB
JavaScript
139 lines
5.2 KiB
JavaScript
// 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-lite --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);
|
|
});
|