@tokenoftrust/storefront-runner 2.4.7 → 2.4.9
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/apps/storefront/package.json +1 -1
- package/apps/storefront/test/pipeline/fake-preview-services.ts +25 -9
- package/apps/storefront/test/pipeline/harness.ts +2 -1
- package/apps/storefront/test/pipeline/matrix-faults.ts +344 -0
- package/apps/storefront/test/pipeline/pipeline-host.ts +173 -8
- package/apps/storefront/test/pipeline/pool-config.cjs +78 -0
- package/apps/storefront/test/pipeline/publish-kinds.ts +109 -27
- package/apps/storefront/test/pipeline-faults/matrix-lib.ts +661 -0
- package/apps/storefront/vitest.faults.config.cts +13 -0
- package/apps/storefront/vitest.workers.config.cts +5 -67
- package/package.json +1 -1
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"preview": "wrangler dev",
|
|
15
15
|
"typecheck": "astro check && tsc --noEmit",
|
|
16
16
|
"astro": "astro",
|
|
17
|
-
"test": "NODE_OPTIONS=--experimental-sqlite vitest run && pnpm run test:pipeline"
|
|
17
|
+
"test": "NODE_OPTIONS=--experimental-sqlite vitest run && pnpm run test:pipeline && pnpm run test:pipeline-faults"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"@astrojs/cloudflare": "^14.0.1",
|
|
@@ -17,16 +17,17 @@
|
|
|
17
17
|
* transient timeout instead of a refusal
|
|
18
18
|
* - `slow-build` every file of a build takes a few milliseconds (a build that is
|
|
19
19
|
* still running seconds after its intent was answered)
|
|
20
|
-
* The fake also records what each job reported to MCP (`fake_events` kind `report`)
|
|
21
|
-
* shas the preview could not render (`refused`).
|
|
20
|
+
* The fake also records what each job reported to MCP (`fake_events` kind `report`), which
|
|
21
|
+
* shas the preview could not render (`refused`), and each undo candidate it opened (`undo`).
|
|
22
22
|
* A fault fires `times` times (0 = every time); its firing is persisted before it acts.
|
|
23
23
|
*/
|
|
24
24
|
import type { DurableObjectState } from "cloudflare:workers";
|
|
25
25
|
import { ForgeToolError, ForgeTransportError } from "../../src/lib/forge/errors";
|
|
26
|
-
import
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
import {
|
|
27
|
+
BUILD_CHECKPOINT_EVERY,
|
|
28
|
+
type MembershipEntry,
|
|
29
|
+
type MergeCandidate,
|
|
30
|
+
type PreviewJobServices,
|
|
30
31
|
} from "../../src/lib/pipeline/preview-jobs";
|
|
31
32
|
|
|
32
33
|
/** Files a fake build materializes. */
|
|
@@ -122,7 +123,14 @@ export function readFakeState(state: DurableObjectState): FakeState {
|
|
|
122
123
|
};
|
|
123
124
|
}
|
|
124
125
|
|
|
125
|
-
|
|
126
|
+
/** The file at which a build reports `build:file` to the fault matrix: past its first checkpoint. */
|
|
127
|
+
export const MATRIX_BUILD_FILE = BUILD_CHECKPOINT_EVERY + 5;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* `onEffect` hears the one effect only the fake can name: the build writing a file past its first
|
|
131
|
+
* checkpoint (`build:file`), where an eviction must resume from that checkpoint.
|
|
132
|
+
*/
|
|
133
|
+
export function fakePreviewServices(state: DurableObjectState, onEffect?: (effect: string) => Promise<void>): PreviewJobServices {
|
|
126
134
|
fakeTables(state);
|
|
127
135
|
const { sql } = state.storage;
|
|
128
136
|
|
|
@@ -200,6 +208,7 @@ export function fakePreviewServices(state: DurableObjectState): PreviewJobServic
|
|
|
200
208
|
if (await fires(`evict-at-file:${file}`)) evict(`at file ${file}`);
|
|
201
209
|
sql.exec(`INSERT INTO fake_file_runs (sha, file) VALUES (?, ?)`, sha, file);
|
|
202
210
|
await resume.record(path, { storedVersionId: `v-${sha}-${file}`, issues: [], skippedHere: false });
|
|
211
|
+
if (file === MATRIX_BUILD_FILE && onEffect) await onEffect("build:file");
|
|
203
212
|
}
|
|
204
213
|
return { ok: true, versionId: sha, digest: `digest-${sha}` };
|
|
205
214
|
},
|
|
@@ -265,8 +274,15 @@ export function fakePreviewServices(state: DurableObjectState): PreviewJobServic
|
|
|
265
274
|
return { outcome: "conflict" as const, notified: true };
|
|
266
275
|
},
|
|
267
276
|
|
|
268
|
-
async prepareUndo(): Promise<MergeCandidate> {
|
|
269
|
-
|
|
277
|
+
async prepareUndo({ target }): Promise<MergeCandidate> {
|
|
278
|
+
// The forge opens the undo of the change as a candidate of its own, once per change.
|
|
279
|
+
const of = "prNumber" in target ? `pr-${target.prNumber}` : target.changeId;
|
|
280
|
+
const changeId = `undo-${of}`;
|
|
281
|
+
const headSha = `head-${changeId}`;
|
|
282
|
+
const merged = sql.exec(`SELECT 1 FROM fake_merges WHERE change_id = ?`, changeId).toArray().length > 0;
|
|
283
|
+
if (!merged) sql.exec(`INSERT OR IGNORE INTO fake_open (change_id, head_sha) VALUES (?, ?)`, changeId, headSha);
|
|
284
|
+
sql.exec(`INSERT INTO fake_events (kind, subject) VALUES ('undo', ?)`, changeId);
|
|
285
|
+
return { changeId, prNumber: null, headSha, message: `Undo ${of}` };
|
|
270
286
|
},
|
|
271
287
|
};
|
|
272
288
|
}
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The systematic fault matrix's injection points inside the harness object (test-only; the suite is
|
|
3
|
+
* `test/pipeline-faults/`).
|
|
4
|
+
*
|
|
5
|
+
* Every job kind the object runs is wrapped so the object can be evicted (`ctx.abort()`) at any
|
|
6
|
+
* named point of any step:
|
|
7
|
+
*
|
|
8
|
+
* before the step's first execution, before its work
|
|
9
|
+
* after the step's work is done (every effect landed), before the engine records the outcome
|
|
10
|
+
* resume the step's second execution (a step that checkpointed, waited, or was retried)
|
|
11
|
+
* effect right after one named external effect of the step landed (mid-step), before the step
|
|
12
|
+
* goes on — the forge merge, a CI dispatch, a pointer write, a notice, …
|
|
13
|
+
*
|
|
14
|
+
* and a step can be HELD (`hold`): its first execution parks inside the step — renewing its lease —
|
|
15
|
+
* until the test releases it, so "something arrives while this step runs" is a latch, not a race.
|
|
16
|
+
*
|
|
17
|
+
* Effects are reported by the stand-ins themselves (`landed(effect)`): the fake preview services,
|
|
18
|
+
* the publish world, the pointer writer the publish kinds write `live` through, the assets pointer.
|
|
19
|
+
* Each one is logged against the job and step that caused it (`matrix_effects`), so the suite
|
|
20
|
+
* derives a step's mid-step kill points from an unfaulted run, and counts duplicates.
|
|
21
|
+
*
|
|
22
|
+
* A fault is armed for one kind and step; its remaining count is persisted (and flushed) before it
|
|
23
|
+
* acts, so it fires once however often the step is re-run after the eviction it causes. At the kill
|
|
24
|
+
* instant the object records what the convergence guarantee needs (`matrix_kills`): the alarm that
|
|
25
|
+
* will resume the work, whether the job's compensation is armed, and what `live` names next to the
|
|
26
|
+
* engine's live record — a kill that leaves them apart with no compensation armed is a finding.
|
|
27
|
+
*/
|
|
28
|
+
import type { DurableObjectState } from "cloudflare:workers";
|
|
29
|
+
import { JobKindRegistry, type JobKindDefinition, type StepContext, type StepDefinition } from "../../src/lib/pipeline/job-runner";
|
|
30
|
+
import type { PreviewJobServices } from "../../src/lib/pipeline/preview-jobs";
|
|
31
|
+
import { readLivePointer } from "../../src/lib/pipeline/publish/live-pointer";
|
|
32
|
+
import { projectionStore, type TestKv } from "../../src/lib/pipeline/publish/publish-test-support";
|
|
33
|
+
|
|
34
|
+
export type MatrixMode = "before" | "after" | "resume" | "effect" | "hold";
|
|
35
|
+
|
|
36
|
+
export interface MatrixFault {
|
|
37
|
+
kind: string;
|
|
38
|
+
step: string;
|
|
39
|
+
mode: MatrixMode;
|
|
40
|
+
/** `effect` mode: the effect after which the object is evicted. */
|
|
41
|
+
effect?: string | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** What the object looked like at the instant a matrix fault evicted it. */
|
|
45
|
+
export interface MatrixKill {
|
|
46
|
+
jobId: string;
|
|
47
|
+
kind: string;
|
|
48
|
+
step: string;
|
|
49
|
+
mode: MatrixMode;
|
|
50
|
+
effect: string | null;
|
|
51
|
+
/** The alarm that resumes the work (null: nothing will). */
|
|
52
|
+
alarm: number | null;
|
|
53
|
+
/** Jobs not yet terminal, and compensations not yet settled. */
|
|
54
|
+
activeJobs: number;
|
|
55
|
+
pendingCompensations: number;
|
|
56
|
+
/** The job's compensation is recorded (armed or pending). */
|
|
57
|
+
armed: boolean;
|
|
58
|
+
/** Publish kinds only: what the `live` channel names, and the engine's live record for the job's provider. */
|
|
59
|
+
live: string | null;
|
|
60
|
+
record: string | null;
|
|
61
|
+
/** CloudFront jobs: what the public host serves (the `live` channel follows proven delivery there). */
|
|
62
|
+
served: string | null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface MatrixEffect {
|
|
66
|
+
jobId: string;
|
|
67
|
+
kind: string;
|
|
68
|
+
step: string;
|
|
69
|
+
effect: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Report an external effect that just landed. Evicts the object when a fault is armed for it. */
|
|
73
|
+
export type Landed = (effect: string) => Promise<void>;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The preview services, reporting each external effect a preview job makes. A refresh that ran into
|
|
77
|
+
* a conflict emails the candidate's author (`email:conflict`); one that did not is `forge:rebase`.
|
|
78
|
+
*/
|
|
79
|
+
export function previewServicesWithEffects(services: PreviewJobServices, landed: Landed): PreviewJobServices {
|
|
80
|
+
const after =
|
|
81
|
+
<A extends unknown[], R>(effect: string, call: (...args: A) => Promise<R>) =>
|
|
82
|
+
async (...args: A): Promise<R> => {
|
|
83
|
+
const result = await call(...args);
|
|
84
|
+
await landed(effect);
|
|
85
|
+
return result;
|
|
86
|
+
};
|
|
87
|
+
return {
|
|
88
|
+
...services,
|
|
89
|
+
integrateCandidate: after("forge:merge", services.integrateCandidate.bind(services)),
|
|
90
|
+
prepareUndo: after("forge:prepare-undo", services.prepareUndo.bind(services)),
|
|
91
|
+
materialize: after("build:materialized", services.materialize.bind(services)),
|
|
92
|
+
reportOutcome: after("mcp:report", services.reportOutcome.bind(services)),
|
|
93
|
+
recordPreviewRefused: after("preview:refused", services.recordPreviewRefused.bind(services)),
|
|
94
|
+
advancePreview: after("preview:advance", services.advancePreview.bind(services)),
|
|
95
|
+
stageAggregate: after("ci:stage-aggregate", services.stageAggregate.bind(services)),
|
|
96
|
+
rebaseCandidate: async (args) => {
|
|
97
|
+
const result = await services.rebaseCandidate(args);
|
|
98
|
+
await landed(result.outcome === "conflict" ? "email:conflict" : "forge:rebase");
|
|
99
|
+
return result;
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface Running {
|
|
105
|
+
jobId: string;
|
|
106
|
+
kind: string;
|
|
107
|
+
step: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function tables(state: DurableObjectState): void {
|
|
111
|
+
const { sql } = state.storage;
|
|
112
|
+
sql.exec(
|
|
113
|
+
`CREATE TABLE IF NOT EXISTS matrix_faults (id INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT, step TEXT, mode TEXT, effect TEXT, remaining INTEGER)`,
|
|
114
|
+
);
|
|
115
|
+
sql.exec(`CREATE TABLE IF NOT EXISTS matrix_effects (seq INTEGER PRIMARY KEY AUTOINCREMENT, job_id TEXT, kind TEXT, step TEXT, effect TEXT)`);
|
|
116
|
+
sql.exec(`CREATE TABLE IF NOT EXISTS matrix_execs (job_id TEXT, step TEXT, n INTEGER, PRIMARY KEY (job_id, step))`);
|
|
117
|
+
sql.exec(`CREATE TABLE IF NOT EXISTS matrix_kills (seq INTEGER PRIMARY KEY AUTOINCREMENT, kill TEXT)`);
|
|
118
|
+
sql.exec(`CREATE TABLE IF NOT EXISTS matrix_holds (job_id TEXT, step TEXT, released INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (job_id, step))`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The longest a held step waits for its release before it goes on by itself. */
|
|
122
|
+
const HOLD_LIMIT_MS = 30_000;
|
|
123
|
+
|
|
124
|
+
export class MatrixFaults {
|
|
125
|
+
private running: Running | null = null;
|
|
126
|
+
|
|
127
|
+
constructor(
|
|
128
|
+
private readonly state: DurableObjectState,
|
|
129
|
+
/** The KV the `live` channel is projected to (what the edge reads). */
|
|
130
|
+
private readonly projectionKv: () => TestKv,
|
|
131
|
+
) {
|
|
132
|
+
tables(state);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Every kind of `registry`, its steps (and compensation) wrapped with the matrix's kill points. */
|
|
136
|
+
wrap(registry: JobKindRegistry): JobKindRegistry {
|
|
137
|
+
const out = new JobKindRegistry();
|
|
138
|
+
for (const name of registry.list()) {
|
|
139
|
+
const definition = registry.get(name)!;
|
|
140
|
+
const onTerminal = definition.onTerminal;
|
|
141
|
+
out.register({
|
|
142
|
+
...definition,
|
|
143
|
+
...(onTerminal
|
|
144
|
+
? {
|
|
145
|
+
onTerminal: async (info: Parameters<NonNullable<JobKindDefinition<unknown>["onTerminal"]>>[0]) => {
|
|
146
|
+
const previous = this.running;
|
|
147
|
+
this.running = { jobId: info.jobId, kind: name, step: "on-terminal" };
|
|
148
|
+
try {
|
|
149
|
+
await onTerminal(info);
|
|
150
|
+
} finally {
|
|
151
|
+
this.running = previous;
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
}
|
|
155
|
+
: {}),
|
|
156
|
+
steps: definition.steps.map((step) => this.wrapStep(name, step)),
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private wrapStep(kind: string, step: StepDefinition<unknown>): StepDefinition<unknown> {
|
|
163
|
+
return {
|
|
164
|
+
...step,
|
|
165
|
+
run: async (ctx: StepContext<unknown>) => {
|
|
166
|
+
const { sql } = this.state.storage;
|
|
167
|
+
const n = sql
|
|
168
|
+
.exec<{ n: number }>(
|
|
169
|
+
`INSERT INTO matrix_execs (job_id, step, n) VALUES (?, ?, 1) ON CONFLICT(job_id, step) DO UPDATE SET n = n + 1 RETURNING n`,
|
|
170
|
+
ctx.jobId,
|
|
171
|
+
step.name,
|
|
172
|
+
)
|
|
173
|
+
.one().n;
|
|
174
|
+
this.running = { jobId: ctx.jobId, kind, step: step.name };
|
|
175
|
+
try {
|
|
176
|
+
if (n === 1) await this.maybeHold(kind, step.name, ctx);
|
|
177
|
+
if (n === 1) await this.maybeKill("before", null);
|
|
178
|
+
if (n === 2) await this.maybeKill("resume", null);
|
|
179
|
+
const outcome = await step.run(ctx);
|
|
180
|
+
await this.maybeKill("after", null);
|
|
181
|
+
return outcome;
|
|
182
|
+
} finally {
|
|
183
|
+
if (this.running?.jobId === ctx.jobId && this.running.step === step.name) this.running = null;
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The effect hook handed to every stand-in. */
|
|
190
|
+
readonly landed: Landed = async (effect) => {
|
|
191
|
+
const running = this.running;
|
|
192
|
+
this.state.storage.sql.exec(
|
|
193
|
+
`INSERT INTO matrix_effects (job_id, kind, step, effect) VALUES (?, ?, ?, ?)`,
|
|
194
|
+
running?.jobId ?? "",
|
|
195
|
+
running?.kind ?? "",
|
|
196
|
+
running?.step ?? "",
|
|
197
|
+
effect,
|
|
198
|
+
);
|
|
199
|
+
if (running) await this.maybeKill("effect", effect);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
arm(fault: MatrixFault): void {
|
|
203
|
+
this.state.storage.sql.exec(
|
|
204
|
+
`INSERT INTO matrix_faults (kind, step, mode, effect, remaining) VALUES (?, ?, ?, ?, 1)`,
|
|
205
|
+
fault.kind,
|
|
206
|
+
fault.step,
|
|
207
|
+
fault.mode,
|
|
208
|
+
fault.effect ?? null,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Steps parked by a `hold` fault and not yet released. */
|
|
213
|
+
held(): { jobId: string; step: string }[] {
|
|
214
|
+
return this.state.storage.sql
|
|
215
|
+
.exec<{ job_id: string; step: string }>(`SELECT job_id, step FROM matrix_holds WHERE released = 0`)
|
|
216
|
+
.toArray()
|
|
217
|
+
.map((r) => ({ jobId: r.job_id, step: r.step }));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
release(): void {
|
|
221
|
+
this.state.storage.sql.exec(`UPDATE matrix_holds SET released = 1`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private async maybeHold(kind: string, step: string, ctx: StepContext<unknown>): Promise<void> {
|
|
225
|
+
const { sql } = this.state.storage;
|
|
226
|
+
const fault = sql
|
|
227
|
+
.exec<{ id: number }>(`SELECT id FROM matrix_faults WHERE kind = ? AND step = ? AND mode = 'hold' AND remaining > 0 ORDER BY id LIMIT 1`, kind, step)
|
|
228
|
+
.toArray()[0];
|
|
229
|
+
if (!fault) return;
|
|
230
|
+
sql.exec(`UPDATE matrix_faults SET remaining = remaining - 1 WHERE id = ?`, fault.id);
|
|
231
|
+
sql.exec(`INSERT OR REPLACE INTO matrix_holds (job_id, step, released) VALUES (?, ?, 0)`, ctx.jobId, step);
|
|
232
|
+
await this.state.storage.sync();
|
|
233
|
+
const until = Date.now() + HOLD_LIMIT_MS;
|
|
234
|
+
for (;;) {
|
|
235
|
+
const row = sql.exec<{ released: number }>(`SELECT released FROM matrix_holds WHERE job_id = ? AND step = ?`, ctx.jobId, step).one();
|
|
236
|
+
if (row.released === 1 || Date.now() > until) return;
|
|
237
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
238
|
+
await ctx.renewLease();
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
kills(): MatrixKill[] {
|
|
243
|
+
return this.state.storage.sql
|
|
244
|
+
.exec<{ kill: string }>(`SELECT kill FROM matrix_kills ORDER BY seq`)
|
|
245
|
+
.toArray()
|
|
246
|
+
.map((r) => JSON.parse(r.kill) as MatrixKill);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
effects(jobId?: string): MatrixEffect[] {
|
|
250
|
+
const { sql } = this.state.storage;
|
|
251
|
+
const rows = jobId
|
|
252
|
+
? sql.exec<{ job_id: string; kind: string; step: string; effect: string }>(
|
|
253
|
+
`SELECT job_id, kind, step, effect FROM matrix_effects WHERE job_id = ? ORDER BY seq`,
|
|
254
|
+
jobId,
|
|
255
|
+
)
|
|
256
|
+
: sql.exec<{ job_id: string; kind: string; step: string; effect: string }>(`SELECT job_id, kind, step, effect FROM matrix_effects ORDER BY seq`);
|
|
257
|
+
return rows.toArray().map((r) => ({ jobId: r.job_id, kind: r.kind, step: r.step, effect: r.effect }));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
execs(jobId: string): { step: string; n: number }[] {
|
|
261
|
+
return this.state.storage.sql.exec<{ step: string; n: number }>(`SELECT step, n FROM matrix_execs WHERE job_id = ?`, jobId).toArray();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Non-terminal jobs, unsettled compensations and the alarm: an orphan is work no alarm will resume. */
|
|
265
|
+
async health(): Promise<{
|
|
266
|
+
alarm: number | null;
|
|
267
|
+
active: { jobId: string; state: string }[];
|
|
268
|
+
compensations: { jobId: string; exhausted: boolean; reported: boolean }[];
|
|
269
|
+
}> {
|
|
270
|
+
const { sql } = this.state.storage;
|
|
271
|
+
const active = sql
|
|
272
|
+
.exec<{ job_id: string; state: string }>(`SELECT job_id, state FROM jobs WHERE state NOT IN ('succeeded', 'needs-developer', 'stuck')`)
|
|
273
|
+
.toArray()
|
|
274
|
+
.map((r) => ({ jobId: r.job_id, state: r.state }));
|
|
275
|
+
const compensations = sql
|
|
276
|
+
.exec<{ job_id: string; exhausted_at: number | null; reported_at: number | null }>(
|
|
277
|
+
`SELECT job_id, exhausted_at, reported_at FROM terminal_hooks`,
|
|
278
|
+
)
|
|
279
|
+
.toArray()
|
|
280
|
+
.map((r) => ({ jobId: r.job_id, exhausted: r.exhausted_at !== null, reported: r.reported_at !== null }));
|
|
281
|
+
return { alarm: await this.state.storage.getAlarm(), active, compensations };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private async maybeKill(mode: MatrixMode, effect: string | null): Promise<void> {
|
|
285
|
+
const running = this.running;
|
|
286
|
+
if (!running) return;
|
|
287
|
+
const { sql } = this.state.storage;
|
|
288
|
+
const fault = sql
|
|
289
|
+
.exec<{ id: number }>(
|
|
290
|
+
`SELECT id FROM matrix_faults WHERE kind = ? AND step = ? AND mode = ? AND IFNULL(effect, '') = ? AND remaining > 0 ORDER BY id LIMIT 1`,
|
|
291
|
+
running.kind,
|
|
292
|
+
running.step,
|
|
293
|
+
mode,
|
|
294
|
+
effect ?? "",
|
|
295
|
+
)
|
|
296
|
+
.toArray()[0];
|
|
297
|
+
if (!fault) return;
|
|
298
|
+
sql.exec(`UPDATE matrix_faults SET remaining = remaining - 1 WHERE id = ?`, fault.id);
|
|
299
|
+
const kill = await this.snapshot(running, mode, effect);
|
|
300
|
+
sql.exec(`INSERT INTO matrix_kills (kill) VALUES (?)`, JSON.stringify(kill));
|
|
301
|
+
// Everything written so far — the effect itself included — is durable, exactly as a real crash
|
|
302
|
+
// after it would leave it.
|
|
303
|
+
await this.state.storage.sync();
|
|
304
|
+
this.state.abort(`synthetic eviction matrix ${mode} ${running.step}${effect ? ` ${effect}` : ""}`);
|
|
305
|
+
throw new Error("abort() returned");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private async snapshot(running: Running, mode: MatrixMode, effect: string | null): Promise<MatrixKill> {
|
|
309
|
+
const { sql } = this.state.storage;
|
|
310
|
+
const job = sql
|
|
311
|
+
.exec<{ tenant_id: string; input: string; lane: string }>(`SELECT tenant_id, input, lane FROM jobs WHERE job_id = ?`, running.jobId)
|
|
312
|
+
.toArray()[0];
|
|
313
|
+
const health = await this.health();
|
|
314
|
+
let live: string | null = null;
|
|
315
|
+
let record: string | null = null;
|
|
316
|
+
let served: string | null = null;
|
|
317
|
+
// Publish kinds (their input declares the serving provider) are the ones that move `live`.
|
|
318
|
+
const input = job ? (JSON.parse(job.input) as { provider?: string | null }) : null;
|
|
319
|
+
if (job?.lane === "live" && input && "provider" in input) {
|
|
320
|
+
const provider = input.provider ?? "worker-r2-pointer";
|
|
321
|
+
const kv = this.projectionKv();
|
|
322
|
+
live = (await readLivePointer(projectionStore(kv), job.tenant_id)).versionId;
|
|
323
|
+
record = sql.exec<{ sha: string }>(`SELECT sha FROM live WHERE provider = ?`, provider).toArray()[0]?.sha ?? null;
|
|
324
|
+
if (provider === "s3-overwrite") {
|
|
325
|
+
const raw = await kv.get("world:cloudfront");
|
|
326
|
+
served = raw ? ((JSON.parse(raw) as { sha?: string }).sha ?? null) : null;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return {
|
|
330
|
+
jobId: running.jobId,
|
|
331
|
+
kind: running.kind,
|
|
332
|
+
step: running.step,
|
|
333
|
+
mode,
|
|
334
|
+
effect,
|
|
335
|
+
alarm: health.alarm,
|
|
336
|
+
activeJobs: health.active.length,
|
|
337
|
+
pendingCompensations: health.compensations.length,
|
|
338
|
+
armed: health.compensations.some((c) => c.jobId === running.jobId),
|
|
339
|
+
live,
|
|
340
|
+
record,
|
|
341
|
+
served,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
}
|
|
@@ -4,21 +4,30 @@
|
|
|
4
4
|
* restarts the object without touching its callers). It runs the production engine with the
|
|
5
5
|
* synthetic job kinds, the production preview kinds (integrate / rebuild / revert / sync-preview) over
|
|
6
6
|
* fake services (`./fake-preview-services.ts`) and the real publish kinds over the stand-in world
|
|
7
|
-
* (`./publish-kinds.ts`), and answers intents through `submitIntent`.
|
|
7
|
+
* (`./publish-kinds.ts`), and answers intents through `submitIntent`. Every kind is wrapped with the
|
|
8
|
+
* fault matrix's kill points (`./matrix-faults.ts`), inert until a test arms one.
|
|
8
9
|
*/
|
|
9
10
|
import type { DurableObjectState } from "cloudflare:workers";
|
|
10
11
|
import { TenantPipeline as DeployedTenantPipeline } from "../../src/lib/pipeline/tenant-pipeline";
|
|
11
12
|
import { submitIntent, tenantPipeline, type TenantPipelineNamespace } from "../../src/lib/pipeline/intake";
|
|
12
13
|
import { submitPreviewSync } from "../../src/lib/pipeline/preview-sync-intake";
|
|
13
|
-
import {
|
|
14
|
+
import { JobKindRegistry, StaleExecutionError, type PipelineIntent } from "../../src/lib/pipeline/job-runner";
|
|
14
15
|
import { abandonedContexts, syntheticJobKinds, syntheticSpans } from "./synthetic-job";
|
|
15
16
|
import { registerPreviewJobKinds, type PreviewJobTiming } from "../../src/lib/pipeline/preview-jobs";
|
|
16
17
|
import { configureFake, fakePreviewServices, readFakeState, type FakeFault, type FakeState } from "./fake-preview-services";
|
|
18
|
+
import { MatrixFaults, previewServicesWithEffects, type Landed, type MatrixFault } from "./matrix-faults";
|
|
19
|
+
import { kvAbandonServices, legacyOperationKey, registerSeedJobKind, type SeedServices } from "../../src/lib/pipeline/seed-from-legacy";
|
|
20
|
+
import { readAssetsPointer } from "@tot/public-runtime";
|
|
17
21
|
import { readLivePointer } from "../../src/lib/pipeline/publish/live-pointer";
|
|
18
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
listIntegrationRuns,
|
|
24
|
+
listReleaseReceipts,
|
|
25
|
+
readReleaseTruth,
|
|
26
|
+
writeIntegrationRun,
|
|
27
|
+
type ReleaseModelKv,
|
|
28
|
+
} from "../../src/lib/change/store/aggregate-release-store";
|
|
19
29
|
import { projectionStore, seedGreenAggregate, testVersionStore, type TestKv } from "../../src/lib/pipeline/publish/publish-test-support";
|
|
20
30
|
import {
|
|
21
|
-
armedAtFaults,
|
|
22
31
|
armPublishFault,
|
|
23
32
|
firedPublishFaults,
|
|
24
33
|
publishWorldOf,
|
|
@@ -27,6 +36,9 @@ import {
|
|
|
27
36
|
} from "./publish-kinds";
|
|
28
37
|
|
|
29
38
|
/** The preview kinds' timing in the harness: the production shape, scaled to milliseconds. */
|
|
39
|
+
/** The seed kind's lease and retries in the harness. */
|
|
40
|
+
const HARNESS_SEED_TIMING = { leaseMs: 1_000, deadlineMs: 30_000, retryBaseMs: 10 };
|
|
41
|
+
|
|
30
42
|
export const HARNESS_PREVIEW_TIMING: PreviewJobTiming = {
|
|
31
43
|
leaseMs: 1_000,
|
|
32
44
|
deadlineMs: 30_000,
|
|
@@ -35,13 +47,133 @@ export const HARNESS_PREVIEW_TIMING: PreviewJobTiming = {
|
|
|
35
47
|
defaultExpectedMs: 2_000,
|
|
36
48
|
};
|
|
37
49
|
|
|
50
|
+
/**
|
|
51
|
+
* The seed's services in the harness: what is served, read from this object's pointer authority (as
|
|
52
|
+
* the deployed object reads it), and the close-out of legacy records over the harness KV — each
|
|
53
|
+
* close-out reported to the fault matrix.
|
|
54
|
+
*/
|
|
55
|
+
function harnessSeedServices(
|
|
56
|
+
ctx: DurableObjectState,
|
|
57
|
+
pointers: { read(tenantId: string): Promise<{ channels?: { preview?: string; live?: string } }> },
|
|
58
|
+
landed: Landed,
|
|
59
|
+
): SeedServices {
|
|
60
|
+
const abandon = kvAbandonServices(publishWorldOf(ctx).kv as unknown as ReleaseModelKv);
|
|
61
|
+
return {
|
|
62
|
+
async pointers(tenantId) {
|
|
63
|
+
const pointer = await pointers.read(tenantId);
|
|
64
|
+
return { preview: pointer.channels?.preview ?? null, live: pointer.channels?.live ?? null };
|
|
65
|
+
},
|
|
66
|
+
async abandonRun(tenantId, runId, at) {
|
|
67
|
+
const changed = await abandon.abandonRun(tenantId, runId, at);
|
|
68
|
+
await landed("kv:abandon-run");
|
|
69
|
+
return changed;
|
|
70
|
+
},
|
|
71
|
+
async abandonOp(tenantId, op, at) {
|
|
72
|
+
const changed = await abandon.abandonOp(tenantId, op, at);
|
|
73
|
+
await landed("kv:abandon-op");
|
|
74
|
+
return changed;
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Per object: the fault matrix's injection points and the (wrapped) registry it runs. */
|
|
80
|
+
const matrices = new WeakMap<DurableObjectState, { faults: MatrixFaults; registry: JobKindRegistry }>();
|
|
81
|
+
|
|
38
82
|
export class TenantPipeline extends DeployedTenantPipeline {
|
|
39
83
|
protected override jobKinds(ctx: DurableObjectState): JobKindRegistry {
|
|
40
|
-
|
|
84
|
+
const faults = new MatrixFaults(ctx, () => publishWorldOf(ctx).kv);
|
|
85
|
+
const services = previewServicesWithEffects(fakePreviewServices(ctx, faults.landed), faults.landed);
|
|
86
|
+
const registry = registerHarnessPublishKinds(
|
|
41
87
|
ctx,
|
|
42
|
-
registerPreviewJobKinds(syntheticJobKinds(ctx),
|
|
88
|
+
registerPreviewJobKinds(syntheticJobKinds(ctx), services, HARNESS_PREVIEW_TIMING),
|
|
43
89
|
this.pointers,
|
|
90
|
+
faults.landed,
|
|
44
91
|
);
|
|
92
|
+
// The production seed kind, its lease and retries scaled to the harness (an evicted seed resumes
|
|
93
|
+
// when its lease runs out, as in production).
|
|
94
|
+
const seed = registerSeedJobKind(new JobKindRegistry(), harnessSeedServices(ctx, this.pointers, faults.landed));
|
|
95
|
+
for (const name of seed.list()) registry.register({ ...seed.get(name)!, ...HARNESS_SEED_TIMING });
|
|
96
|
+
const wrapped = faults.wrap(registry);
|
|
97
|
+
matrices.set(ctx, { faults, registry: wrapped });
|
|
98
|
+
return wrapped;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Harness-only: arm and read the fault matrix (`./matrix-faults.ts`, `test/pipeline-faults/`). */
|
|
102
|
+
async matrix(op: string, args: Record<string, unknown> = {}): Promise<unknown> {
|
|
103
|
+
const { faults, registry } = matrices.get(this.ctx)!;
|
|
104
|
+
switch (op) {
|
|
105
|
+
case "arm":
|
|
106
|
+
faults.arm(args as unknown as MatrixFault);
|
|
107
|
+
return null;
|
|
108
|
+
case "held":
|
|
109
|
+
return faults.held();
|
|
110
|
+
case "release":
|
|
111
|
+
faults.release();
|
|
112
|
+
return null;
|
|
113
|
+
case "kills":
|
|
114
|
+
return faults.kills();
|
|
115
|
+
case "effects":
|
|
116
|
+
return faults.effects(args.jobId as string | undefined);
|
|
117
|
+
case "execs":
|
|
118
|
+
return faults.execs(String(args.jobId));
|
|
119
|
+
case "health":
|
|
120
|
+
return faults.health();
|
|
121
|
+
case "kinds":
|
|
122
|
+
return registry.list().map((kind) => {
|
|
123
|
+
const definition = registry.get(kind)!;
|
|
124
|
+
return { kind, lane: definition.lane ?? "live", steps: definition.steps.map((s) => s.name) };
|
|
125
|
+
});
|
|
126
|
+
case "endState":
|
|
127
|
+
return this.endState(String(args.tenantId));
|
|
128
|
+
default:
|
|
129
|
+
throw new Error(`unknown matrix op ${op}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** What a converged job leaves behind, on both lanes — the fault matrix compares it to an unfaulted run. */
|
|
134
|
+
private async endState(tenantId: string): Promise<Record<string, unknown>> {
|
|
135
|
+
const { kv, world } = publishWorldOf(this.ctx);
|
|
136
|
+
const truth = await readReleaseTruth(kv, tenantId, "preview");
|
|
137
|
+
const fake = readFakeState(this.ctx);
|
|
138
|
+
return {
|
|
139
|
+
live: await readLivePointer(projectionStore(kv), tenantId),
|
|
140
|
+
liveRecords: this.ctx.storage.sql
|
|
141
|
+
.exec<{ provider: string; sha: string; receipt_id: string }>(`SELECT provider, sha, receipt_id FROM live ORDER BY provider`)
|
|
142
|
+
.toArray()
|
|
143
|
+
.map((r) => ({ provider: r.provider, sha: r.sha, receiptId: r.receipt_id })),
|
|
144
|
+
truth: truth
|
|
145
|
+
? {
|
|
146
|
+
liveReceiptId: truth.liveReceiptId ?? null,
|
|
147
|
+
pendingReceiptId: truth.pendingReceiptId ?? null,
|
|
148
|
+
deliveryState: truth.deliveryState ?? null,
|
|
149
|
+
unprovenLive: truth.unprovenLive ?? null,
|
|
150
|
+
liveDisagrees: truth.liveDisagrees ? { serving: truth.liveDisagrees.serving, recordedReceiptId: truth.liveDisagrees.recordedReceiptId } : null,
|
|
151
|
+
}
|
|
152
|
+
: null,
|
|
153
|
+
receipts: (await listReleaseReceipts(kv, tenantId, "preview")).map((r) => ({ receiptId: r.receiptId, sha: r.aggregateSha })),
|
|
154
|
+
promotion: await world.promotionStatus(),
|
|
155
|
+
cloudfront: await world.cloudFrontServes(),
|
|
156
|
+
assetsLive: (await readAssetsPointer({ get: (k: string) => kv.get(k) }, tenantId)).live ?? null,
|
|
157
|
+
legacy: {
|
|
158
|
+
runs: (await listIntegrationRuns(kv as unknown as ReleaseModelKv, tenantId, "preview")).map((r) => ({ runId: r.runId, state: r.state })),
|
|
159
|
+
op: await (async () => {
|
|
160
|
+
const raw = await kv.get(legacyOperationKey(tenantId, { operation: "publication", subjectId: "op-stale" }));
|
|
161
|
+
return raw ? ((JSON.parse(raw) as { problem?: { code?: string } | null }).problem?.code ?? "open") : null;
|
|
162
|
+
})(),
|
|
163
|
+
},
|
|
164
|
+
preview: {
|
|
165
|
+
pointer: fake.previewPointer,
|
|
166
|
+
readModel: fake.readModel,
|
|
167
|
+
merges: fake.merges.map((m) => ({ changeId: m.changeId, sha: m.sha })),
|
|
168
|
+
commits: this.ctx.storage.sql
|
|
169
|
+
.exec<{ sha: string; membership: string }>(`SELECT sha, membership FROM commits ORDER BY sha`)
|
|
170
|
+
.toArray()
|
|
171
|
+
.map((r) => ({ sha: r.sha, membership: JSON.parse(r.membership) as unknown })),
|
|
172
|
+
evidence: this.ctx.storage.sql
|
|
173
|
+
.exec<{ sha: string; digest: string }>(`SELECT sha, digest FROM evidence ORDER BY sha`)
|
|
174
|
+
.toArray(),
|
|
175
|
+
},
|
|
176
|
+
};
|
|
45
177
|
}
|
|
46
178
|
|
|
47
179
|
/**
|
|
@@ -123,13 +255,46 @@ export class TenantPipeline extends DeployedTenantPipeline {
|
|
|
123
255
|
Number(args.times ?? 1),
|
|
124
256
|
);
|
|
125
257
|
return null;
|
|
258
|
+
case "seedPointers":
|
|
259
|
+
// What the shared preview and the live site are served from, as a legacy store left them.
|
|
260
|
+
await this.pointers.update(tenantId, (current) => ({
|
|
261
|
+
...current,
|
|
262
|
+
channels: { ...current.channels, preview: String(args.preview), live: String(args.live) },
|
|
263
|
+
channelRevs: { ...current.channelRevs, preview: (current.channelRevs?.preview ?? 0) + 1, live: (current.channelRevs?.live ?? 0) + 1 },
|
|
264
|
+
}));
|
|
265
|
+
return null;
|
|
266
|
+
case "seedLegacy": {
|
|
267
|
+
// A request-scoped run and an operation that never finished.
|
|
268
|
+
const at = "2026-10-09T00:00:00.000Z";
|
|
269
|
+
await writeIntegrationRun(
|
|
270
|
+
kv as unknown as ReleaseModelKv,
|
|
271
|
+
{
|
|
272
|
+
tenantId,
|
|
273
|
+
aggregateId: "preview",
|
|
274
|
+
runId: String(args.runId),
|
|
275
|
+
aggregateSha: String(args.sha),
|
|
276
|
+
includedPrs: [],
|
|
277
|
+
state: "running",
|
|
278
|
+
previousRunId: null,
|
|
279
|
+
artifactDigest: null,
|
|
280
|
+
startedAt: at,
|
|
281
|
+
finishedAt: null,
|
|
282
|
+
statusMessage: null,
|
|
283
|
+
},
|
|
284
|
+
0,
|
|
285
|
+
);
|
|
286
|
+
const op = { operation: "publication" as const, subjectId: String(args.opSubject) };
|
|
287
|
+
await kv.put(
|
|
288
|
+
legacyOperationKey(tenantId, op),
|
|
289
|
+
JSON.stringify({ tenantId, ...op, revision: String(args.sha), stage: "publishing", problem: null, attemptsMade: 1, updatedAt: at, rev: 0 }),
|
|
290
|
+
);
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
126
293
|
case "armFault":
|
|
127
294
|
armPublishFault(this.ctx, args as unknown as PublishFault);
|
|
128
295
|
return null;
|
|
129
296
|
case "firedFaults":
|
|
130
297
|
return firedPublishFaults(this.ctx);
|
|
131
|
-
case "armedAtFaults":
|
|
132
|
-
return armedAtFaults(this.ctx);
|
|
133
298
|
default:
|
|
134
299
|
throw new Error(`unknown publish harness op ${op}`);
|
|
135
300
|
}
|