Files
MultiforaDB/tests/e2e/e2e.js
Aleksey Shakhmatov 21489723a9 db: a write that changes nothing is not a write
`nModified` counted every write, so an update that altered nothing was reported
as a modification. MongoDB counts a document as modified only if applying the
update changed it, and writes no oplog entry when it did not: `$set: {x: 11}`
on a document already holding `x: 11` is matched and not modified. The spec
suite says it plainly -- `bulkWrite` with four updateOne operations expects
matchedCount 2 and modifiedCount 1.

Decided in the engine rather than the command, because that is where the
document is already serialized: the comparison is against the bytes that would
actually be stored, and it lands before the log append, so a no-op costs no log
record, no fsync, no slab bytes and no garbage. `Engine.replace` returns
`Written.modified` or `.unchanged` and `cmd_update` counts the first.

That exposed a second difference. A replacement keeps `_id` at the front, so
replacing a document with itself was a byte-level change whenever `_id` was not
stored first -- and it usually was not: the Node driver fills a missing `_id` by
assigning the property, which in JavaScript appends it, so `insertOne({name,
age})` reaches the server as `{name, age, _id}` and we stored it that way.
MongoDB moves `_id` to the front whatever order it arrives in. Now so does
`serialize_with_id`, for every document rather than only the ones whose `_id` it
generates. Visible to clients as `_id` coming back first, as it does from
MongoDB.

  spec scorecard   161 pass / 131 fail  ->  163 pass / 129 fail
  bulkWrite.json   8 pass / 2 fail      ->  10 pass / 0 fail
  e2e.js           45 checks -> 49

No spec file regressed. Mutation: delete the byte comparison in `upsert`'s
`.replace` arm -- red on the log growing, on the garbage counters moving, and on
`replace` claiming `.modified`.
2026-08-04 00:02:17 +03:00

276 lines
13 KiB
JavaScript

