Skip to content

Commit ca5e799

Browse files
committed
test(runtime): add the #3259 flake canary — nested writes stay green at the stock 250ms CPU budget under load
The verification "flake canary" from the ADR-0102 plan (#3275). It drives the real nested-write scenario (child afterInsert -> nested parent.update -> the parent's own hook = a second re-entrant VM) across 128 chains CONCURRENTLY through one shared stock runner, and asserts every invocation lands the correct rollup with zero CPU-budget / wall-ceiling errors at the stock 250ms budget. Closes the loop on ADR-0102 D1. #3259 was a LOAD flake: under the old wall-clock budget, the time an invocation spent parked at an `await` while other concurrent invocations ran was charged to it, so it blew 250ms under oversubscription even though its own VM did microseconds of work. D1 charges script CPU-time only, so parked time is not counted. Validated as a real regression gate, not a no-op: temporarily reverting the interrupt handler to a `Date.now() - start` wall budget turns this suite RED from ~64 concurrent chains up, while the shipped CPU budget stays green through 256. It is robust (not itself flaky) because concurrency stresses the parked-time accounting D1 excludes, not slice size — the charged CPU per tiny rollup body is invariant to how many chains are in flight. `OS_CANARY_CONCURRENCY` overrides the default for an ad-hoc heavier soak. Test-only; no changeset. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011iJLjqToxNv1aYP3syQtRp
1 parent 45a3769 commit ca5e799

1 file changed

Lines changed: 179 additions & 0 deletions

File tree

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Flake canary for #3259 (ADR-0102 D1 — the CPU-time budget).
5+
*
6+
* #3259 was a LOAD flake, not a logic bug: on an oversubscribed CI runner the
7+
* fixed per-invocation WASM-module creation cost alone tripped the 250ms hook
8+
* deadline while the VM was still progressing — because the budget was
9+
* WALL-CLOCK. When many hook invocations run concurrently on one JS thread, any
10+
* single invocation spends most of its wall time PARKED at an `await` (host call
11+
* / `setImmediate` yield) while the OTHER invocations run; the old budget charged
12+
* that parked time to the parked invocation, so it blew 250ms under load even
13+
* though its own VM did microseconds of work. D1 made the budget script
14+
* CPU-time: only VM-active slices are charged, parked time is not, so the stock
15+
* 250ms budget is meaningful again under load.
16+
*
17+
* This canary reproduces that exact condition and asserts the fix holds: it runs
18+
* the real nested-write scenario (child `afterInsert` → nested `parent.update`
19+
* → the parent's OWN hook = a second re-entrant VM) across many chains
20+
* CONCURRENTLY through one shared stock runner. Every invocation runs at the
21+
* STOCK 250ms budget and must land the correct rollup with NOT ONE CPU-budget /
22+
* wall-ceiling error. Revert D1 to a wall-clock budget and the parked-while-
23+
* others-run time reappears on the charge → this goes red. That is the
24+
* regression it guards.
25+
*
26+
* Why the canary is itself robust (not flaky): the stressor is CONCURRENCY, not
27+
* unrelated CPU busy-loops. JS runs each synchronous VM slice to completion, so
28+
* concurrent chains interleave only BETWEEN slices (at `await` points), never
29+
* mid-slice — the `Date.now()`-measured slices stay accurate and no legitimate
30+
* tiny rollup body ever approaches 250ms of charged VM-active time regardless of
31+
* how many chains are in flight.
32+
*/
33+
34+
import { describe, it, expect } from 'vitest';
35+
import { ObjectQL, bindHooksToEngine } from '@objectstack/objectql';
36+
import { hookBodyRunnerFactory } from './body-runner.js';
37+
import { QuickJSScriptRunner } from './quickjs-runner.js';
38+
39+
const parent = {
40+
name: 'parent',
41+
label: 'Parent',
42+
fields: {
43+
id: { name: 'id', type: 'text' as const, primaryKey: true },
44+
total_amount: { name: 'total_amount', type: 'number' as const },
45+
child_count: { name: 'child_count', type: 'number' as const },
46+
},
47+
};
48+
const child = {
49+
name: 'child',
50+
label: 'Child',
51+
fields: {
52+
id: { name: 'id', type: 'text' as const, primaryKey: true },
53+
amount: { name: 'amount', type: 'number' as const },
54+
parent_id: { name: 'parent_id', type: 'text' as const },
55+
},
56+
};
57+
58+
function makeMemoryDriver() {
59+
const stores = new Map<string, Map<string, Record<string, unknown>>>();
60+
const storeFor = (o: string) => {
61+
let s = stores.get(o);
62+
if (!s) { s = new Map(); stores.set(o, s); }
63+
return s;
64+
};
65+
let nextId = 0;
66+
const matches = (row: Record<string, unknown>, where: any): boolean => {
67+
if (!where || typeof where !== 'object') return true;
68+
for (const [k, v] of Object.entries(where)) {
69+
if (k.startsWith('$')) continue;
70+
const exp = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v;
71+
if ((row[k] ?? null) !== (exp ?? null)) return false;
72+
}
73+
return true;
74+
};
75+
const driver: any = {
76+
name: 'memory', version: '0.0.0', supports: {},
77+
async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; },
78+
async find(o: string, ast: any) { return Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)); },
79+
findStream() { throw new Error('ns'); },
80+
async findOne(o: string, ast: any) { for (const r of storeFor(o).values()) if (matches(r, ast?.where)) return r; return null; },
81+
async create(o: string, data: Record<string, unknown>) {
82+
nextId += 1; const id = (data.id as string) ?? `r_${nextId}`; const row = { ...data, id }; storeFor(o).set(id, row); return row;
83+
},
84+
async update(o: string, id: string, data: Record<string, unknown>) {
85+
const s = storeFor(o); const cur = s.get(id); if (!cur) throw new Error(`nf ${o}/${id}`);
86+
const up = { ...cur, ...data, id }; s.set(id, up); return up;
87+
},
88+
async upsert(o: string, data: Record<string, unknown>) { const id = data.id as string | undefined; return id && storeFor(o).has(id) ? this.update(o, id, data) : this.create(o, data); },
89+
async delete(o: string, id: string) { return storeFor(o).delete(id); },
90+
async count(o: string, ast: any) { return (await this.find(o, ast)).length; },
91+
async bulkCreate(o: string, rows: Record<string, unknown>[]) { return Promise.all(rows.map((r) => this.create(o, r))); },
92+
async bulkUpdate() { return []; }, async bulkDelete() {},
93+
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, async commit() {}, async rollback() {},
94+
};
95+
return { driver, stores };
96+
}
97+
98+
// The rollup hook the issue says authors could not write, plus a trivial parent
99+
// hook whose mere presence forces a SECOND, re-entrant sandbox VM while the
100+
// child's hook VM is still in flight.
101+
const hooks = [
102+
{
103+
name: 'rollup_parent_total',
104+
object: 'child',
105+
events: ['afterInsert', 'afterUpdate'],
106+
body: {
107+
language: 'js',
108+
source: `
109+
const pid = ctx.input.parent_id || (ctx.previous && ctx.previous.parent_id);
110+
const rows = await ctx.api.object('child').find({ where: { parent_id: pid } });
111+
const total = rows.reduce((s, r) => s + (r.amount || 0), 0);
112+
await ctx.api.object('parent').update({ id: pid, total_amount: total, child_count: rows.length });
113+
`,
114+
capabilities: ['api.read', 'api.write'],
115+
},
116+
} as any,
117+
{
118+
name: 'parent_touch_marker',
119+
object: 'parent',
120+
events: ['afterUpdate'],
121+
body: { language: 'js', source: "return { seen: ctx.input.id };", capabilities: [] },
122+
} as any,
123+
];
124+
125+
describe('#3259 flake canary — nested writes stay green at the stock 250ms CPU budget under concurrency', () => {
126+
// One shared stock runner (250ms CPU budget), exactly as production wires it —
127+
// many concurrent invocations, each still getting its own isolated WASM module.
128+
const runner = new QuickJSScriptRunner();
129+
130+
async function rollupChain(n: number) {
131+
const engine = new ObjectQL();
132+
const { driver } = makeMemoryDriver();
133+
engine.registerDriver(driver, true);
134+
await engine.init();
135+
for (const o of [parent, child]) engine.registry.registerObject(o as any);
136+
engine.setDefaultBodyRunner(hookBodyRunnerFactory(runner, { ql: engine, appId: 'test' }));
137+
bindHooksToEngine(engine, hooks, { packageId: 'test' });
138+
139+
const pid = `p_${n}`;
140+
await engine.insert('parent', { id: pid, total_amount: 0, child_count: 0 });
141+
await engine.insert('child', { id: `c_${n}_1`, amount: 100, parent_id: pid });
142+
await engine.insert('child', { id: `c_${n}_2`, amount: 50, parent_id: pid });
143+
return (await engine.findOne('parent', { where: { id: pid } })) as any;
144+
}
145+
146+
it('runs many concurrent nested-write rollups with no CPU-budget / wall-ceiling errors', async () => {
147+
// Enough chains in flight that, under the OLD wall-clock budget, each one's
148+
// parked-while-others-run time blows past 250ms; under the CPU budget it does
149+
// not, because that parked time is not charged. Validated during development:
150+
// temporarily reverting the interrupt handler to a `Date.now() - start` wall
151+
// budget turns this suite RED from ~64 chains up, while the shipped CPU budget
152+
// stays green through 256 — so this is a real regression gate, not a no-op.
153+
// `OS_CANARY_CONCURRENCY` overrides it for an ad-hoc heavier soak.
154+
const CONCURRENCY = Number(process.env.OS_CANARY_CONCURRENCY) || 128;
155+
156+
const results = await Promise.allSettled(
157+
Array.from({ length: CONCURRENCY }, (_, n) => rollupChain(n)),
158+
);
159+
160+
const reasons = results
161+
.filter((r): r is PromiseRejectedResult => r.status === 'rejected')
162+
.map((r) => String(r.reason?.message ?? r.reason));
163+
164+
// The signature of a #3259 regression: a load-induced budget/ceiling trip.
165+
// Assert it separately so a failure names the actual cause, not a generic count.
166+
const budgetTrips = reasons.filter((m) => /CPU budget|wall-clock ceiling/.test(m));
167+
expect(budgetTrips, 'no load-induced CPU-budget / wall-ceiling trips at the stock 250ms budget').toEqual([]);
168+
169+
// No failures of any kind, and every rollup landed the correct denormalized total.
170+
expect(reasons, 'all concurrent nested-write chains succeed').toEqual([]);
171+
for (const r of results) {
172+
expect(r.status).toBe('fulfilled');
173+
if (r.status === 'fulfilled') {
174+
expect(r.value.total_amount).toBe(150);
175+
expect(r.value.child_count).toBe(2);
176+
}
177+
}
178+
}, 120000);
179+
});

0 commit comments

Comments
 (0)