@ultimat3/testing 30.5.0 → 30.6.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 +2 -2
- package/package.json +17 -17
- package/src/exercise-job-run.ts +68 -0
- package/src/exercise-subjects.ts +6 -4
- package/src/exercise.ts +9 -23
- package/src/fixture-jobs.ts +17 -5
- package/src/job-crash-proof.ts +3 -0
- package/src/job-sim-world.ts +16 -5
package/CLAUDE.md
CHANGED
|
@@ -46,10 +46,10 @@ import is `registry-isolation.ts`, own entry point, off the barrel. Full rule: `
|
|
|
46
46
|
| Injection | `SqlRunner` and `connect` are parameters, so unit tests need no server |
|
|
47
47
|
| Fixtures | the preload registers the framework bag — an app registers only what it alone knows (`seed`, `actorFor`) |
|
|
48
48
|
| Every `e2eTest` is audited | `e2e-a11y.ts` (rules), `e2e-a11y-run.ts` (per-test run, `allow`, dedupe), `ax-node.ts` (the one AX reader); the full tree is read only in `cdp-e2e-page.ts` (`FULL_AX_TREE`, a convention). No switch; `allow` needs a non-blank `because`. And every raw tab: `e2e-a11y-tabs.ts`, checked after each `goto`/`reload` and at the test's end from the preload's `afterEach`; exceptions are `allowA11y([{ role, selector, because }])` |
|
|
49
|
-
| The exercise pass runs what is registered | `exercise.ts` (`runExercisePass`): every action, query and job once, its own tenant pair off one `seedGraph`, an actor `exercise-actor.ts` picks, 39's `runExercise` as the verdict. Rows COMMITTED. Overrides only via `defineExercise` (blank `off` refused) |
|
|
49
|
+
| The exercise pass runs what is registered | `exercise.ts` (`runExercisePass`): every action, query and job once, its own tenant pair off one `seedGraph`, an actor `exercise-actor.ts` picks, 39's `runExercise` as the verdict. Rows COMMITTED. Overrides only via `defineExercise` (blank `off` refused). A job runs ALONE (`exercise-job-run.ts`): a fresh queue, one `tick()`, what it queued left on the caller's queue unrun — never a `drain()` |
|
|
50
50
|
| An override `input` is awaited, as the call's actor | `exercise-override-input.ts`: run inside the call's own request, so `useContext().actor` is the seated actor; the one way to a signed input. `async` ones are left out of `x drill`'s load |
|
|
51
51
|
| A passkey test signs for real | `softAuthenticator` is `@ultimat3/auth/soft-authenticator`'s, never re-exported here (one value, one path: `flight-copies`) and never a second one (auth's own suites need it, and auth cannot import testing) |
|
|
52
|
-
| A reached job is crash-proven | `exercise-crash.ts`: `crashProof` with the control's input and actor, after the tenant proof; only `diverged` is red (`X_JOB_CRASH_DIVERGED`), its place `crashEffectPlace` (`inside` a step via jobs' `currentStep()`, or `after` one). Committed: job bodies escape transactions |
|
|
52
|
+
| A reached job is crash-proven | `exercise-crash.ts`: `crashProof` with the control's input and actor, after the tenant proof; only `diverged` is red (`X_JOB_CRASH_DIVERGED`), its place `crashEffectPlace` (`inside` a step via jobs' `currentStep()`, or `after` one). Its world is `alone`: only the job's own row is claimed, a child is an `enqueue:` effect, never run. Committed: job bodies escape transactions |
|
|
53
53
|
| The tenant proof is taint only | `tenant-proof.ts`: B, A, B; B is A's role minus `tenancy:cross`. Leak = seeded canary or unhanded id (`tenant-canary.ts`); write = moved digest. Only `crossed` is red |
|
|
54
54
|
| Every statement the pass sent is planned | `plan-sweep.ts` (`sweepPlans`): first relation an app table, `PREPARE` in a READ-ONLY transaction with seq/bitmap scans and sorts off, judged by `plan-oracle.ts`; red = `X_QUERY_UNINDEXED` / `X_QUERY_SORT_UNSERVED` with the `indexes:` entry (`plan-sweep-fix.ts`). `scan:` or `expectedSeqScan` is exempt, listed; credited: a partial unique whose predicate the statement states (one row), a declared equality-then-range index under a limit; no generic plan is `X_QUERY_PLAN_UNAVAILABLE`, never a pass |
|
|
55
55
|
| A shape that grows with the rows is red | `scale-sweep-run.ts`: two more tenants at `SCALE_SIZES` (2, 8 rows a table), every reached primitive once in each; `judgeScale` (pure) reds `X_QUERY_SCALES_WITH_ROWS`. Under `expectedQueryLoop` it is listed with its reason |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/testing",
|
|
3
|
-
"version": "30.
|
|
3
|
+
"version": "30.6.0",
|
|
4
4
|
"description": "Test harness: cloned template DBs per worker, frozen clock, sealed network, 6 test types",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ultimate",
|
|
@@ -46,21 +46,21 @@
|
|
|
46
46
|
"test": "bun test"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@ultimat3/action": "30.
|
|
50
|
-
"@ultimat3/cache": "30.
|
|
51
|
-
"@ultimat3/core": "30.
|
|
52
|
-
"@ultimat3/db": "30.
|
|
53
|
-
"@ultimat3/entity": "30.
|
|
54
|
-
"@ultimat3/http": "30.
|
|
55
|
-
"@ultimat3/i18n": "30.
|
|
56
|
-
"@ultimat3/jobs": "30.
|
|
57
|
-
"@ultimat3/mail": "30.
|
|
58
|
-
"@ultimat3/policy": "30.
|
|
59
|
-
"@ultimat3/pwa": "30.
|
|
60
|
-
"@ultimat3/query": "30.
|
|
61
|
-
"@ultimat3/realtime": "30.
|
|
62
|
-
"@ultimat3/render": "30.
|
|
63
|
-
"@ultimat3/schema": "30.
|
|
64
|
-
"@ultimat3/time": "30.
|
|
49
|
+
"@ultimat3/action": "30.6.0",
|
|
50
|
+
"@ultimat3/cache": "30.6.0",
|
|
51
|
+
"@ultimat3/core": "30.6.0",
|
|
52
|
+
"@ultimat3/db": "30.6.0",
|
|
53
|
+
"@ultimat3/entity": "30.6.0",
|
|
54
|
+
"@ultimat3/http": "30.6.0",
|
|
55
|
+
"@ultimat3/i18n": "30.6.0",
|
|
56
|
+
"@ultimat3/jobs": "30.6.0",
|
|
57
|
+
"@ultimat3/mail": "30.6.0",
|
|
58
|
+
"@ultimat3/policy": "30.6.0",
|
|
59
|
+
"@ultimat3/pwa": "30.6.0",
|
|
60
|
+
"@ultimat3/query": "30.6.0",
|
|
61
|
+
"@ultimat3/realtime": "30.6.0",
|
|
62
|
+
"@ultimat3/render": "30.6.0",
|
|
63
|
+
"@ultimat3/schema": "30.6.0",
|
|
64
|
+
"@ultimat3/time": "30.6.0"
|
|
65
65
|
}
|
|
66
66
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// A job primitive's one execution, as the exercise pass runs it: ALONE, on a queue that holds nothing
|
|
2
|
+
// else, for one worker pass. What it enqueues is handed back to the caller's queue unrun, exactly as
|
|
3
|
+
// an action's enqueues are — counted there as `enqueue` effects, searched by the tenant proof, and
|
|
4
|
+
// never executed inside the subject's room, so a child's statements are never the subject's.
|
|
5
|
+
|
|
6
|
+
import type { Actor } from '@ultimat3/core';
|
|
7
|
+
import { assert } from '@ultimat3/core';
|
|
8
|
+
import type { AnyJobHandle, JobExecution, JobRecord } from '@ultimat3/jobs';
|
|
9
|
+
import { jobCursor, jobDriver, MAX_JOB_PAGE, registeredJobs } from '@ultimat3/jobs';
|
|
10
|
+
import type { RunJobs } from './fixture-jobs';
|
|
11
|
+
import { testJobs } from './fixture-jobs';
|
|
12
|
+
|
|
13
|
+
const QUEUED: ReadonlySet<string> = new Set(['ready', 'delayed']);
|
|
14
|
+
|
|
15
|
+
/** Every row the AMBIENT driver holds, page by page: what a fixture's queue has, finished or not. */
|
|
16
|
+
export async function ambientJobRows(): Promise<readonly JobRecord[]> {
|
|
17
|
+
const introspect = jobDriver()?.introspect;
|
|
18
|
+
if (introspect === undefined) return [];
|
|
19
|
+
const records: JobRecord[] = [];
|
|
20
|
+
for (let after: string | undefined; ; ) {
|
|
21
|
+
const page = await introspect.list({
|
|
22
|
+
limit: MAX_JOB_PAGE,
|
|
23
|
+
...(after === undefined ? {} : { after }),
|
|
24
|
+
});
|
|
25
|
+
records.push(...page);
|
|
26
|
+
const last = page[page.length - 1];
|
|
27
|
+
if (last === undefined || page.length < MAX_JOB_PAGE) break;
|
|
28
|
+
after = jobCursor(last);
|
|
29
|
+
}
|
|
30
|
+
return records;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* `handle` run once with `input` in `tenant`, as `actor`'s worker: enqueued on a fresh queue and
|
|
35
|
+
* claimed by ONE pass, so the only run is its own — never a job `into` already held (an earlier
|
|
36
|
+
* call's, an earlier primitive's) and never one it queued. Those it queued land on `into`, unrun.
|
|
37
|
+
*/
|
|
38
|
+
export async function runJobAlone(
|
|
39
|
+
handle: AnyJobHandle,
|
|
40
|
+
input: unknown,
|
|
41
|
+
at: { readonly actor: Actor | null; readonly tenant: string },
|
|
42
|
+
into: RunJobs,
|
|
43
|
+
): Promise<JobExecution | undefined> {
|
|
44
|
+
let own: JobExecution | undefined;
|
|
45
|
+
let queued: readonly JobRecord[];
|
|
46
|
+
{
|
|
47
|
+
await using alone = await testJobs();
|
|
48
|
+
const row = await alone.enqueue(handle, input, { tenantId: at.tenant });
|
|
49
|
+
const trace = await alone.tick(at.actor === null ? {} : { actor: at.actor });
|
|
50
|
+
own = trace.executions.find((run) => run.jobId === row.id);
|
|
51
|
+
queued = (await ambientJobRows()).filter(
|
|
52
|
+
(record) => record.id !== row.id && QUEUED.has(record.state),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
const handles = new Map(registeredJobs().map((one) => [one.name, one]));
|
|
56
|
+
for (const record of queued) {
|
|
57
|
+
const child = handles.get(record.name);
|
|
58
|
+
assert(
|
|
59
|
+
child !== undefined,
|
|
60
|
+
`job "${handle.name}" queued job "${record.name}" but nothing registered it`,
|
|
61
|
+
`import the module that declares job("${record.name}") from the app's api module — the registry is populated by the import, not by the queue`,
|
|
62
|
+
);
|
|
63
|
+
await into.enqueue(child, record.input, {
|
|
64
|
+
...(record.tenantId === undefined ? {} : { tenantId: record.tenantId }),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return own;
|
|
68
|
+
}
|
package/src/exercise-subjects.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The primitives an exercise pass drives, each as one call: every registered action, query and job,
|
|
2
2
|
// loaded on demand. A job is enqueued in the tenant it is called for, its input SEATED there first
|
|
3
|
-
// (`exercise-job-tenant.ts`): a drawn org is a random one, and the enqueue refuses it.
|
|
3
|
+
// (`exercise-job-tenant.ts`): a drawn org is a random one, and the enqueue refuses it. It runs
|
|
4
|
+
// alone (`exercise-job-run.ts`): what it queues is left queued, as an action's is.
|
|
4
5
|
|
|
5
6
|
import type { Actor } from '@ultimat3/core';
|
|
6
7
|
import { renderCauseValue, renderFixShellArg, UltimateError } from '@ultimat3/core';
|
|
@@ -8,6 +9,7 @@ import type { AnyJobHandle, JobExecution } from '@ultimat3/jobs';
|
|
|
8
9
|
import { registeredJobs } from '@ultimat3/jobs';
|
|
9
10
|
import type { Policy } from '@ultimat3/policy';
|
|
10
11
|
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
12
|
+
import { runJobAlone } from './exercise-job-run';
|
|
11
13
|
import type { JobTenantBinding } from './exercise-job-tenant';
|
|
12
14
|
import { bindJobTenant } from './exercise-job-tenant';
|
|
13
15
|
import { withExerciseRequest } from './exercise-request';
|
|
@@ -84,9 +86,9 @@ const jobSubject = (handle: AnyJobHandle): Subject => ({
|
|
|
84
86
|
handle,
|
|
85
87
|
seat: (input, tenant) => bindJobTenant(handle, input, tenant),
|
|
86
88
|
call: async (input, actor, tenant, jobs) => {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const own =
|
|
89
|
+
// Its own execution and nothing else: a drain of `jobs` ran what it queued (one child per row,
|
|
90
|
+
// which the scale sweep read as the job's loop) and whatever an earlier call had left there.
|
|
91
|
+
const own = await runJobAlone(handle, input, { actor, tenant }, jobs);
|
|
90
92
|
// The worker's failure is on the row, never a throw: the attempt's error and its code.
|
|
91
93
|
// Suspended is a body that ran and parked (`step.sleep`, `waitForEvent`): reached.
|
|
92
94
|
return own !== undefined && ANSWERED.has(own.outcome)
|
package/src/exercise.ts
CHANGED
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
|
|
9
9
|
import type { Actor, ExerciseAttempt, ExerciseVerdict } from '@ultimat3/core';
|
|
10
10
|
import { isUltimateError, renderThrowable, runExercise } from '@ultimat3/core';
|
|
11
|
-
import type { JobExecution, JobRecord } from '@ultimat3/jobs';
|
|
12
11
|
import { registeredJobs } from '@ultimat3/jobs';
|
|
13
12
|
import type { ExerciseOverride, ExerciseOverrides } from './define-exercise';
|
|
14
13
|
import { assertOverridesKnown, unknownDeclaration } from './define-exercise';
|
|
@@ -25,6 +24,7 @@ import {
|
|
|
25
24
|
namesActorEntity,
|
|
26
25
|
seededIds,
|
|
27
26
|
} from './exercise-input';
|
|
27
|
+
import { ambientJobRows } from './exercise-job-run';
|
|
28
28
|
import type { OverrideInput } from './exercise-override-input';
|
|
29
29
|
import { overrideInput } from './exercise-override-input';
|
|
30
30
|
import type {
|
|
@@ -76,13 +76,15 @@ const codeOf = (error: unknown): string | undefined => {
|
|
|
76
76
|
const reasonOf = (error: unknown): string =>
|
|
77
77
|
isUltimateError(error) ? error.cause : typeof error === 'string' ? error : renderThrowable(error);
|
|
78
78
|
|
|
79
|
-
/**
|
|
80
|
-
|
|
79
|
+
/**
|
|
80
|
+
* Jobs the primitive queued, its own job excepted. Never run: a job's children are left queued as
|
|
81
|
+
* an action's are (`exercise-job-run.ts`), so their statements are theirs, never this record's.
|
|
82
|
+
*/
|
|
83
|
+
async function enqueued(subject: Subject, jobs: RunJobs) {
|
|
81
84
|
const effects: ExerciseEffect[] = [];
|
|
82
85
|
for (const handle of registeredJobs()) {
|
|
83
86
|
if (subject.kind === 'job' && handle.name === subject.name) continue;
|
|
84
|
-
const
|
|
85
|
-
const count = ran + (await jobs.depth(handle));
|
|
87
|
+
const count = await jobs.depth(handle);
|
|
86
88
|
if (count > 0) effects.push({ kind: 'enqueue', job: handle.name, count });
|
|
87
89
|
}
|
|
88
90
|
return effects;
|
|
@@ -93,21 +95,7 @@ async function enqueued(subject: Subject, jobs: RunJobs, runs: readonly JobExecu
|
|
|
93
95
|
* input is what B was handed. Read off the leg's own queue, page by page.
|
|
94
96
|
*/
|
|
95
97
|
async function queuedPayloads(subject: Subject): Promise<readonly EffectPayload[]> {
|
|
96
|
-
|
|
97
|
-
const introspect = jobs.jobDriver()?.introspect;
|
|
98
|
-
if (introspect === undefined) return [];
|
|
99
|
-
const records: JobRecord[] = [];
|
|
100
|
-
for (let after: string | undefined; ; ) {
|
|
101
|
-
const page = await introspect.list({
|
|
102
|
-
limit: jobs.MAX_JOB_PAGE,
|
|
103
|
-
...(after === undefined ? {} : { after }),
|
|
104
|
-
});
|
|
105
|
-
records.push(...page);
|
|
106
|
-
const last = page[page.length - 1];
|
|
107
|
-
if (last === undefined || page.length < jobs.MAX_JOB_PAGE) break;
|
|
108
|
-
after = jobs.jobCursor(last);
|
|
109
|
-
}
|
|
110
|
-
return records
|
|
98
|
+
return (await ambientJobRows())
|
|
111
99
|
.filter((record) => !(subject.kind === 'job' && record.name === subject.name))
|
|
112
100
|
.map((record, i) => ({
|
|
113
101
|
path: `effect.enqueue[${i}]`,
|
|
@@ -174,7 +162,6 @@ async function control(
|
|
|
174
162
|
let lastError: unknown;
|
|
175
163
|
let lastInput: unknown;
|
|
176
164
|
let output: unknown;
|
|
177
|
-
let executions: readonly JobExecution[] = [];
|
|
178
165
|
room.begin(name);
|
|
179
166
|
const started = performance.now();
|
|
180
167
|
const verdict = await runExercise({
|
|
@@ -209,9 +196,8 @@ async function control(
|
|
|
209
196
|
statusOf: (await import('@ultimat3/http')).statusFor,
|
|
210
197
|
});
|
|
211
198
|
const ms = Math.round(performance.now() - started);
|
|
212
|
-
if (kind === 'job') executions = (await jobs.drain()).executions;
|
|
213
199
|
const observed = room.end();
|
|
214
|
-
const effects = [...observed.effects, ...(await enqueued(subject, jobs
|
|
200
|
+
const effects = [...observed.effects, ...(await enqueued(subject, jobs))];
|
|
215
201
|
const verdictOf = judged(subject, { verdict, answered, lastError, lastInput }, observed);
|
|
216
202
|
// A 5xx's cause, statement and fields: the code alone sent the author to reproduce it by hand.
|
|
217
203
|
const defect =
|
package/src/fixture-jobs.ts
CHANGED
|
@@ -69,6 +69,11 @@ export interface RunJobs extends AsyncDisposable {
|
|
|
69
69
|
enqueue<I>(handle: JobHandle<I>, input: I, options?: JobEnqueueAs): Promise<EnqueueResult>;
|
|
70
70
|
/** Claim and execute everything due at the current instant, until nothing is. */
|
|
71
71
|
drain(options?: JobDrainAs): Promise<JobRunTrace>;
|
|
72
|
+
/**
|
|
73
|
+
* ONE worker pass: what is due now runs, and what those runs enqueue waits for the next pass —
|
|
74
|
+
* "it queued one child per row" asserted without running a child.
|
|
75
|
+
*/
|
|
76
|
+
tick(options?: JobDrainAs): Promise<JobRunTrace>;
|
|
72
77
|
/** Live jobs — ready, delayed, running or suspended — optionally for one job only. */
|
|
73
78
|
depth(handle?: AnyJobHandle): Promise<number>;
|
|
74
79
|
/** Live jobs claimable right now. `clock.advance()` is what turns delayed into due. */
|
|
@@ -194,14 +199,20 @@ export async function testJobs(): Promise<RunJobs> {
|
|
|
194
199
|
/** Every execution this fixture has driven, because the trace is cumulative. */
|
|
195
200
|
const history: JobExecution[] = [];
|
|
196
201
|
|
|
202
|
+
const ctxFor = (options: JobDrainAs): Ctx =>
|
|
203
|
+
options.actor === undefined ? anonymousWorker : ctxOf({ role: 'worker', actor: options.actor });
|
|
204
|
+
const traced = (): JobRunTrace => ({ executions: [...history], steps: tallyOf(history) });
|
|
205
|
+
|
|
206
|
+
const tick = async (options: JobDrainAs = {}): Promise<JobRunTrace> => {
|
|
207
|
+
history.push(...(await round(ctxFor(options))));
|
|
208
|
+
return traced();
|
|
209
|
+
};
|
|
210
|
+
|
|
197
211
|
const drain = async (options: JobDrainAs = {}): Promise<JobRunTrace> => {
|
|
198
|
-
const ctx =
|
|
199
|
-
options.actor === undefined
|
|
200
|
-
? anonymousWorker
|
|
201
|
-
: ctxOf({ role: 'worker', actor: options.actor });
|
|
212
|
+
const ctx = ctxFor(options);
|
|
202
213
|
for (let rounds = 0; rounds < MAX_ROUNDS; rounds += 1) {
|
|
203
214
|
const batch = await round(ctx);
|
|
204
|
-
if (batch.length === 0) return
|
|
215
|
+
if (batch.length === 0) return traced();
|
|
205
216
|
history.push(...batch);
|
|
206
217
|
}
|
|
207
218
|
assert(
|
|
@@ -239,6 +250,7 @@ export async function testJobs(): Promise<RunJobs> {
|
|
|
239
250
|
return Object.assign(enqueueThenDrain, {
|
|
240
251
|
enqueue,
|
|
241
252
|
drain,
|
|
253
|
+
tick,
|
|
242
254
|
depth: async (handle?: AnyJobHandle) => (await live(handle?.name)).length,
|
|
243
255
|
due: async () =>
|
|
244
256
|
(await live()).filter(
|
package/src/job-crash-proof.ts
CHANGED
|
@@ -134,7 +134,10 @@ async function runOnce<I>(
|
|
|
134
134
|
if (op.step.startsWith(COMPENSATE_PREFIX)) compensated.push(op.step);
|
|
135
135
|
else if (op.step !== UNWINDING_STEP) forward.add(op.step);
|
|
136
136
|
};
|
|
137
|
+
// Alone: a child the job queues is its enqueue effect, never run — the clean run that ran it
|
|
138
|
+
// first wrote its rows, and every killed run after found them there (0× against 6×).
|
|
137
139
|
await using world = await openSimWorld({
|
|
140
|
+
alone: true,
|
|
138
141
|
seed: finiteCount('crashProof', 'seed', options.seed ?? 1),
|
|
139
142
|
decide,
|
|
140
143
|
landed,
|
package/src/job-sim-world.ts
CHANGED
|
@@ -31,6 +31,8 @@ export const SIM_VISIBILITY_MS = 30_000;
|
|
|
31
31
|
const RENEW_MS = 1;
|
|
32
32
|
const CLAIM_LIMIT = 64;
|
|
33
33
|
const LIVE: ReadonlySet<string> = new Set(['ready', 'delayed', 'running', 'suspended']);
|
|
34
|
+
/** Where an `alone` world files the harness's row: no body enqueues here, so no worker claims theirs. */
|
|
35
|
+
const HARNESS_QUEUE = 'x-sim-harness';
|
|
34
36
|
|
|
35
37
|
export interface SimWorldOptions {
|
|
36
38
|
/** Seeds `Math.random` for the run: a body's jitter and draws are the seed's. */
|
|
@@ -40,6 +42,11 @@ export interface SimWorldOptions {
|
|
|
40
42
|
decide(op: SimOp): SimDecision | Promise<SimDecision>;
|
|
41
43
|
/** Told every landed call, after the world has read it. */
|
|
42
44
|
landed?(op: SimOp, answer: unknown): void;
|
|
45
|
+
/**
|
|
46
|
+
* Only the row the harness filed is ever claimed: what a body enqueues lands, dedupes and is an
|
|
47
|
+
* `enqueue:` effect, and never runs — its effects are its own job's, not this run's.
|
|
48
|
+
*/
|
|
49
|
+
readonly alone?: boolean;
|
|
43
50
|
}
|
|
44
51
|
|
|
45
52
|
export interface SimWorld extends AsyncDisposable {
|
|
@@ -115,9 +122,11 @@ export async function openSimWorld(options: SimWorldOptions): Promise<SimWorld>
|
|
|
115
122
|
const anonymous = ctxOf({ role: 'worker' });
|
|
116
123
|
const context = options.context ?? (() => anonymous);
|
|
117
124
|
|
|
118
|
-
const queues = (): readonly string[] =>
|
|
119
|
-
|
|
120
|
-
|
|
125
|
+
const queues = (): readonly string[] =>
|
|
126
|
+
options.alone === true
|
|
127
|
+
? [HARNESS_QUEUE]
|
|
128
|
+
: [...new Set([jobs.DEFAULT_QUEUE, ...jobs.registeredJobs().map((handle) => handle.queue)])];
|
|
129
|
+
const claimable = new Set(queues());
|
|
121
130
|
|
|
122
131
|
const rows = async (): Promise<readonly JobRecord[]> => {
|
|
123
132
|
const all: JobRecord[] = [];
|
|
@@ -146,7 +155,7 @@ export async function openSimWorld(options: SimWorldOptions): Promise<SimWorld>
|
|
|
146
155
|
enqueue: async (handle, input) => {
|
|
147
156
|
const result = await base.enqueue({
|
|
148
157
|
name: handle.name,
|
|
149
|
-
queue: handle.queue,
|
|
158
|
+
queue: options.alone === true ? HARNESS_QUEUE : handle.queue,
|
|
150
159
|
input,
|
|
151
160
|
idempotencyKey: handle.idempotencyKeyFor(input),
|
|
152
161
|
maxAttempts: jobs.enqueuedAttempts(handle.retry, handle.compensates),
|
|
@@ -179,7 +188,9 @@ export async function openSimWorld(options: SimWorldOptions): Promise<SimWorld>
|
|
|
179
188
|
rows,
|
|
180
189
|
nextDue: async () => {
|
|
181
190
|
const due = (await rows())
|
|
182
|
-
.filter(
|
|
191
|
+
.filter(
|
|
192
|
+
(row) => LIVE.has(row.state) && (options.alone !== true || claimable.has(row.queue)),
|
|
193
|
+
)
|
|
183
194
|
.map((row) => (row.state === 'running' ? (row.visibleAt ?? row.runAt) : row.runAt));
|
|
184
195
|
return due.length === 0 ? undefined : Math.min(...due);
|
|
185
196
|
},
|