diff --git a/packages/plugins/plugin-audit/src/comment-parent-pair.integration.test.ts b/packages/plugins/plugin-audit/src/comment-parent-pair.integration.test.ts index 23969c2ae5c..0e123e09e84 100644 --- a/packages/plugins/plugin-audit/src/comment-parent-pair.integration.test.ts +++ b/packages/plugins/plugin-audit/src/comment-parent-pair.integration.test.ts @@ -1,4 +1,5 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// test-budget: 40 — spawns the real producer once per single-cause fixture: three in-process kernel boots on a real SQL driver, the first seeding 2,600 comments on 260 docs through the real writers to sit past the 2,000-row window the comment gate replaced; slowest CI reading 28.07 s (run 40204927516, refresh PR #25960's window (hourly runs 40200279306 to 40272142430)) x 1.25, rounded up; read before this file seeded its comments ten to a doc and paid its first boot at module scope /** * [ADR-0139 E7] The comment-thread read gate on the LEAF path, over @@ -55,9 +56,19 @@ const DELEGATED_A = { ...MEMBER_A, onBehalfOf: { userId: 'u_delegator' } }; /** The delegator's own leg on `e7_doc` admits every record but these (by name). */ const WITHHELD_FROM_DELEGATOR = ['a0', 'a1', 'a2']; -/** Past the 2,000-row candidate window the leaf replaces. */ -const DOCS_OF_A = 2_300; -const DOCS_OF_B = 300; +/** + * Past the 2,000-row candidate window the leaf replaces: 2,300 comments member + * A may read, 300 member B may. The window was over COMMENT rows (the thread + * probe's candidate pre-scan reads `sys_comment`, `READ_SCAN_LIMIT` in + * `comment-access-hooks.ts`), never over parent records, so the comments sit + * ten to a doc. [#23181] One comment per doc wrote 2,600 docs, and each doc + * write pays the CRUD mirror's ledger row and activity row, which nothing in + * this file reads: about 6 s of the file's clock, against well under 1 s for + * the comments themselves. + */ +const COMMENTS_PER_DOC = 10; +const DOCS_OF_A = 230; +const DOCS_OF_B = 30; const BATCH = 200; const READ_OPS = new Set(['find', 'findOne', 'count', 'aggregate']); @@ -145,6 +156,12 @@ const commentOn = (object: string, id: string, body: string): Row => ({ author_id: 'u_fixture', }); +// [#23181] Pay the first kernel boot at module scope, never in a hook: vitest +// clocks a hook as it clocks a test, and the first boot is the slow one. The +// throwaway kernel registers no fixture object, writes nothing and is shut down +// before any hook runs. +await (await bootKernel(new SqliteWasmDriver({ filename: ':memory:' }))).kernel.shutdown(); + describe('[ADR-0139 E7] the comment-thread read gate over the stamped parent pair', () => { let kernel: ObjectKernel; let engine: ObjectQL; @@ -209,7 +226,8 @@ describe('[ADR-0139 E7] the comment-thread read gate over the stamped parent pai for (let i = 0; i < DOCS_OF_A; i++) docs.push({ name: `a${i}`, owner: MEMBER_A.userId }); for (let i = 0; i < DOCS_OF_B; i++) docs.push({ name: `b${i}`, owner: MEMBER_B.userId }); const docIds = await insertInBatches(engine, DOC, docs); - await insertInBatches(engine, COMMENT, docIds.map((id, i) => commentOn(DOC, id, `c${i}`))); + await insertInBatches(engine, COMMENT, docIds.flatMap((id, i) => + Array.from({ length: COMMENTS_PER_DOC }, (_, k) => commentOn(DOC, id, `c${i * COMMENTS_PER_DOC + k}`)))); ids.gone = String((await engine.insert(DOC, { name: 'gone', owner: MEMBER_A.userId }, { context: SYS })).id); ids.vault = String((await engine.insert(VAULT, { name: 'vault' }, { context: SYS })).id); diff --git a/packages/plugins/plugin-audit/src/parent-record-read-leaf.integration.test.ts b/packages/plugins/plugin-audit/src/parent-record-read-leaf.integration.test.ts index 657f8188360..128b558ba1b 100644 --- a/packages/plugins/plugin-audit/src/parent-record-read-leaf.integration.test.ts +++ b/packages/plugins/plugin-audit/src/parent-record-read-leaf.integration.test.ts @@ -1,4 +1,5 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// test-budget: 50 — spawns the real producer once per single-cause fixture: two in-process kernel boots on a real SQL driver, the first seeding 2,600 records through the real CRUD mirror (each pays its activity row and its ledger row) to sit past the 2,000-row window the leaf gate replaced; slowest CI reading 35.78 s (run 40204927516, refresh PR #25960's window (hourly runs 40200279306 to 40272142430)) x 1.25, rounded up; read before this file reused its at-rest reads and paid its first boot at module scope /** * [ADR-0139 E5] The parent-record read gates on the LEAF path, through a real @@ -180,6 +181,12 @@ async function insertInBatches(engine: ObjectQL, object: string, rows: Row[]): P } } +// [#23181] Pay the first kernel boot at module scope, never in a hook: vitest +// clocks a hook as it clocks a test, and the first boot is the slow one. The +// throwaway kernel registers no fixture object, writes nothing and is shut down +// before any hook runs. +await (await bootKernel(new SqliteWasmDriver({ filename: ':memory:' }))).kernel.shutdown(); + describe('[ADR-0139 E5] the parent-record read gates on the leaf path', () => { let kernel: ObjectKernel; let engine: ObjectQL; @@ -201,12 +208,30 @@ describe('[ADR-0139 E5] the parent-record read gates on the leaf path', () => { const ledgerOrganizationBound: boolean[] = []; const ids: Record = {}; + /** + * [#23181] Every row of an object at rest, as the system reads it — read once + * and reused. No case in this describe writes after `beforeAll`, so each such + * read answered the same rows: the cases made nearly seventy of them, most + * over tables of thousands of rows. Each reuse counts the object first and + * re-reads on a moved count, so a case that adds or removes rows is never + * judged against an older ground truth. + */ + const atRestCache = new Map(); + const atRestOf = async (object: string): Promise => { + const count = await engine.count(object, {}, { context: SYS }); + const hit = atRestCache.get(object); + if (hit && hit.count === count) return hit.rows; + const rows: Row[] = await engine.find(object, { limit: 100_000, context: SYS }); + atRestCache.set(object, { count, rows }); + return rows; + }; + /** Every row at rest, and the ground truth of who may read which, judged by the stand-in's own rules. */ const truth = async (object: string, reader: any): Promise => { - const atRest: Row[] = await engine.find(object, { limit: 100_000, context: SYS }); + const atRest = await atRestOf(object); const live = new Map>(); for (const parent of [DOC, VAULT, GATED, FAR]) { - const records: Row[] = await engine.find(parent, { limit: 100_000, context: SYS }); + const records = await atRestOf(parent); live.set(parent, new Map(records.map((r) => [String(r.id), r]))); } const holder = Array.isArray(reader?.systemPermissions) && reader.systemPermissions.includes(LEDGER_AUDIT_CAPABILITY); @@ -298,12 +323,12 @@ describe('[ADR-0139 E5] the parent-record read gates on the leaf path', () => { }, 30_000); it('control: the fixture is past the window, and every row came from its producer', async () => { - const activity: Row[] = await engine.find(ACTIVITY, { limit: 100_000, context: SYS }); + const activity = await atRestOf(ACTIVITY); expect(activity.length).toBeGreaterThan(PARENT_GATE_SCAN_LIMIT); expect(activity.filter((r) => r.object_name === DOC && r.type === 'created').length).toBe(DOCS_OF_A + DOCS_OF_B + 1); expect(activity.some((r) => r.object_name === DOC && r.record_id === ids.gone && r.type === 'deleted')).toBe(true); expect((await truth(ACTIVITY, MEMBER_A)).length).toBeGreaterThan(PARENT_GATE_SCAN_LIMIT); - const ledger: Row[] = await engine.find(LEDGER, { limit: 100_000, context: SYS }); + const ledger = await atRestOf(LEDGER); expect(ledger.filter((r) => r.action === 'config_change').length).toBe(ROWS_ABOUT_NO_RECORD); }); @@ -328,7 +353,7 @@ describe('[ADR-0139 E5] the parent-record read gates on the leaf path', () => { }); it('no row is served whose parent the reader may not read, or that no longer exists', async () => { - const all: Row[] = await engine.find(ACTIVITY, { limit: 100_000, context: SYS }); + const all = await atRestOf(ACTIVITY); const byId = new Map(all.map((r) => [String(r.id), r])); for (const id of await served(ACTIVITY, MEMBER_A)) { const row = byId.get(id)!; @@ -379,7 +404,7 @@ describe('[ADR-0139 E5] the parent-record read gates on the leaf path', () => { const named = { object_name: { $in: NAMED } }; // The same rows, in a spelling the gate does not read as naming its parents: it keeps every branch. const spelledOut = { $or: NAMED.map((object_name) => ({ object_name })) }; - const atRest: Row[] = await engine.find(ACTIVITY, { limit: 100_000, context: SYS }); + const atRest = await atRestOf(ACTIVITY); const parentOf = new Map(atRest.map((r) => [String(r.id), r.object_name])); const gateConjunct = (where: any) => where.$and[1]; seenWhere[ACTIVITY].length = 0; @@ -431,7 +456,7 @@ describe('[ADR-0139 E5] the parent-record read gates on the leaf path', () => { // per parent object through the engine middleware chain, with the // delegated context. Asked here for every parent pair at rest, so no // window cuts it; the leaf must serve exactly the rows it admits. - const atRest: Row[] = await engine.find(ACTIVITY, { limit: 100_000, context: SYS }); + const atRest = await atRestOf(ACTIVITY); const pairs = new Map>(); for (const row of atRest) { const parent = parseParentRecord(row, ACTIVITY); @@ -488,7 +513,7 @@ describe('[ADR-0139 E5] the parent-record read gates on the leaf path', () => { return Math.round((performance.now() - t0) * 10) / 10; }; const reading = { - rowsAtRest: (await engine.find(ACTIVITY, { limit: 100_000, context: SYS })).length, + rowsAtRest: (await atRestOf(ACTIVITY)).length, leafMs: { memberFind: await time(() => served(ACTIVITY, MEMBER_A)), memberCount: await time(() => engine.count(ACTIVITY, {}, { context: MEMBER_A })),