// End-to-end test: official MongoDB Node.js driver against multiforadb.
const { MongoClient, ObjectId } = 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}`);
}
async function main() {
const client = new MongoClient(URL, { serverSelectionTimeoutMS: 5000 });
await client.connect();
const db = client.db('e2e');
const users = db.collection('users');
await users.drop().catch(() => {});
// --- insert ---
await users.insertOne({ name: 'alice', age: 30, tags: ['a', 'b'] });
const many = await users.insertMany([
{ name: 'bob', age: 25, tags: ['b'] },
{ name: 'carol', age: 35, tags: ['c', 'a'] },
{ name: 'dave', age: 40, tags: [] },
]);
check('insertMany acknowledged', many.acknowledged === true, many);
check('auto _id assigned', ObjectId.isValid(many.insertedIds[0]));
// --- find: filters, operators, sort, skip, limit, projection ---
const gt = await users.find({ age: { $gt: 28 } }).sort({ age: -1 }).toArray();
check('find $gt + sort desc', gt.map((d) => d.name).join(',') === 'dave,carol,alice', gt.map((d) => d.name));
const inq = await users.find({ name: { $in: ['alice', 'bob'] } }).count();
check('find $in count', inq === 2, inq);
const rgx = await users.find({ name: /^[bc]/ }).toArray();
check('find $regex', rgx.length === 2, rgx.map((d) => d.name));
const exists = await users.find({ tags: { $exists: true } }).count();
check('find $exists', exists === 4, exists);
const lim = await users.find({}).sort({ age: 1 }).skip(1).limit(2).toArray();
check('find skip+limit+sort', lim.map((d) => d.name).join(',') === 'alice,carol', lim.map((d) => d.name));
const proj = await users.findOne({ name: 'alice' }, { projection: { _id: 0, name: 1 } });
check('projection', proj.name === 'alice' && proj.age === undefined, JSON.stringify(proj));
const dot = await users.findOne({ 'tags.0': 'c' });
check('dot path + array', dot?.name === 'carol');
// --- count ---
check('countDocuments', (await users.countDocuments({})) === 4);
check('countDocuments with filter', (await users.countDocuments({ age: { $gte: 30 } })) === 3);
check('estimatedDocumentCount', (await users.estimatedDocumentCount()) === 4);
// --- update ---
const u1 = await users.updateOne({ name: 'alice' }, { $set: { vip: true }, $inc: { age: 1 } });
check('updateOne nModified', u1.modifiedCount === 1, u1);
const alice = await users.findOne({ name: 'alice' });
check('updateOne $set+$inc applied', alice.vip === true && alice.age === 31, JSON.stringify(alice));
const um = await users.updateMany({}, { $set: { seen: true } });
check('updateMany', um.modifiedCount === 4, um);
// `_id` comes back first, whatever order the client sent it in. The driver
// fills a missing `_id` by assigning the property, which in JavaScript appends
// it, so this document reaches the server as {name, age, _id}.
const ordered = db.collection('ordering');
await ordered.drop().catch(() => {});
await ordered.insertOne({ zzz: 1, aaa: 2 });
const orderedDoc = await ordered.findOne({});
check('_id is stored first however the client ordered it', Object.keys(orderedDoc)[0] === '_id', Object.keys(orderedDoc).join(','));
await ordered.insertOne({ mid: 1, _id: 'explicit', tail: 2 });
const explicitDoc = await ordered.findOne({ _id: 'explicit' });
check(
'_id moves to the front and the other fields keep their order',
Object.keys(explicitDoc).join(',') === '_id,mid,tail',
Object.keys(explicitDoc).join(','),
);
// Matched but not modified: MongoDB counts a document as modified only if the
// update altered it, and writes nothing when it did not. Setting a field to
// the value it already holds is the plain case.
const noop = await users.updateMany({}, { $set: { seen: true } });
check('a no-op update matches without modifying', noop.matchedCount === 4 && noop.modifiedCount === 0, JSON.stringify(noop));
const noopRep = await users.replaceOne({ name: 'alice' }, await users.findOne({ name: 'alice' }));
check('replacing a document with itself modifies nothing', noopRep.matchedCount === 1 && noopRep.modifiedCount === 0, JSON.stringify(noopRep));
const push = await users.updateOne({ name: 'dave' }, { $push: { tags: 'x' } });
check('$push', push.modifiedCount === 1);
check('$push visible', (await users.findOne({ name: 'dave' })).tags.length === 1);
const ups = await users.updateOne({ name: 'erin' }, { $set: { age: 28 } }, { upsert: true });
check('upsert', ups.upsertedCount === 1 && ups.matchedCount === 0, ups);
check('upsert doc exists', (await users.findOne({ name: 'erin' }))?.age === 28);
// --- replacement-style writes ---
// A replacement is data, not instructions: it replaces every field except
// `_id`. Distinct enough from operator updates to be worth its own block --
// these all used to fail with "bad update", because the update path required
// a $-prefixed first field.
const repl = db.collection('repl');
await repl.drop().catch(() => {});
await repl.insertMany([{ _id: 1, a: 1, gone: 'x' }, { _id: 2, a: 2 }, { _id: 3, a: 3 }]);
const rp = await repl.replaceOne({ _id: 1 }, { fresh: 9 });
check('replaceOne modifies one', rp.modifiedCount === 1, JSON.stringify(rp));
const rpDoc = await repl.findOne({ _id: 1 });
check(
'replaceOne keeps _id and drops the old fields',
rpDoc._id === 1 && rpDoc.fresh === 9 && rpDoc.a === undefined && rpDoc.gone === undefined,
JSON.stringify(rpDoc),
);
const farRaw = await repl.findOneAndReplace({ _id: 2 }, { swapped: true }, { returnDocument: 'after' });
const far = farRaw.value ?? farRaw;
check('findOneAndReplace returns the new document', far.swapped === true && far._id === 2, JSON.stringify(farRaw));
const bulkRep = await repl.bulkWrite([{ replaceOne: { filter: { _id: 3 }, replacement: { via: 'bulk' } } }]);
check('bulkWrite replaceOne', bulkRep.modifiedCount === 1, String(bulkRep.modifiedCount));
const rpUp = await repl.replaceOne({ _id: 99 }, { u: 1 }, { upsert: true });
check('replacement upsert takes the _id from the filter', String(rpUp.upsertedId) === '99', JSON.stringify(rpUp.upsertedId));
const rpUpDoc = await repl.findOne({ _id: 99 });
check(
'upserted document is the replacement plus _id',
rpUpDoc && rpUpDoc.u === 1 && Object.keys(rpUpDoc).length === 2,
JSON.stringify(rpUpDoc),
);
let idChangeRefused = false;
try { await repl.replaceOne({ _id: 1 }, { _id: 1234, x: 1 }); } catch { idChangeRefused = true; }
check('a replacement may not change _id', idChangeRefused);
const rpEmpty = await repl.replaceOne({ _id: 1 }, {});
const rpEmptyDoc = await repl.findOne({ _id: 1 });
check(
'an empty replacement leaves only _id',
rpEmpty.modifiedCount === 1 && Object.keys(rpEmptyDoc).length === 1,
JSON.stringify(rpEmptyDoc),
);
// Raw command: the driver refuses updateMany-with-a-replacement client side,
// so going through it would test the driver rather than this server.
let multiRefused = null;
try {
await db.command({ update: 'repl', updates: [{ q: {}, u: { a: 1 }, multi: true }] });
} catch (e) { multiRefused = e; }
check(
'multi with a replacement is refused with FailedToParse',
multiRefused?.code === 9 && /replacement-style/.test(multiRefused.message),
`code=${multiRefused?.code} ${multiRefused?.message?.slice(0, 50)}`,
);
const multiOps = await db.command({ update: 'repl', updates: [{ q: {}, u: { $set: { touched: 1 } }, multi: true }] });
check('multi with operators still runs', multiOps.ok === 1 && multiOps.n === 4, JSON.stringify(multiOps));
// --- findOneAndUpdate (findAndModify) ---
const fam = await users.findOneAndUpdate(
{ name: 'bob' },
{ $set: { lucky: true } },
{ returnDocument: 'after' },
);
const famDoc = fam.value ?? fam;
check('findOneAndUpdate returns new', famDoc.lucky === true, JSON.stringify(fam));
const famRemove = await users.findOneAndDelete({ name: 'erin' });
const famDelDoc = famRemove.value ?? famRemove;
check('findOneAndDelete', famDelDoc?.name === 'erin', JSON.stringify(famRemove));
// --- aggregate ---
const grp = await users
.aggregate([
{ $match: { age: { $gte: 25 } } },
{ $group: { _id: '$tags.length', total: { $sum: '$age' } } },
{ $sort: { _id: 1 } },
])
.toArray();
check('aggregate $match+$group+$sum+$sort', grp.length >= 1 && grp.some((g) => g.total > 0), JSON.stringify(grp));
const cnt = await users.aggregate([{ $match: {} }, { $count: 'n' }]).toArray();
check('aggregate $count', cnt[0]?.n === 4, JSON.stringify(cnt));
// $sort with NO preceding $group. This shape used to kill the server: the
// stage materialized its document list from the reply arena and it was then
// freed with the general allocator. Every aggregate case above happens to
// sort after a $group, which leaves the stream already materialized and the
// guilty branch unreached -- so the bug survived the whole suite. The second
// aggregate is the part that actually proves recovery: if the server died,
// this connection is gone.
const sorted = await users.aggregate([{ $sort: { age: -1 } }]).toArray();
const ages = sorted.map((d) => d.age);
check(
'aggregate bare $sort (no $group) returns sorted docs',
ages.length === 4 && ages.every((a, i) => i === 0 || ages[i - 1] >= a),
JSON.stringify(ages),
);
const stillAlive = await users.aggregate([{ $match: {} }, { $count: 'n' }]).toArray();
check('server survives a bare $sort pipeline', stillAlive[0]?.n === 4, JSON.stringify(stillAlive));
// --- a database-level command must not leak the catalog lock ---
// db.aggregate() sends {aggregate: 1}, which names no collection. dispatch
// used to resolve the namespace after taking the catalog lock and bail with a
// plain return, leaking it shared forever. Reads kept working, so the damage
// only showed on the next write that had to create a collection -- which is
// the second half of this check, and would hang rather than fail.
let dbLevelErr = null;
try {
await db.aggregate([{ $listLocalSessions: {} }]).toArray();
} catch (e) {
dbLevelErr = e;
}
check(
'database-level aggregate gives a real error, not an empty reply',
dbLevelErr !== null && typeof dbLevelErr.message === 'string' && dbLevelErr.message !== 'n/a',
String(dbLevelErr && dbLevelErr.message).slice(0, 60),
);
const afterDbLevel = await db.collection('lock_probe').insertOne({ _id: 1 });
check('a write creating a collection still completes afterwards', afterDbLevel.insertedId === 1);
// --- unacknowledged writes must not desync the connection ---
// An OP_MSG request with moreToCome set gets no reply. Sending one anyway
// left it unread in the socket, so the *next* command on that connection
// read the wrong reply. maxPoolSize 1 pins both operations to one socket,
// which is what makes the bug visible; with a larger pool the driver may
// hand out a different connection and hide it. The driver also does this to
// itself on close, via endSessions with {w: 0}.
const w0client = new MongoClient(URL, { maxPoolSize: 1 });
try {
await w0client.connect();
const w0 = w0client.db('e2e').collection('unack');
await w0.deleteMany({});
await w0.insertOne({ _id: 1, v: 'acknowledged' });
const unack = await w0.insertOne({ _id: 2, v: 'unacknowledged' }, { writeConcern: { w: 0 } });
check('unacknowledged insert is not acknowledged', unack.acknowledged === false, JSON.stringify(unack));
// The assertion that matters: the same socket still works afterwards.
const after = await w0.countDocuments({});
check('connection survives an unacknowledged write', after === 2, `count=${after}`);
} finally {
await w0client.close();
}
// --- duplicate key ---
let dupErr = null;
try {
await users.insertOne({ _id: many.insertedIds[0], name: 'clobber' });
} catch (e) {
dupErr = e;
}
check('duplicate key rejected', dupErr?.code === 11000, dupErr?.message);
// --- listCollections / listDatabases ---
const colls = await db.listCollections({}, { nameOnly: true }).toArray();
check('listCollections', colls.some((c) => c.name === 'users'), JSON.stringify(colls));
const dbs = await client.db('admin').admin().listDatabases();
check('listDatabases', dbs.databases.some((d) => d.name === 'e2e'), JSON.stringify(dbs.databases.map((d) => d.name)));
// --- delete ---
const del1 = await users.deleteOne({ name: 'dave' });
check('deleteOne', del1.deletedCount === 1, del1);
const delMany = await users.deleteMany({});
check('deleteMany', delMany.deletedCount === 3, delMany);
check('empty after delete', (await users.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('E2E_OK');
}
main().catch((e) => {
console.error('E2E_FAIL', e);
process.exit(1);
});