diff --git a/packages/plugins/driver-mongodb/README.md b/packages/plugins/driver-mongodb/README.md index a59598376c..102197a8b0 100644 --- a/packages/plugins/driver-mongodb/README.md +++ b/packages/plugins/driver-mongodb/README.md @@ -199,13 +199,32 @@ kernel.use(mongodbPlugin, { ## Development ```bash -# Run tests +# Run tests (the suites that need a real mongod SKIP — see below) pnpm test +# Run every suite, including the ones that need a real mongod +OS_TEST_MONGODB_MEMORY_SERVER_ENABLED=1 pnpm test + # Build pnpm build ``` +### The mongod-backed suites are opt-in (#5517) + +Seven suites here need a real MongoDB, which `mongodb-memory-server` provides by +downloading a ~123 MB binary on first use. With a cold cache, two vitest workers +downloaded it at the same time and the loser's `rename` failed as an unhandled +rejection — turning an all-green run into `exit 1` and ejecting unrelated PRs +from the merge queue. Those suites are therefore gated behind +`OS_TEST_MONGODB_MEMORY_SERVER_ENABLED=1`: without it they skip, each printing +one line that names this issue and the switch, and **no download starts**. + +The rest of the package's tests — filter translation, the shared filter-logic +conformance case-set over the emitted documents, sort specs, tenancy guard, +temporal helpers — run by default and need no binary. The gate lives in +`src/test-mongod.ts`, which documents the mechanism and what a default run gives +up. + ## License Apache-2.0. See [LICENSING.md](../../../LICENSING.md). diff --git a/packages/plugins/driver-mongodb/src/mongodb-driver.test.ts b/packages/plugins/driver-mongodb/src/mongodb-driver.test.ts index 90d2373dfb..487f56bf6a 100644 --- a/packages/plugins/driver-mongodb/src/mongodb-driver.test.ts +++ b/packages/plugins/driver-mongodb/src/mongodb-driver.test.ts @@ -7,13 +7,14 @@ import { MongoDBDriver } from './mongodb-driver.js'; import { createTestMongod } from './test-mongod.js'; // `mongodb-memory-server` downloads a real MongoDB binary from -// fastdl.mongodb.org on first use. In sandboxed / offline CI that download can -// fail — or HANG, which this suite could not express until `createTestMongod` -// put a deadline on the wait (see that module); both now land on the same skip. -// When it does we **skip** this suite rather than failing the whole package's -// test run (and, with it, the monorepo `Test Core` job). The startup is -// attempted once here so availability is known at collection time — a throwing -// `beforeAll` would *fail* every test instead of skipping it. +// fastdl.mongodb.org on first use, which is why this suite is OPT-IN since +// #5517: two workers downloading it at once made an all-green run `exit 1` and +// ejected unrelated PRs from the merge queue. `createTestMongod` skips this +// suite — printing why — unless `OS_TEST_MONGODB_MEMORY_SERVER_ENABLED=1`, and +// an opted-in download that fails or HANGS lands on the same skip rather than +// stalling the job. The acquisition happens once here so availability is known +// at collection time — a throwing `beforeAll` would *fail* every test instead +// of skipping it. const sharedMongod: MongoMemoryServer | undefined = await createTestMongod('MongoDBDriver'); describe.skipIf(!sharedMongod)('MongoDBDriver', () => { diff --git a/packages/plugins/driver-mongodb/src/mongodb-filter-logic-conformance.test.ts b/packages/plugins/driver-mongodb/src/mongodb-filter-logic-conformance.test.ts index ce49b657ae..af14bf8ca7 100644 --- a/packages/plugins/driver-mongodb/src/mongodb-filter-logic-conformance.test.ts +++ b/packages/plugins/driver-mongodb/src/mongodb-filter-logic-conformance.test.ts @@ -16,11 +16,12 @@ * * The same table is driven server-free by * `mongodb-filter-logic-translation.test.ts`, which is the half that always - * runs. This one skips when the mongod binary cannot be fetched (the - * `createTestMongod` convention every suite in this package uses — a blocked or - * hanging download costs a skipped suite, not a stalled test job). **A skip is - * not a pass**: on a machine without the binary, the translation suite is the - * whole proof, which is exactly why it carries the priority half. + * runs. This one is OPT-IN since #5517: it needs the mongod binary, whose + * concurrent download turned green runs into `exit 1`, so `createTestMongod` + * skips it unless `OS_TEST_MONGODB_MEMORY_SERVER_ENABLED=1` (and still skips, + * rather than stalling, when an opted-in download is blocked or hanging). + * **A skip is not a pass**: by default the translation suite is the whole proof, + * which is exactly why it carries the priority half. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; diff --git a/packages/plugins/driver-mongodb/src/mongodb-findone-query.test.ts b/packages/plugins/driver-mongodb/src/mongodb-findone-query.test.ts index b86eb3b0f5..c0f9c3ab15 100644 --- a/packages/plugins/driver-mongodb/src/mongodb-findone-query.test.ts +++ b/packages/plugins/driver-mongodb/src/mongodb-findone-query.test.ts @@ -24,24 +24,22 @@ * because an untouched collection comes back in insertion order whether or not * a sort went out. * - * Skips itself when the mongod binary cannot be fetched — the convention the - * other suites in this package already use. A skip is not a pass. + * Needs a real mongod, so it is OPT-IN since #5517 — `createTestMongod` prints + * why and this suite skips unless `OS_TEST_MONGODB_MEMORY_SERVER_ENABLED=1`. It + * used to call `MongoMemoryServer.create()` itself, which both started a + * download in every default run and bypassed the package's shared deadline. A + * skip is not a pass. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { MongoMemoryServer } from 'mongodb-memory-server'; +import type { MongoMemoryServer } from 'mongodb-memory-server'; import { MongoDBDriver } from './mongodb-driver.js'; import { FINDONE_CASES, FINDONE_ROWS } from './mongodb-findone-cases.js'; +import { createTestMongod } from './test-mongod.js'; -let sharedMongod: MongoMemoryServer | undefined; -try { - sharedMongod = await MongoMemoryServer.create({ instance: { launchTimeout: 60_000 } }); -} catch (err) { - console.warn( - '[driver-mongodb] Skipping findOne query-execution suite — mongodb-memory-server could not ' - + `start: ${(err as Error)?.message ?? String(err)}`, - ); -} +const sharedMongod: MongoMemoryServer | undefined = await createTestMongod( + 'findOne query-execution', +); describe.skipIf(!sharedMongod)('driver-mongodb — findOne executes the whole query', () => { const mongod = sharedMongod as MongoMemoryServer; diff --git a/packages/plugins/driver-mongodb/src/mongodb-memory-server-gate.test.ts b/packages/plugins/driver-mongodb/src/mongodb-memory-server-gate.test.ts new file mode 100644 index 0000000000..999fccd1dc --- /dev/null +++ b/packages/plugins/driver-mongodb/src/mongodb-memory-server-gate.test.ts @@ -0,0 +1,217 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#5517] The binary-dependent suites are OPT-IN, and the gate is checked before + * anything can start a download. + * + * This is the suite that survives the retirement, so it has to pin the two + * properties the retirement is worth anything for: + * + * 1. **Nothing is even imported when the gate is closed.** A `describe.skipIf` + * alone would not have been enough: the download starts at MODULE LOAD, so + * a gate evaluated after `import { MongoMemoryServer } from …` would skip + * the tests and still fetch 123 MB. The mock factory below counts library + * evaluations, so re-adding a static value import to `test-mongod.ts` turns + * the first test red. + * 2. **The abandoned-download rejection is swallowed, and only it.** The + * guard's listener suppresses vitest's own unhandled-rejection reporting for + * the whole worker (vitest steps aside when a second listener exists), so a + * guard that swallowed indiscriminately would hide real failures. The + * re-raise path is asserted, not assumed. + * + * Test ORDER matters in the first three cases: case 1 asserts the library has + * never been evaluated, and case 3 is what evaluates it. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +/** Counts evaluations of `mongodb-memory-server` and lets a case choose what `create()` does. */ +const lib = vi.hoisted(() => ({ + imported: 0, + create: undefined as undefined | (() => Promise), +})); + +vi.mock('mongodb-memory-server', () => { + lib.imported++; + return { + MongoMemoryServer: { + create: () => { + if (!lib.create) throw new Error('no create() behaviour was configured by this test'); + return lib.create(); + }, + }, + }; +}); + +import { + MONGOD_TESTS_ENV, + createTestMongod, + disposeAbandonedDownloadGuard, + installAbandonedDownloadGuard, + isAbandonedDownloadRejection, + mongodSkipReason, + mongodTestsEnabled, + printMongodNotice, +} from './test-mongod.js'; + +/** The exact shape `fs.promises.rename` throws at MongoBinaryDownload.js:413. */ +function renameEnoent( + path = '/home/runner/.cache/mongodb-binaries/mongodb-linux-x86_64-ubuntu2404-8.2.6.tgz.downloading', +): Error { + return Object.assign( + new Error(`ENOENT: no such file or directory, rename '${path}' -> '${path.slice(0, -12)}'`), + { code: 'ENOENT', syscall: 'rename', errno: -2, path, dest: path.slice(0, -12) }, + ); +} + +describe('[#5517] the mongod opt-in gate', () => { + let warnings: string[]; + + beforeEach(() => { + warnings = []; + // The notices go to stderr, NOT through `console` — vitest's default reporter + // renders nothing a passing/skipped file logs via `console`, which would make + // the skip silent in exactly the runs that matter. `printMongodNotice` + // documents the measurement. + vi.spyOn(process.stderr, 'write').mockImplementation((chunk: unknown) => { + warnings.push(String(chunk).trimEnd()); + return true; + }); + delete process.env[MONGOD_TESTS_ENV]; + lib.create = undefined; + }); + + afterEach(() => { + vi.restoreAllMocks(); + delete process.env[MONGOD_TESTS_ENV]; + // Cases that open the gate install the guard on the REAL process. Leaving it + // behind would make vitest defer its own unhandled-rejection reporting for + // every later file in this worker. + disposeAbandonedDownloadGuard(); + }); + + it('skips without touching the library at all when the switch is unset', async () => { + expect(mongodTestsEnabled()).toBe(false); + + const mongod = await createTestMongod('gate probe'); + + expect(mongod).toBeUndefined(); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('#5517'); + expect(warnings[0]).toContain(MONGOD_TESTS_ENV); + expect(warnings[0]).toContain('SKIP gate probe'); + // The property that makes the skip cost nothing: the module that would + // download the binary was never evaluated. + expect(lib.imported).toBe(0); + }); + + it('treats a set-but-not-"1" value as OFF and says so instead of skipping quietly', async () => { + process.env[MONGOD_TESTS_ENV] = 'true'; + + const mongod = await createTestMongod('gate probe'); + + expect(mongod).toBeUndefined(); + expect(lib.imported).toBe(0); + expect(warnings[0]).toContain('is set to "true", which does NOT enable it'); + expect(mongodSkipReason('x', { [MONGOD_TESTS_ENV]: '' })).not.toContain('does NOT enable it'); + }); + + it('imports the library and returns a server once the switch is on', async () => { + process.env[MONGOD_TESTS_ENV] = '1'; + const stub = { getUri: () => 'mongodb://stub', stop: async () => {} }; + lib.create = async () => stub; + + const mongod = await createTestMongod('gate probe'); + + expect(mongod).toBe(stub); + expect(lib.imported).toBe(1); + expect(warnings).toEqual([]); + }); + + it('still degrades to a named skip when an opted-in acquisition fails', async () => { + process.env[MONGOD_TESTS_ENV] = '1'; + lib.create = async () => { + throw renameEnoent(); + }; + + const mongod = await createTestMongod('gate probe'); + + expect(mongod).toBeUndefined(); + expect(warnings[0]).toContain('SKIP gate probe'); + expect(warnings[0]).toContain('could not start'); + }); + + it('prints on stderr, not through the console vitest silences on a green run', () => { + const sink: string[] = []; + printMongodNotice('injected', (chunk) => sink.push(chunk)); + expect(sink).toEqual(['injected\n']); + + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + printMongodNotice('default sink'); + expect(warnings).toContain('default sink'); + expect(consoleWarn).not.toHaveBeenCalled(); + }); +}); + +describe('[#5517] the abandoned-download rejection guard', () => { + it('recognises the rename ENOENT of a lost download race', () => { + expect(isAbandonedDownloadRejection(renameEnoent())).toBe(true); + }); + + it('does not recognise anything else', () => { + expect(isAbandonedDownloadRejection(undefined)).toBe(false); + expect(isAbandonedDownloadRejection(null)).toBe(false); + expect(isAbandonedDownloadRejection('ENOENT: rename')).toBe(false); + expect(isAbandonedDownloadRejection(new Error('boom'))).toBe(false); + // Right code and syscall, but a real file rather than a download temp file. + expect( + isAbandonedDownloadRejection( + Object.assign(new Error('x'), { code: 'ENOENT', syscall: 'rename', path: '/tmp/a.json' }), + ), + ).toBe(false); + // A different syscall on the download temp file is somebody else's bug. + expect( + isAbandonedDownloadRejection( + Object.assign(new Error('x'), { code: 'ENOENT', syscall: 'unlink', path: '/c/x.tgz.downloading' }), + ), + ).toBe(false); + }); + + it('swallows the abandoned download and re-raises every other rejection', () => { + const listeners: ((reason: unknown) => void)[] = []; + const host = { + on: (_event: 'unhandledRejection', listener: (reason: unknown) => void) => { + listeners.push(listener); + }, + off: (_event: 'unhandledRejection', listener: (reason: unknown) => void) => { + listeners.splice(listeners.indexOf(listener), 1); + }, + }; + const reraised: unknown[] = []; + const warned: string[] = []; + + installAbandonedDownloadGuard({ + host, + reraise: (reason) => reraised.push(reason), + warn: (message) => warned.push(message), + }); + // One suite per worker installs it; the rest must not stack listeners. + installAbandonedDownloadGuard({ host, reraise: () => {}, warn: () => {} }); + expect(listeners).toHaveLength(1); + + listeners[0](renameEnoent()); + expect(reraised).toEqual([]); + expect(warned).toHaveLength(1); + expect(warned[0]).toContain('#5517'); + + const real = new Error('a genuine unhandled rejection'); + listeners[0](real); + // Vitest has stepped aside because this listener exists, so the guard owes + // the run a failure for anything it does not recognise. + expect(reraised).toEqual([real]); + expect(warned).toHaveLength(1); + + disposeAbandonedDownloadGuard(); + expect(listeners).toHaveLength(0); + }); +}); diff --git a/packages/plugins/driver-mongodb/src/mongodb-pagination-conformance.test.ts b/packages/plugins/driver-mongodb/src/mongodb-pagination-conformance.test.ts index eb35523fbd..c1fd5555a7 100644 --- a/packages/plugins/driver-mongodb/src/mongodb-pagination-conformance.test.ts +++ b/packages/plugins/driver-mongodb/src/mongodb-pagination-conformance.test.ts @@ -25,7 +25,7 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { MongoMemoryServer } from 'mongodb-memory-server'; +import type { MongoMemoryServer } from 'mongodb-memory-server'; import { PAGINATION_ALL_IDS, PAGINATION_CASES, @@ -33,16 +33,15 @@ import { PAGINATION_UNORDERED_CASES, } from '@objectstack/spec/data'; import { MongoDBDriver } from './mongodb-driver.js'; - -let sharedMongod: MongoMemoryServer | undefined; -try { - sharedMongod = await MongoMemoryServer.create({ instance: { launchTimeout: 60_000 } }); -} catch (err) { - console.warn( - '[driver-mongodb] Skipping pagination conformance — mongodb-memory-server could not start: ' + - `${(err as Error)?.message ?? String(err)}`, - ); -} +import { createTestMongod } from './test-mongod.js'; + +// The live half needs a real mongod, so it is OPT-IN since #5517 (see +// `test-mongod.ts`). It used to call `MongoMemoryServer.create()` itself, which +// both started a download in every default run and bypassed the package's shared +// deadline. The sort-spec half below runs regardless — it needs no server. +const sharedMongod: MongoMemoryServer | undefined = await createTestMongod( + 'pagination conformance', +); describe.skipIf(!sharedMongod)('driver-mongodb — paged reads are a partition of the result set', () => { const mongod = sharedMongod as MongoMemoryServer; diff --git a/packages/plugins/driver-mongodb/src/test-mongod.ts b/packages/plugins/driver-mongodb/src/test-mongod.ts index cbbb7a97da..453b1d2317 100644 --- a/packages/plugins/driver-mongodb/src/test-mongod.ts +++ b/packages/plugins/driver-mongodb/src/test-mongod.ts @@ -1,78 +1,300 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * One bounded way to obtain a `MongoMemoryServer` for this package's suites. + * One bounded way to obtain a `MongoMemoryServer` for this package's suites — + * and, since #5517, the OPT-IN GATE in front of it. + * + * ## Why these suites are opt-in (#5517, maintainer decision 2026-08-05) * * `mongodb-memory-server` downloads a ~123 MB MongoDB binary from - * fastdl.mongodb.org the first time it runs on a machine, at MODULE LOAD time - * (these suites need the server before `describe.skipIf` can decide anything). - * The three suites already anticipated that download FAILING: each wrapped the - * call in try/catch and skipped itself, so a blocked download costs a skipped - * suite rather than a red `Test Core` — the stated intent in - * `mongodb-driver.test.ts`'s header. - * - * A catch cannot express the other half. When the download HANGS — socket open, - * no bytes, no error — the top-level `await` never settles, so the suite neither - * runs nor skips: it stops, silently, and takes the whole monorepo test job with - * it. Observed twice on one branch (PR #4322): zero output from this package, no - * skip warning, and `Test Core` force-killed 10 minutes later by the #4250 stall - * guard with `@objectstack/driver-mongodb#test` as the surviving task. The same - * task on `main` 40 minutes earlier logged `Downloading MongoDB "8.2.6": 0% … - * 100%` and passed, so this is the network changing under an unbounded wait, not - * a code change. - * - * `instance.launchTimeout` does NOT cover it: that bounds spawning `mongod` - * once the binary is on disk, which is a later phase than the fetch. - * - * So the wait gets a deadline, and a timeout lands in the branch the suites - * already had. The degraded outcome is unchanged and still visible — a warning - * plus a skipped suite — but it is now reached in bounded time instead of never. - * Real coverage of a real mongod is preserved wherever the binary is reachable - * (every developer machine, and CI whenever the download works), which is the - * property that makes skipping acceptable at all. + * fastdl.mongodb.org the first time it runs on a machine, and seven suites in + * this package need it. On a runner with a cold binary cache — the normal state + * of a merge-queue runner — two vitest workers start that download at the same + * time and the loser corrupts the run. The mechanism is in the library, one + * unawaited promise, `mongodb-memory-server-core@11.2.0` + * `lib/util/MongoBinaryDownload.js:405-415`: + * + * ```js + * fileStream.on('finish', async () => { // async listener, nobody awaits it + * ... + * await fs.promises.rename(tempDownloadLocation, downloadLocation); // :413 + * resolve(downloadLocation); + * }); + * ``` + * + * The winner renames `.tgz.downloading` to `.tgz`. The loser's + * `rename` then throws `ENOENT`, and because the listener is `async` and + * fire-and-forget, that rejection reaches NOBODY: the enclosing `new Promise` + * neither resolves nor rejects, so + * + * 1. the loser's `MongoMemoryServer.create()` never settles — it is + * {@link ACQUIRE_TIMEOUT_MS} that ends the wait, which is the observed + * "timed out after 120s" skip; and + * 2. the `ENOENT` surfaces as a process-level **unhandled rejection**, which + * vitest reports as `Errors 1 error` and turns into `exit 1` even though + * every test passed. + * + * That is why the two field shapes #5517 lists — "two suites race" and "one + * suite times out and its abandoned download blows up the run" — are ONE event + * seen from two sides, and why no consumer-side `.catch()` on the `create()` + * promise can fix it: the error never travels through that promise. It cost the + * merge queue at least three red builds in one day and ejected unrelated PRs. + * + * The maintainer's call was to RETIRE the download rather than build + * single-flight / prewarm infrastructure for a driver family whose investment is + * frozen (#5499). So: no `globalSetup` pre-download, no cross-worker lock, no + * workflow cache warming. The binary-dependent suites simply do not run unless a + * human asks for them with {@link MONGOD_TESTS_ENV}, and the gate is checked + * before the library is even imported, so a default run starts zero downloads. + * + * The retirement is deliberately LOUD, not silent: every gated suite prints one + * line naming #5517 and the switch, so the missing coverage is discoverable + * rather than folklore. What a default run honestly loses: the real-mongod + * halves of the filter-logic / temporal / pagination conformance matrices. + * `scripts/check-driver-conformance.mjs` keeps passing on the server-free + * halves, which still run — see the note recorded in that ledger. + * + * ## Running them + * + * ```bash + * OS_TEST_MONGODB_MEMORY_SERVER_ENABLED=1 pnpm --filter @objectstack/driver-mongodb test + * ``` + * + * The first run downloads the binary; later runs reuse `~/.cache/mongodb-binaries` + * and never race. An opt-in run that cannot get a binary still DEGRADES to a + * named skip rather than going red — the pre-#5517 behaviour, kept because a + * developer's network is not a test failure — and + * {@link installAbandonedDownloadGuard} keeps the abandoned download from + * ejecting that run. + * + * Deliberately NOT added: an `OS_EXPECT_*` "this runner provisioned it, so a skip + * is a defect" switch (the `OS_EXPECT_LIVE_DIALECT_MATRIX` shape). Nothing + * provisions a mongod binary in CI any more, so the flag would have no consumer; + * it belongs with whatever future decision un-freezes this family. */ -import { MongoMemoryServer } from 'mongodb-memory-server'; +// Type-only: erased at compile time, so importing THIS module cannot pull in +// `mongodb-memory-server`. The value import lives behind the gate, in +// `createTestMongod`. `mongodb-memory-server-gate.test.ts` pins that property. +import type { MongoMemoryServer } from 'mongodb-memory-server'; + +/** + * Opt-in switch for every suite in this package that needs a real mongod. + * + * `OS_TEST_*` per Prime Directive #9's test/CI-only shape and `_ENABLED` because + * it is a boolean opt-in — the same spelling as `OS_TEST_MULTI_ORG_ENABLED`. + */ +export const MONGOD_TESTS_ENV = 'OS_TEST_MONGODB_MEMORY_SERVER_ENABLED'; /** - * How long to wait for the binary fetch + first launch before giving up. + * How long to wait for the binary fetch + first launch before giving up, on the + * opt-in path. * - * Generous against a slow-but-working download (the observed healthy case takes - * seconds; this allows two orders of magnitude more) and still far inside the - * stall guard's 10-minute silence budget, so a hung fetch is reported by THIS - * module — naming the cause — rather than by a guard that can only say the job - * stopped producing output. + * Generous against a slow-but-working download (a healthy one takes seconds; + * this allows two orders of magnitude more) and still far inside the CI stall + * guard's 10-minute silence budget, so a hung fetch is reported by THIS module — + * naming the cause — rather than by a guard that can only say the job stopped + * producing output. */ const ACQUIRE_TIMEOUT_MS = 120_000; /** - * A started `MongoMemoryServer`, or `undefined` when one could not be obtained - * within {@link ACQUIRE_TIMEOUT_MS} — for any reason: download blocked, download - * hung, or launch refused. Callers gate with `describe.skipIf(!mongod)`. + * Is the caller asking for the binary-dependent suites? * - * @param suite - Suite name for the warning, e.g. `'MongoDBDriver'`. + * Read per call rather than captured at module load so the gate is testable, and + * so a value that is set-but-not-`1` can be reported instead of read as "off". + */ +export function mongodTestsEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + return env[MONGOD_TESTS_ENV] === '1'; +} + +/** + * Print a notice so that it survives a GREEN run. + * + * Not `console.warn`, and this is measured rather than assumed: on vitest 4.1.10 + * with this package's config, the DEFAULT reporter prints nothing that + * `console.warn` / `console.log` emits from a file whose tests passed or skipped + * — only `--reporter=verbose` renders those, under a `stderr | ` header — + * while a direct `process.stderr.write` always reaches the log, because it + * bypasses vitest's console interception entirely. (That asymmetry is also why + * the field logs in #5517 showed `Downloading MongoDB "8.2.6": 100%`: the library + * writes its progress straight to stdout.) + * + * A skip whose reason nobody can see in a green log is the silent skip this gate + * exists to avoid, so the notices below do not go through `console`. + * + * @param write - Sink seam for tests; defaults to this process's stderr. + */ +export function printMongodNotice( + message: string, + write: (chunk: string) => void = (chunk) => { + process.stderr.write(chunk); + }, +): void { + write(`${message}\n`); +} + +/** The one line a gated-off suite prints. Names the issue and the switch. */ +export function mongodSkipReason(suite: string, env: NodeJS.ProcessEnv = process.env): string { + const raw = env[MONGOD_TESTS_ENV]; + const misset = raw === undefined || raw === '' + ? '' + : ` (${MONGOD_TESTS_ENV} is set to "${raw}", which does NOT enable it — only "1" does)`; + return ( + `[driver-mongodb] SKIP ${suite} — needs a real mongod, and mongodb-memory-server would ` + + 'download a ~123 MB binary; retired from default test runs by #5517 (concurrent downloads ' + + `made green runs exit 1). Set ${MONGOD_TESTS_ENV}=1 to run it${misset}.` + ); +} + +/** + * Does this rejection look like the abandoned download of #5517 — the loser of a + * concurrent-download race failing to rename a temp file the winner already + * moved? + * + * Deliberately narrow: the exact `fs` error shape `MongoBinaryDownload.js:413` + * produces (`ENOENT` + `rename` + a source path ending in the library's + * `.downloading` suffix, built at that file's line 170). Anything else is + * somebody's real bug and must stay loud — see + * {@link installAbandonedDownloadGuard}, which re-raises what this does not match. + */ +export function isAbandonedDownloadRejection(reason: unknown): boolean { + if (typeof reason !== 'object' || reason === null) return false; + const err = reason as { code?: unknown; syscall?: unknown; path?: unknown }; + return ( + err.code === 'ENOENT' + && err.syscall === 'rename' + && typeof err.path === 'string' + && err.path.endsWith('.downloading') + ); +} + +/** The bits of `process` the guard needs — injectable so tests never touch the real one. */ +export interface RejectionGuardHost { + on(event: 'unhandledRejection', listener: (reason: unknown) => void): unknown; + off(event: 'unhandledRejection', listener: (reason: unknown) => void): unknown; +} + +export interface RejectionGuardOptions { + host?: RejectionGuardHost; + /** + * How a rejection the guard does NOT recognise is re-surfaced. Default: + * re-throw on a later tick, which lands in vitest's `uncaughtException` + * listener and fails the run. + */ + reraise?: (reason: unknown) => void; + /** Where the recognised-and-swallowed line goes. Default: {@link printMongodNotice}. */ + warn?: (message: string) => void; +} + +let disposeGuard: (() => void) | undefined; + +/** + * Swallow the abandoned-download rejection, and ONLY that one, for the rest of + * this worker's life. Installed by {@link createTestMongod} on the opt-in path + * only — a default run downloads nothing, so it needs no guard, and vitest's own + * error handling is left untouched there. + * + * Why a process listener is the only place this can be caught: the rejection is + * born inside the library's unawaited `async` listener (see the module note), so + * it never passes through the `create()` promise we hold. + * + * Why this must re-raise everything else: vitest's worker handler steps aside the + * moment any other `unhandledRejection` listener exists — + * `if (processListeners(event).length > 1) return;` in + * `vitest/dist/chunks/init.*.js` — so a listener that only swallowed would + * silence every OTHER unhandled rejection in the worker too, which is exactly + * the class of hidden failure #5517 is about. Re-raising as an uncaught + * exception keeps the run red; the label changes ("Unhandled Rejection" -> + * "Uncaught Exception"), the verdict does not. + * + * Idempotent: repeat calls (one per gated suite in the worker) install one + * listener. + */ +export function installAbandonedDownloadGuard(options: RejectionGuardOptions = {}): void { + if (disposeGuard) return; + const host = options.host ?? process; + const warn = options.warn ?? ((message: string) => printMongodNotice(message)); + const reraise = options.reraise + ?? ((reason: unknown) => { + setTimeout(() => { + throw reason; + }, 0); + }); + + const listener = (reason: unknown): void => { + if (!isAbandonedDownloadRejection(reason)) { + reraise(reason); + return; + } + warn( + '[driver-mongodb] Ignoring the abandoned MongoDB binary download of #5517 ' + + `(${(reason as Error).message}). Another worker won the race and renamed the archive; ` + + 'the suite that lost it has already degraded to a named skip, and this rejection must ' + + 'not fail an otherwise green run.', + ); + }; + + host.on('unhandledRejection', listener); + disposeGuard = () => host.off('unhandledRejection', listener); +} + +/** + * Remove the guard. Test-only seam: real runs keep it for the worker's lifetime, + * because the rejection it catches can arrive long after the suite that started + * the download has finished. + */ +export function disposeAbandonedDownloadGuard(): void { + disposeGuard?.(); + disposeGuard = undefined; +} + +/** + * A started `MongoMemoryServer`, or `undefined` — in which case the caller must + * skip, via `describe.skipIf(!mongod)`. + * + * `undefined` has two causes, and both print why: + * - the {@link MONGOD_TESTS_ENV} gate is closed (the default; no library + * import, no download, no launch); or + * - the caller opted in and the binary could not be obtained within + * {@link ACQUIRE_TIMEOUT_MS} — download blocked, download hung, or launch + * refused. + * + * @param suite - Suite name for the printed line, e.g. `'MongoDBDriver'`. */ export async function createTestMongod(suite: string): Promise { + if (!mongodTestsEnabled()) { + printMongodNotice(mongodSkipReason(suite)); + return undefined; + } + + installAbandonedDownloadGuard(); + let timer: ReturnType | undefined; try { + // Behind the gate on purpose: this import is the first step that can lead + // to a download, so it must not happen in a default run. + const { MongoMemoryServer } = await import('mongodb-memory-server'); const started = MongoMemoryServer.create({ instance: { launchTimeout: 60_000 } }); const timeout = new Promise((_resolve, reject) => { timer = setTimeout( () => reject(new Error( `timed out after ${ACQUIRE_TIMEOUT_MS / 1000}s waiting for the MongoDB binary ` - + '(fastdl.mongodb.org unreachable or hanging)', + + '(fastdl.mongodb.org unreachable or hanging, or another worker holds the ' + + 'download — #5517)', )), ACQUIRE_TIMEOUT_MS, ); }); - // Whichever settles first. On timeout the underlying `create()` promise - // is abandoned rather than cancelled — the library exposes no cancel — - // but the process is already headed for a skipped suite and vitest tears - // the worker down at the end of the file. + // Whichever settles first. On timeout the underlying `create()` promise is + // abandoned rather than cancelled — the library exposes no cancel — but + // `Promise.race` has already attached handlers to it, so ITS rejection is + // handled. The rejection that is not is the library's internal one, which + // the guard above owns. return await Promise.race([started, timeout]); } catch (err) { - console.warn( - `[driver-mongodb] Skipping ${suite} suite — mongodb-memory-server could not start ` + printMongodNotice( + `[driver-mongodb] SKIP ${suite} — mongodb-memory-server could not start ` + `(MongoDB binary unavailable / download blocked or hanging): ${(err as Error)?.message ?? String(err)}`, ); return undefined; diff --git a/scripts/check-driver-conformance.mjs b/scripts/check-driver-conformance.mjs index 2582badb6e..5110afec24 100644 --- a/scripts/check-driver-conformance.mjs +++ b/scripts/check-driver-conformance.mjs @@ -157,6 +157,39 @@ const CASE_SETS = [ // An empty ledger is the intended steady state, not a reason to delete the // mechanism: the next driver that arrives uncovered fails CONSUMED and lands // its measured entry here. +// +// ## What driver-mongodb's cells mean since #5517 — read this before trusting them +// +// This gate judges coverage by IMPORT: does some file under the package's `src/` +// name the marker export. That is deliberate (see "What 'covered' means"), and it +// is why no entry below changed when #5517 made the mongodb suites that need a +// real mongod OPT-IN (`OS_TEST_MONGODB_MEMORY_SERVER_ENABLED=1`, gate in +// `packages/plugins/driver-mongodb/src/test-mongod.ts`): the files still exist +// and still import the markers, so CONSUMED still passes — honestly, but about +// less than it did. Recorded here rather than as a ledger entry because an entry +// for a covered cell fails RECONCILED; this is the only place the fact fits. +// +// Measured on the day of that change, per marker, for driver-mongodb: +// +// FILTER_LOGIC_CASES still runs by default — `mongodb-filter-logic- +// translation.test.ts` drives the whole case-set +// server-free over the documents `translateFilter` +// emits. The real-mongod twin +// (`mongodb-filter-logic-conformance.test.ts`) is +// opt-in. +// PAGINATION_CASES, opt-in only. `mongodb-pagination-conformance.test.ts` +// PAGINATION_UNORDERED_CASES keeps a server-free half, but it asserts the SORT +// SPEC, not the partition property the case-sets +// define. +// TEMPORAL_CASES, opt-in only — `mongodb-temporal-conformance.test.ts` +// TEMPORAL_TIME_CASES has no server-free half. +// +// Why: on a cold binary cache two vitest workers downloaded the same ~123 MB +// archive and the loser's `rename` blew up an all-green run as an unhandled +// rejection, ejecting unrelated PRs from the merge queue. The maintainer retired +// the download rather than fund single-flight/prewarm for a family whose +// investment is frozen (#5499). Un-freezing it is what should re-run these cells +// in CI; until then, this note is the honest state of the mongo column. const LEDGER = [];