@ultimat3/testing 29.4.0 → 30.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +56 -63
- package/README.md +281 -6
- package/package.json +27 -16
- package/src/ax-node.ts +96 -0
- package/src/cassette-errors.ts +91 -0
- package/src/cassette-format.ts +175 -0
- package/src/cassette-record.ts +120 -0
- package/src/cassette-secret-scan.ts +77 -0
- package/src/cassette.ts +166 -0
- package/src/cdp-browser.ts +5 -3
- package/src/cdp-e2e-page.ts +91 -3
- package/src/cdp-e2e-session.ts +18 -1
- package/src/define-exercise.ts +92 -0
- package/src/drill-budgets.ts +54 -0
- package/src/drill-errors.ts +45 -0
- package/src/drill-fleet.ts +251 -0
- package/src/drill-jobs.ts +79 -0
- package/src/drill-load.ts +166 -0
- package/src/drill-probe.ts +68 -0
- package/src/drill-run.ts +144 -0
- package/src/drill-services.ts +70 -0
- package/src/drill-sync.ts +108 -0
- package/src/drill-verdict.ts +256 -0
- package/src/drill-writes.ts +123 -0
- package/src/drills.ts +109 -0
- package/src/e2e-a11y-run.ts +189 -0
- package/src/e2e-a11y-tabs.ts +113 -0
- package/src/e2e-a11y.ts +153 -0
- package/src/e2e-driver.ts +46 -10
- package/src/e2e-page.ts +28 -3
- package/src/e2e-run.ts +1 -0
- package/src/e2e-spawn.ts +3 -2
- package/src/errors.ts +43 -0
- package/src/exercise-actor.ts +124 -0
- package/src/exercise-crash.ts +46 -0
- package/src/exercise-input.ts +182 -0
- package/src/exercise-override-input.ts +34 -0
- package/src/exercise-report.ts +282 -0
- package/src/exercise-request.ts +38 -0
- package/src/exercise-sweeps.ts +100 -0
- package/src/exercise.ts +451 -0
- package/src/factories.ts +10 -7
- package/src/factory-registry.ts +49 -15
- package/src/fake-identity-provider.ts +191 -0
- package/src/fault-proxy.ts +342 -0
- package/src/file-boundary.ts +4 -1
- package/src/fixture-jobs.ts +3 -1
- package/src/fixture-statements.ts +4 -1
- package/src/free-port.ts +59 -0
- package/src/index.ts +148 -3
- package/src/island-dom.ts +5 -0
- package/src/isolated-plugins.ts +1 -1
- package/src/job-crash-proof.ts +249 -0
- package/src/job-sim-driver.ts +164 -0
- package/src/job-sim-effects.ts +142 -0
- package/src/job-sim-errors.ts +72 -0
- package/src/job-sim-schedule.ts +154 -0
- package/src/job-sim-world.ts +198 -0
- package/src/job-simulate.ts +179 -0
- package/src/mock-route.ts +81 -0
- package/src/module-mock-boundary.ts +85 -0
- package/src/module-mock-error.ts +28 -0
- package/src/plan-oracle.ts +164 -0
- package/src/plan-sweep-fix.ts +123 -0
- package/src/plan-sweep-report.ts +84 -0
- package/src/plan-sweep.ts +148 -0
- package/src/preload.ts +24 -4
- package/src/registry-snapshot.ts +1 -1
- package/src/repro-stand-in.ts +67 -0
- package/src/repro.ts +158 -0
- package/src/request-log.ts +64 -0
- package/src/reusable-database.ts +1 -1
- package/src/scale-sweep-graph.ts +82 -0
- package/src/scale-sweep-run.ts +134 -0
- package/src/scale-sweep.ts +74 -0
- package/src/sealed-network.ts +63 -46
- package/src/seed-graph-error.ts +50 -0
- package/src/seed-graph-order.ts +98 -0
- package/src/seed-graph-pass.ts +85 -0
- package/src/seed-graph-plan.ts +81 -0
- package/src/seed-graph-row.ts +134 -0
- package/src/seed-graph-shape.ts +104 -0
- package/src/seed-graph-values.ts +135 -0
- package/src/seed-graph.ts +250 -0
- package/src/seed-writer.ts +180 -0
- package/src/subject-proof-error.ts +23 -0
- package/src/subject-proof.ts +180 -0
- package/src/template-db-roles.ts +51 -0
- package/src/template-db.ts +8 -2
- package/src/tenant-canary.ts +120 -0
- package/src/tenant-proof-report.ts +62 -0
- package/src/tenant-proof.ts +177 -0
- package/src/test-types.ts +12 -3
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// The effect log a simulated run is judged by: row writes (the entity row observer), mail, calls
|
|
2
|
+
// out and enqueues, each attributed to the worker whose body made it. Reads are not effects, and
|
|
3
|
+
// neither is what the process reports. An effect a worker makes after it died is dropped. Every
|
|
4
|
+
// seam it borrows is chained onto what was installed — the reporter set aside — and handed back.
|
|
5
|
+
|
|
6
|
+
import type { AsyncContext } from '@ultimat3/core';
|
|
7
|
+
import {
|
|
8
|
+
asyncContext,
|
|
9
|
+
configureErrorReporting,
|
|
10
|
+
installedErrorReporter,
|
|
11
|
+
noopErrorReporter,
|
|
12
|
+
} from '@ultimat3/core';
|
|
13
|
+
import type { RowObserver } from '@ultimat3/entity';
|
|
14
|
+
import type { MailDriver } from '@ultimat3/mail';
|
|
15
|
+
|
|
16
|
+
export interface SimEffect {
|
|
17
|
+
/** `row:posts:insert`, `mail:receipt→a@x.example`, `network:POST api.example/v1/charges`. */
|
|
18
|
+
readonly key: string;
|
|
19
|
+
/** The worker whose body made it; absent for the harness. */
|
|
20
|
+
readonly worker?: string;
|
|
21
|
+
/** The last step that worker's run had written when the effect happened. */
|
|
22
|
+
readonly after?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface EffectLog extends AsyncDisposable {
|
|
26
|
+
/** The worker a call in flight belongs to: what `run` scoped it to. */
|
|
27
|
+
readonly scope: AsyncContext<string>;
|
|
28
|
+
record(key: string): void;
|
|
29
|
+
/** A worker's run wrote this step — what the next effect it makes is placed after. */
|
|
30
|
+
stepWritten(worker: string, step: string): void;
|
|
31
|
+
effects(): readonly SimEffect[];
|
|
32
|
+
clear(): void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface EffectLogOptions {
|
|
36
|
+
readonly isDead: (worker: string) => boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const hostAndPath = (url: string): string => {
|
|
40
|
+
if (!URL.canParse(url)) return url;
|
|
41
|
+
const parsed = new URL(url);
|
|
42
|
+
return `${parsed.host}${parsed.pathname}`;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const urlOf = (input: RequestInfo | URL): string =>
|
|
46
|
+
typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
|
47
|
+
|
|
48
|
+
const methodOf = (input: RequestInfo | URL, init: RequestInit | undefined): string =>
|
|
49
|
+
(
|
|
50
|
+
init?.method ?? (typeof input === 'object' && 'method' in input ? input.method : 'GET')
|
|
51
|
+
).toUpperCase();
|
|
52
|
+
|
|
53
|
+
export async function openEffectLog(options: EffectLogOptions): Promise<EffectLog> {
|
|
54
|
+
const { setRowObserver } = await import('@ultimat3/entity');
|
|
55
|
+
const mail = await import('@ultimat3/mail');
|
|
56
|
+
const scope = asyncContext<string>('the job simulation');
|
|
57
|
+
const log: SimEffect[] = [];
|
|
58
|
+
const lastStep = new Map<string, string>();
|
|
59
|
+
|
|
60
|
+
const record = (key: string): void => {
|
|
61
|
+
const worker = scope.get();
|
|
62
|
+
if (worker !== undefined && options.isDead(worker)) return;
|
|
63
|
+
const after = worker === undefined ? undefined : lastStep.get(worker);
|
|
64
|
+
log.push({
|
|
65
|
+
key,
|
|
66
|
+
...(worker === undefined ? {} : { worker }),
|
|
67
|
+
...(after === undefined ? {} : { after }),
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const previousRows = setRowObserver(null);
|
|
72
|
+
const rows: RowObserver = {
|
|
73
|
+
onChange: (change) => {
|
|
74
|
+
record(`row:${change.entity}:${change.op}`);
|
|
75
|
+
previousRows?.onChange(change);
|
|
76
|
+
},
|
|
77
|
+
onBulk: (change) => {
|
|
78
|
+
for (let i = 0; i < change.rows; i += 1) record(`row:${change.entity}:${change.op}`);
|
|
79
|
+
previousRows?.onBulk?.(change);
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
setRowObserver(rows);
|
|
83
|
+
|
|
84
|
+
// A send with no mail driver installed is answered by an in-memory one: the effect is the send.
|
|
85
|
+
const previousMail = mail.tryMailDriver();
|
|
86
|
+
const deliver = previousMail ?? mail.memoryMailDriver();
|
|
87
|
+
const recording: MailDriver = {
|
|
88
|
+
name: deliver.name,
|
|
89
|
+
send: async (message) => {
|
|
90
|
+
const sent = await deliver.send(message);
|
|
91
|
+
record(`mail:${message.mailId}→${message.to.join(',')}`);
|
|
92
|
+
return sent;
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
mail.setMailDriver(recording);
|
|
96
|
+
|
|
97
|
+
// Set aside, never chained: a report is the process telling its operator, not the body acting.
|
|
98
|
+
// An app's sink keeps state across runs (`errorGroupsSink` enqueues on a group's FIRST sighting),
|
|
99
|
+
// so chained, whichever simulated run reported first would carry its enqueue and every other
|
|
100
|
+
// run's count would differ — and a death planted here would land in the app's own error store.
|
|
101
|
+
const previousReporter = installedErrorReporter();
|
|
102
|
+
configureErrorReporting({ reporter: noopErrorReporter });
|
|
103
|
+
|
|
104
|
+
// Recorded on the way out, then handed to whatever `fetch` is installed — the seal decides.
|
|
105
|
+
const previousFetch = globalThis.fetch;
|
|
106
|
+
const recordingFetch = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
107
|
+
record(`network:${methodOf(input, init)} ${hostAndPath(urlOf(input))}`);
|
|
108
|
+
return previousFetch(input, init);
|
|
109
|
+
};
|
|
110
|
+
globalThis.fetch = Object.assign(recordingFetch, previousFetch);
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
scope,
|
|
114
|
+
record,
|
|
115
|
+
stepWritten: (worker, step) => {
|
|
116
|
+
lastStep.set(worker, step);
|
|
117
|
+
},
|
|
118
|
+
effects: () => [...log],
|
|
119
|
+
clear: () => {
|
|
120
|
+
log.length = 0;
|
|
121
|
+
lastStep.clear();
|
|
122
|
+
},
|
|
123
|
+
[Symbol.asyncDispose]: async (): Promise<void> => {
|
|
124
|
+
// Each restore runs whatever the one before it did: a throw must not strand a seam.
|
|
125
|
+
try {
|
|
126
|
+
globalThis.fetch = previousFetch;
|
|
127
|
+
} finally {
|
|
128
|
+
configureErrorReporting({ reporter: previousReporter });
|
|
129
|
+
if (previousMail === undefined) mail.resetMailDriver();
|
|
130
|
+
else mail.setMailDriver(previousMail);
|
|
131
|
+
setRowObserver(previousRows);
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** How many times each key happened. */
|
|
138
|
+
export function effectCounts(effects: readonly SimEffect[]): ReadonlyMap<string, number> {
|
|
139
|
+
const counts = new Map<string, number>();
|
|
140
|
+
for (const effect of effects) counts.set(effect.key, (counts.get(effect.key) ?? 0) + 1);
|
|
141
|
+
return counts;
|
|
142
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The job proofs' refusals: a killed run that diverged from a clean one, a seed that broke the
|
|
2
|
+
// app's invariant, a seed that would not replay. Apart from ./errors, which owns the codes and the
|
|
3
|
+
// one registration, because that catalogue is at its ceiling. Every fix replays the exact case.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
renderCauseValue,
|
|
7
|
+
renderFixLiteral,
|
|
8
|
+
renderFixShellArg,
|
|
9
|
+
UltimateError,
|
|
10
|
+
} from '@ultimat3/core';
|
|
11
|
+
import './errors';
|
|
12
|
+
|
|
13
|
+
export interface CrashDivergedInput {
|
|
14
|
+
readonly job: string;
|
|
15
|
+
/** The boundary the run was killed at: `charge`, `send (started)`, `ack`. */
|
|
16
|
+
readonly boundary: string;
|
|
17
|
+
readonly effect: string;
|
|
18
|
+
readonly clean: number;
|
|
19
|
+
readonly killed: number;
|
|
20
|
+
/** The last step written before the effect, in the clean run. */
|
|
21
|
+
readonly after?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class JobCrashDivergedError extends UltimateError {
|
|
25
|
+
constructor(input: CrashDivergedInput) {
|
|
26
|
+
const where =
|
|
27
|
+
input.after === undefined ? 'before the first step' : `after step "${input.after}"`;
|
|
28
|
+
super({
|
|
29
|
+
code: 'X_JOB_CRASH_DIVERGED',
|
|
30
|
+
cause: `job ${renderCauseValue(input.job)} killed at ${renderCauseValue(input.boundary)} made ${renderCauseValue(input.effect)} ${input.killed}× against ${input.clean}× in a clean run: it runs outside every step, ${where}, so the retry makes it again`,
|
|
31
|
+
fix: `step.run(${renderFixLiteral(`${input.boundary}-effect`, '<name>')}, async () => …) # in job(${renderFixLiteral(input.job, '<job>')}): wrap the ${renderFixLiteral(input.effect, '<effect>')} made ${where}; key an external call on \`\${jobId}:<step>\`; then: x test job --filter <the test file>`,
|
|
32
|
+
meta: { ...input },
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface SimInvariantInput {
|
|
38
|
+
readonly seed: number;
|
|
39
|
+
readonly faults: readonly string[];
|
|
40
|
+
readonly reason: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class JobSimInvariantError extends UltimateError {
|
|
44
|
+
constructor(input: SimInvariantInput) {
|
|
45
|
+
const faults = input.faults.length === 0 ? 'no fault' : input.faults.join(', ');
|
|
46
|
+
super({
|
|
47
|
+
code: 'X_JOB_SIM_INVARIANT',
|
|
48
|
+
cause: `seed ${input.seed} broke the invariant (${renderCauseValue(input.reason)}) under ${renderCauseValue(faults)}`,
|
|
49
|
+
fix: `x test job --seed ${renderFixShellArg(String(input.seed), '<n>')} --filter <the test file> # replays that one seed and its faults`,
|
|
50
|
+
meta: { seed: input.seed, faults: [...input.faults] },
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface SimNondeterministicInput {
|
|
56
|
+
readonly seed: number;
|
|
57
|
+
/** The index of the first event the two traces disagree on. */
|
|
58
|
+
readonly at: number;
|
|
59
|
+
readonly first: string;
|
|
60
|
+
readonly second: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class JobSimNondeterministicError extends UltimateError {
|
|
64
|
+
constructor(input: SimNondeterministicInput) {
|
|
65
|
+
super({
|
|
66
|
+
code: 'X_JOB_SIM_NONDETERMINISTIC',
|
|
67
|
+
cause: `seed ${input.seed} ran twice and the traces differ at event ${input.at}: ${renderCauseValue(input.first)} then ${renderCauseValue(input.second)} — a body reads the wall clock, real I/O or state an earlier run left`,
|
|
68
|
+
fix: `x test job --seed ${renderFixShellArg(String(input.seed), '<n>')} --filter <the test file> # read time through Date (frozen) and ids through the job's input`,
|
|
69
|
+
meta: { ...input },
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// The seeded picker: every driver call a simulated worker makes parks at a yield point, and one
|
|
2
|
+
// seeded RNG decides which parked call goes next and which fault, if any, lands on it. One thread,
|
|
3
|
+
// so the interleaving is the seed's alone; every pick and every fault is a line of the trace.
|
|
4
|
+
|
|
5
|
+
import { assert } from '@ultimat3/core';
|
|
6
|
+
import { advanceClock, seededRandom } from './determinism';
|
|
7
|
+
import type { SimDecision, SimOp } from './job-sim-driver';
|
|
8
|
+
import { SEND_AND_ANSWER } from './job-sim-driver';
|
|
9
|
+
import type { SimWorld } from './job-sim-world';
|
|
10
|
+
import { SIM_VISIBILITY_MS } from './job-sim-world';
|
|
11
|
+
|
|
12
|
+
export const JOB_FAULT_KINDS = [
|
|
13
|
+
'kill',
|
|
14
|
+
'answer-lost',
|
|
15
|
+
'lease-lapse',
|
|
16
|
+
'duplicate-claim',
|
|
17
|
+
'clock-jump',
|
|
18
|
+
] as const;
|
|
19
|
+
|
|
20
|
+
export type JobFaultKind = (typeof JOB_FAULT_KINDS)[number];
|
|
21
|
+
|
|
22
|
+
/** Every fault kind: what `simulateJobs` plants unless told fewer. */
|
|
23
|
+
export const ALL_FAULTS: readonly JobFaultKind[] = JOB_FAULT_KINDS;
|
|
24
|
+
|
|
25
|
+
/** Event-loop turns with nothing parked before a run is a body waiting on something unowned. */
|
|
26
|
+
const MAX_IDLE_TURNS = 10_000;
|
|
27
|
+
|
|
28
|
+
/** Calls whose answer can be lost after they landed: the ones that change something. */
|
|
29
|
+
const WRITES: ReadonlySet<SimOp['call']> = new Set([
|
|
30
|
+
'claim',
|
|
31
|
+
'ack',
|
|
32
|
+
'nack',
|
|
33
|
+
'heartbeat',
|
|
34
|
+
'enqueue',
|
|
35
|
+
'enqueueMany',
|
|
36
|
+
'put',
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
interface Parked {
|
|
40
|
+
readonly op: SimOp;
|
|
41
|
+
readonly resolve: (decision: SimDecision) => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface SimSchedulerOptions {
|
|
45
|
+
readonly seed: number;
|
|
46
|
+
readonly faults: readonly JobFaultKind[];
|
|
47
|
+
/** The chance a pick carries a fault, in [0, 1]. */
|
|
48
|
+
readonly faultRate: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface SimScheduler {
|
|
52
|
+
/** The world's `decide`: park until picked, then answer the fault the pick drew. */
|
|
53
|
+
decide(op: SimOp): Promise<SimDecision>;
|
|
54
|
+
/** Release parked calls one at a time until every task has settled. */
|
|
55
|
+
run(world: SimWorld, tasks: readonly Promise<unknown>[]): Promise<void>;
|
|
56
|
+
readonly trace: string[];
|
|
57
|
+
/** The faults planted, in order — what a broken invariant is reported with. */
|
|
58
|
+
readonly planted: string[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const turn = (): Promise<void> => new Promise((resolve) => setImmediate(resolve));
|
|
62
|
+
|
|
63
|
+
export function simScheduler(options: SimSchedulerOptions): SimScheduler {
|
|
64
|
+
// Its own stream, apart from the world's `Math.random`: a body's draws never shift a pick.
|
|
65
|
+
const rng = seededRandom((options.seed ^ 0x9e3779b9) >>> 0);
|
|
66
|
+
const pickOf = (length: number): number => Math.min(length - 1, Math.floor(rng() * length));
|
|
67
|
+
const parked: Parked[] = [];
|
|
68
|
+
const trace: string[] = [];
|
|
69
|
+
const planted: string[] = [];
|
|
70
|
+
|
|
71
|
+
const labelOf = (world: SimWorld, op: SimOp): string =>
|
|
72
|
+
[
|
|
73
|
+
op.worker,
|
|
74
|
+
op.call,
|
|
75
|
+
op.jobId === undefined ? undefined : world.name(op.jobId),
|
|
76
|
+
op.runId === undefined ? undefined : world.name(op.runId),
|
|
77
|
+
op.step,
|
|
78
|
+
op.status,
|
|
79
|
+
op.names?.join(','),
|
|
80
|
+
]
|
|
81
|
+
.filter((part) => part !== undefined)
|
|
82
|
+
.join(' ');
|
|
83
|
+
|
|
84
|
+
const plant = (line: string): void => {
|
|
85
|
+
planted.push(line);
|
|
86
|
+
trace.push(`fault ${line}`);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** Draw the pick's fault, landing the clock ones here; answer what the call itself suffers. */
|
|
90
|
+
const fault = async (world: SimWorld, op: SimOp, label: string): Promise<SimDecision> => {
|
|
91
|
+
if (options.faults.length === 0 || rng() >= options.faultRate) return SEND_AND_ANSWER;
|
|
92
|
+
const kind = options.faults[pickOf(options.faults.length)] ?? 'kill';
|
|
93
|
+
if (kind === 'kill') {
|
|
94
|
+
const landed = rng() < 0.5;
|
|
95
|
+
plant(`kill ${label} ${landed ? 'after it landed' : 'before it was sent'}`);
|
|
96
|
+
return landed ? { before: 'send', after: 'kill' } : { before: 'kill', after: 'answer' };
|
|
97
|
+
}
|
|
98
|
+
if (kind === 'answer-lost') {
|
|
99
|
+
if (!WRITES.has(op.call)) return SEND_AND_ANSWER;
|
|
100
|
+
plant(`answer-lost ${label}`);
|
|
101
|
+
return { before: 'send', after: 'lose' };
|
|
102
|
+
}
|
|
103
|
+
if (kind === 'lease-lapse') {
|
|
104
|
+
plant(`lease-lapse at ${label}`);
|
|
105
|
+
advanceClock(SIM_VISIBILITY_MS + 1);
|
|
106
|
+
return SEND_AND_ANSWER;
|
|
107
|
+
}
|
|
108
|
+
if (kind === 'clock-jump') {
|
|
109
|
+
const ms = 1 + Math.floor(rng() * (SIM_VISIBILITY_MS / 2));
|
|
110
|
+
plant(`clock-jump ${ms}ms at ${label}`);
|
|
111
|
+
advanceClock(ms);
|
|
112
|
+
return SEND_AND_ANSWER;
|
|
113
|
+
}
|
|
114
|
+
const running = (await world.rows()).filter((row) => row.state === 'running');
|
|
115
|
+
const victim = running[pickOf(running.length)];
|
|
116
|
+
if (victim === undefined) return SEND_AND_ANSWER;
|
|
117
|
+
plant(`duplicate-claim ${world.name(victim.id)} at ${label}`);
|
|
118
|
+
// The row's lease, cut to now: the next claim by anyone re-delivers a job still running here.
|
|
119
|
+
await world.sim.base.heartbeat(victim.id, { visibilityTimeoutMs: 0 });
|
|
120
|
+
return SEND_AND_ANSWER;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
trace,
|
|
125
|
+
planted,
|
|
126
|
+
decide: (op) => new Promise<SimDecision>((resolve) => parked.push({ op, resolve })),
|
|
127
|
+
async run(world, tasks) {
|
|
128
|
+
let settled = false;
|
|
129
|
+
const all = Promise.allSettled(tasks).then(() => {
|
|
130
|
+
settled = true;
|
|
131
|
+
});
|
|
132
|
+
for (let idle = 0; ; ) {
|
|
133
|
+
await turn();
|
|
134
|
+
const index = parked.length === 0 ? -1 : pickOf(parked.length);
|
|
135
|
+
const next = index === -1 ? undefined : parked.splice(index, 1)[0];
|
|
136
|
+
if (next === undefined) {
|
|
137
|
+
if (settled) break;
|
|
138
|
+
idle += 1;
|
|
139
|
+
assert(
|
|
140
|
+
idle < MAX_IDLE_TURNS,
|
|
141
|
+
`simulateJobs waited ${MAX_IDLE_TURNS} event-loop turns with no driver call parked: a job body awaits a timer, a socket or a promise the simulation does not own`,
|
|
142
|
+
'x test job --filter <the test file> # replace the wait with step.sleep, and the I/O with a fixture the simulation installs',
|
|
143
|
+
);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
idle = 0;
|
|
147
|
+
const label = labelOf(world, next.op);
|
|
148
|
+
trace.push(`pick ${label}`);
|
|
149
|
+
next.resolve(await fault(world, next.op, label));
|
|
150
|
+
}
|
|
151
|
+
await all;
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// The world one simulated run happens in: one memory queue behind per-worker views, the effect
|
|
2
|
+
// log, a fresh event bus, renewals on the frozen clock, the clock and `Math.random` put back to the
|
|
3
|
+
// same instant and seed at the start — so one run is a function of its decisions and nothing else.
|
|
4
|
+
// Ids are minted by the queue and differ run to run; the effect log names them by ordinal instead.
|
|
5
|
+
|
|
6
|
+
import type { Ctx } from '@ultimat3/core';
|
|
7
|
+
import { ctxOf } from '@ultimat3/core';
|
|
8
|
+
import type {
|
|
9
|
+
EnqueueResult,
|
|
10
|
+
EventBus,
|
|
11
|
+
JobDriver,
|
|
12
|
+
JobExecution,
|
|
13
|
+
JobHandle,
|
|
14
|
+
JobRecord,
|
|
15
|
+
} from '@ultimat3/jobs';
|
|
16
|
+
import {
|
|
17
|
+
captureDeterminism,
|
|
18
|
+
installDeterminism,
|
|
19
|
+
restoreCapturedDeterminism,
|
|
20
|
+
seededRandom,
|
|
21
|
+
} from './determinism';
|
|
22
|
+
import { frozenScheduler } from './frozen-scheduler';
|
|
23
|
+
import type { SimDecision, SimDriver, SimOp } from './job-sim-driver';
|
|
24
|
+
import { isSimFault, simDriver } from './job-sim-driver';
|
|
25
|
+
import type { EffectLog, SimEffect } from './job-sim-effects';
|
|
26
|
+
import { openEffectLog } from './job-sim-effects';
|
|
27
|
+
|
|
28
|
+
/** A lease short enough that a lapse is one clock jump, long enough that no renewal misses it. */
|
|
29
|
+
export const SIM_VISIBILITY_MS = 30_000;
|
|
30
|
+
/** Renewals every millisecond of test time: any move renews every running job once. */
|
|
31
|
+
const RENEW_MS = 1;
|
|
32
|
+
const CLAIM_LIMIT = 64;
|
|
33
|
+
const LIVE: ReadonlySet<string> = new Set(['ready', 'delayed', 'running', 'suspended']);
|
|
34
|
+
|
|
35
|
+
export interface SimWorldOptions {
|
|
36
|
+
/** Seeds `Math.random` for the run: a body's jitter and draws are the seed's. */
|
|
37
|
+
readonly seed: number;
|
|
38
|
+
/** What every worker runs its bodies under. Default: an anonymous worker. */
|
|
39
|
+
readonly context?: () => Ctx;
|
|
40
|
+
decide(op: SimOp): SimDecision | Promise<SimDecision>;
|
|
41
|
+
/** Told every landed call, after the world has read it. */
|
|
42
|
+
landed?(op: SimOp, answer: unknown): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface SimWorld extends AsyncDisposable {
|
|
46
|
+
readonly sim: SimDriver;
|
|
47
|
+
readonly log: EffectLog;
|
|
48
|
+
/** Filed by the harness, not a worker: no yield, no effect. */
|
|
49
|
+
enqueue<I>(handle: JobHandle<I>, input: I): Promise<EnqueueResult>;
|
|
50
|
+
/** One claim pass by `worker`, a real `jobWorker().tick()`. A planted fault ends it quietly. */
|
|
51
|
+
tick(worker: string): Promise<readonly JobExecution[]>;
|
|
52
|
+
rows(): Promise<readonly JobRecord[]>;
|
|
53
|
+
/** The instant the next live row is claimable — a lease lapsing or a `runAt` — if any. */
|
|
54
|
+
nextDue(): Promise<number | undefined>;
|
|
55
|
+
/** `job#1`, `run#1`: the ordinal a queue id is reported under. */
|
|
56
|
+
name(id: string): string;
|
|
57
|
+
/** The effect log, ids replaced by their ordinals. */
|
|
58
|
+
effects(): readonly SimEffect[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function openSimWorld(options: SimWorldOptions): Promise<SimWorld> {
|
|
62
|
+
const jobs = await import('@ultimat3/jobs');
|
|
63
|
+
const clock = captureDeterminism();
|
|
64
|
+
if (!clock.installed) installDeterminism();
|
|
65
|
+
Math.random = seededRandom(options.seed);
|
|
66
|
+
const ordinals = new Map<string, string>();
|
|
67
|
+
const counters = new Map<string, number>();
|
|
68
|
+
const known = (id: string | undefined, kind: 'job' | 'run'): void => {
|
|
69
|
+
if (id === undefined || ordinals.has(id)) return;
|
|
70
|
+
const n = (counters.get(kind) ?? 0) + 1;
|
|
71
|
+
counters.set(kind, n);
|
|
72
|
+
ordinals.set(id, `${kind}#${n}`);
|
|
73
|
+
};
|
|
74
|
+
const remember = (answer: unknown): void => {
|
|
75
|
+
for (const one of Array.isArray(answer) ? answer : [answer]) {
|
|
76
|
+
if (typeof one !== 'object' || one === null) continue;
|
|
77
|
+
const row = one as { readonly id?: unknown; readonly runId?: unknown };
|
|
78
|
+
if (typeof row.id === 'string') known(row.id, 'job');
|
|
79
|
+
if (typeof row.runId === 'string') known(row.runId, 'run');
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
let log: EffectLog | undefined;
|
|
84
|
+
const base = jobs.memoryJobDriver();
|
|
85
|
+
const sim = simDriver(base, {
|
|
86
|
+
decide: (op) => options.decide(op),
|
|
87
|
+
landed: (op, answer) => {
|
|
88
|
+
remember(answer);
|
|
89
|
+
if (op.call === 'put' && op.step !== undefined) log?.stepWritten(op.worker, op.step);
|
|
90
|
+
if ((op.call === 'enqueue' || op.call === 'enqueueMany') && log !== undefined) {
|
|
91
|
+
const results = (Array.isArray(answer) ? answer : [answer]) as readonly EnqueueResult[];
|
|
92
|
+
results.forEach((result, i) => {
|
|
93
|
+
if (!result.deduped) log?.record(`enqueue:${op.names?.[i] ?? '?'}`);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
options.landed?.(op, answer);
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
log = await openEffectLog({ isDead: sim.isDead });
|
|
100
|
+
const effectLog = log;
|
|
101
|
+
|
|
102
|
+
// A body's own enqueue goes through the view of the worker running it: a dead one's is refused.
|
|
103
|
+
const ambient = (): JobDriver => sim.viewFor(effectLog.scope.get() ?? 'harness');
|
|
104
|
+
const ambientDriver: JobDriver = new Proxy(base, {
|
|
105
|
+
get: (_target, key) => Reflect.get(ambient(), key),
|
|
106
|
+
});
|
|
107
|
+
const previousDriver = jobs.jobDriver();
|
|
108
|
+
jobs.setJobDriver(ambientDriver);
|
|
109
|
+
const previousBus: EventBus = jobs.eventBus();
|
|
110
|
+
jobs.setEventBus(jobs.memoryEventBus());
|
|
111
|
+
const renewals = frozenScheduler();
|
|
112
|
+
const anonymous = ctxOf({ role: 'worker' });
|
|
113
|
+
const context = options.context ?? (() => anonymous);
|
|
114
|
+
|
|
115
|
+
const queues = (): readonly string[] => [
|
|
116
|
+
...new Set([jobs.DEFAULT_QUEUE, ...jobs.registeredJobs().map((handle) => handle.queue)]),
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
const rows = async (): Promise<readonly JobRecord[]> => {
|
|
120
|
+
const all: JobRecord[] = [];
|
|
121
|
+
for (let after: string | undefined; ; ) {
|
|
122
|
+
const page =
|
|
123
|
+
(await base.introspect?.list({
|
|
124
|
+
limit: jobs.MAX_JOB_PAGE,
|
|
125
|
+
...(after === undefined ? {} : { after }),
|
|
126
|
+
})) ?? [];
|
|
127
|
+
all.push(...page);
|
|
128
|
+
const last = page[page.length - 1];
|
|
129
|
+
if (last === undefined || page.length < jobs.MAX_JOB_PAGE) return all;
|
|
130
|
+
after = jobs.jobCursor(last);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const normalise = (key: string): string => {
|
|
135
|
+
let out = key;
|
|
136
|
+
for (const [id, ordinal] of ordinals) out = out.split(id).join(ordinal);
|
|
137
|
+
return out;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
sim,
|
|
142
|
+
log: effectLog,
|
|
143
|
+
enqueue: async (handle, input) => {
|
|
144
|
+
const result = await base.enqueue({
|
|
145
|
+
name: handle.name,
|
|
146
|
+
queue: handle.queue,
|
|
147
|
+
input,
|
|
148
|
+
idempotencyKey: handle.idempotencyKeyFor(input),
|
|
149
|
+
maxAttempts: jobs.enqueuedAttempts(handle.retry, handle.compensates),
|
|
150
|
+
});
|
|
151
|
+
remember(result);
|
|
152
|
+
return result;
|
|
153
|
+
},
|
|
154
|
+
tick: (worker) =>
|
|
155
|
+
effectLog.scope.run(worker, async () => {
|
|
156
|
+
const one = jobs.jobWorker({
|
|
157
|
+
driver: sim.viewFor(worker),
|
|
158
|
+
workerId: worker,
|
|
159
|
+
queues: queues(),
|
|
160
|
+
concurrency: CLAIM_LIMIT,
|
|
161
|
+
visibilityTimeoutMs: SIM_VISIBILITY_MS,
|
|
162
|
+
heartbeatIntervalMs: RENEW_MS,
|
|
163
|
+
schedule: renewals.schedule,
|
|
164
|
+
pollIntervalMs: 0,
|
|
165
|
+
drainOnShutdown: false,
|
|
166
|
+
context,
|
|
167
|
+
});
|
|
168
|
+
try {
|
|
169
|
+
return await one.tick();
|
|
170
|
+
} catch (error) {
|
|
171
|
+
// A claim refused by a planted fault is the pass's end; anything else is a real failure.
|
|
172
|
+
if (isSimFault(error)) return [];
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
}),
|
|
176
|
+
rows,
|
|
177
|
+
nextDue: async () => {
|
|
178
|
+
const due = (await rows())
|
|
179
|
+
.filter((row) => LIVE.has(row.state))
|
|
180
|
+
.map((row) => (row.state === 'running' ? (row.visibleAt ?? row.runAt) : row.runAt));
|
|
181
|
+
return due.length === 0 ? undefined : Math.min(...due);
|
|
182
|
+
},
|
|
183
|
+
name: (id) => ordinals.get(id) ?? id,
|
|
184
|
+
effects: () => effectLog.effects().map((effect) => ({ ...effect, key: normalise(effect.key) })),
|
|
185
|
+
[Symbol.asyncDispose]: async (): Promise<void> => {
|
|
186
|
+
try {
|
|
187
|
+
await effectLog[Symbol.asyncDispose]();
|
|
188
|
+
} finally {
|
|
189
|
+
renewals[Symbol.dispose]();
|
|
190
|
+
jobs.setEventBus(previousBus);
|
|
191
|
+
if (previousDriver === undefined) jobs.resetJobDriver();
|
|
192
|
+
else jobs.setJobDriver(previousDriver);
|
|
193
|
+
await base.close();
|
|
194
|
+
restoreCapturedDeterminism(clock);
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|