@tokenoftrust/storefront-runner 2.4.6 → 2.4.8
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/migrations-apps/0014_retire_candidate_ledger.sql +12 -0
- package/apps/storefront/package.json +1 -1
- package/apps/storefront/src/lib/d1/schema-apps.ts +0 -88
- package/apps/storefront/test/pipeline/fake-preview-services.ts +25 -15
- 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 +108 -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
- package/packages/public-runtime/src/candidate-index.ts +1 -2
|
@@ -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
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// The workerd pool both pipeline suites run in (@cloudflare/vitest-pool-workers): the TenantPipeline
|
|
2
|
+
// Durable Object is exercised with real SQLite storage, real alarms and real eviction
|
|
3
|
+
// (`ctx.abort()`). The Node unit suite (vitest.config.cts) never picks these tests up.
|
|
4
|
+
//
|
|
5
|
+
// The object lives in its own Worker, `pipeline-host`, not in the test runner's isolate: an
|
|
6
|
+
// eviction must restart the object without touching its callers, as in production. Auxiliary
|
|
7
|
+
// Workers take plain modules, so the host is bundled here, in memory, with esbuild.
|
|
8
|
+
//
|
|
9
|
+
// vitest.workers.config.cts test/pipeline/** the hand-written harness tests, one at a time
|
|
10
|
+
// vitest.faults.config.cts test/pipeline-faults/** the systematic fault matrix, cells concurrent
|
|
11
|
+
const { defineWorkersConfig } = require("@cloudflare/vitest-pool-workers/config");
|
|
12
|
+
const esbuild = require("esbuild");
|
|
13
|
+
const path = require("node:path");
|
|
14
|
+
|
|
15
|
+
const compatibilityDate = "2024-11-01";
|
|
16
|
+
const compatibilityFlags = ["nodejs_compat"];
|
|
17
|
+
const HOST = "pipeline-host";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {{ root: string; name: string; include: string[]; maxConcurrency?: number; testTimeout?: number }} options
|
|
21
|
+
*/
|
|
22
|
+
function pipelinePoolConfig({ root, name, include, maxConcurrency, testTimeout = 60_000 }) {
|
|
23
|
+
const hostModule = esbuild.buildSync({
|
|
24
|
+
entryPoints: [path.join(root, "test/pipeline/pipeline-host.ts")],
|
|
25
|
+
bundle: true,
|
|
26
|
+
format: "esm",
|
|
27
|
+
platform: "neutral",
|
|
28
|
+
target: "es2022",
|
|
29
|
+
external: ["cloudflare:*"],
|
|
30
|
+
write: false,
|
|
31
|
+
}).outputFiles[0].text;
|
|
32
|
+
|
|
33
|
+
const pipelineBinding = { TENANT_PIPELINE: { className: "TenantPipeline", scriptName: HOST } };
|
|
34
|
+
|
|
35
|
+
return defineWorkersConfig({
|
|
36
|
+
// The source tree's `@/` import alias (tsconfig `paths`), for modules the tests import directly.
|
|
37
|
+
resolve: { alias: { "@": path.join(root, "src") } },
|
|
38
|
+
test: {
|
|
39
|
+
name,
|
|
40
|
+
include,
|
|
41
|
+
cache: false,
|
|
42
|
+
testTimeout,
|
|
43
|
+
hookTimeout: 60_000,
|
|
44
|
+
...(maxConcurrency ? { maxConcurrency } : {}),
|
|
45
|
+
poolOptions: {
|
|
46
|
+
workers: {
|
|
47
|
+
isolatedStorage: false,
|
|
48
|
+
singleWorker: true,
|
|
49
|
+
miniflare: {
|
|
50
|
+
compatibilityDate,
|
|
51
|
+
compatibilityFlags,
|
|
52
|
+
durableObjects: pipelineBinding,
|
|
53
|
+
serviceBindings: { PIPELINE_HOST: HOST, INTAKE_B: "pipeline-intake-b" },
|
|
54
|
+
workers: [
|
|
55
|
+
{
|
|
56
|
+
name: HOST,
|
|
57
|
+
modules: [{ type: "ESModule", path: "pipeline-host.mjs", contents: hostModule }],
|
|
58
|
+
compatibilityDate,
|
|
59
|
+
compatibilityFlags,
|
|
60
|
+
durableObjects: { TENANT_PIPELINE: { className: "TenantPipeline", useSQLite: true } },
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: "pipeline-intake-b",
|
|
64
|
+
modules: true,
|
|
65
|
+
scriptPath: path.join(root, "test/pipeline/intake-worker.mjs"),
|
|
66
|
+
compatibilityDate,
|
|
67
|
+
compatibilityFlags,
|
|
68
|
+
durableObjects: pipelineBinding,
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = { pipelinePoolConfig };
|
|
@@ -26,18 +26,26 @@ import {
|
|
|
26
26
|
movePreview,
|
|
27
27
|
standInPublishEnvironment,
|
|
28
28
|
testVersionStore,
|
|
29
|
+
type CiDispatch,
|
|
29
30
|
type TestKv,
|
|
30
31
|
} from "../../src/lib/pipeline/publish/publish-test-support";
|
|
32
|
+
import type { Landed } from "./matrix-faults";
|
|
31
33
|
|
|
32
34
|
type SiteVersionStore = ReturnType<typeof testVersionStore>;
|
|
33
35
|
type PointerWriter = NonNullable<Parameters<typeof testVersionStore>[1]>;
|
|
36
|
+
type PointerRecord = Awaited<ReturnType<PointerWriter["read"]>>;
|
|
34
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Production's ratios, in seconds: a CI window (45 min) outlasts the lease an evicted object waits
|
|
40
|
+
* out before it resumes (10 min, here `leaseMs`), so a job resumed after an eviction is still inside
|
|
41
|
+
* its window — as in production.
|
|
42
|
+
*/
|
|
35
43
|
export const PUBLISH_HARNESS_TIMING: PublishTiming = {
|
|
36
|
-
awaitWindowMs:
|
|
44
|
+
awaitWindowMs: 4_000,
|
|
37
45
|
runGraceMs: 500,
|
|
38
46
|
proofDelayMs: 30,
|
|
39
47
|
proofProbes: { synchronous: 3, asynchronous: 5 },
|
|
40
|
-
stageWindowMs:
|
|
48
|
+
stageWindowMs: 4_000,
|
|
41
49
|
stageChecks: 3,
|
|
42
50
|
};
|
|
43
51
|
|
|
@@ -64,25 +72,6 @@ function tables(state: DurableObjectState): void {
|
|
|
64
72
|
sql.exec(`CREATE TABLE IF NOT EXISTS harness_kv (key TEXT PRIMARY KEY, value TEXT NOT NULL)`);
|
|
65
73
|
sql.exec(`CREATE TABLE IF NOT EXISTS harness_publish_faults (id INTEGER PRIMARY KEY AUTOINCREMENT, step TEXT, at TEXT, mode TEXT, remaining INTEGER, version_id TEXT)`);
|
|
66
74
|
sql.exec(`CREATE TABLE IF NOT EXISTS harness_publish_fired (seq INTEGER PRIMARY KEY AUTOINCREMENT, step TEXT, at TEXT, mode TEXT)`);
|
|
67
|
-
sql.exec(`CREATE TABLE IF NOT EXISTS harness_publish_armed (seq INTEGER PRIMARY KEY AUTOINCREMENT, step TEXT, at TEXT, armed INTEGER)`);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** At each fired fault: how many compensations the engine held armed for jobs not yet terminal. */
|
|
71
|
-
export function armedAtFaults(state: DurableObjectState): { step: string; at: string; armed: number }[] {
|
|
72
|
-
tables(state);
|
|
73
|
-
return state.storage.sql.exec<{ step: string; at: string; armed: number }>(`SELECT step, at, armed FROM harness_publish_armed ORDER BY seq`).toArray();
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function armedCompensations(state: DurableObjectState): number {
|
|
77
|
-
try {
|
|
78
|
-
return state.storage.sql
|
|
79
|
-
.exec<{ n: number }>(
|
|
80
|
-
`SELECT COUNT(*) AS n FROM terminal_hooks h JOIN jobs j ON j.job_id = h.job_id WHERE j.state NOT IN ('succeeded', 'needs-developer', 'stuck')`,
|
|
81
|
-
)
|
|
82
|
-
.one().n;
|
|
83
|
-
} catch {
|
|
84
|
-
return -1;
|
|
85
|
-
}
|
|
86
75
|
}
|
|
87
76
|
|
|
88
77
|
/** A KV over the object's SQLite: durable across evictions, visible to every later execution. */
|
|
@@ -133,7 +122,6 @@ async function maybeFault(state: DurableObjectState, step: string, at: "before"
|
|
|
133
122
|
if (!fault) return;
|
|
134
123
|
sql.exec(`UPDATE harness_publish_faults SET remaining = remaining - 1 WHERE id = ?`, fault.id);
|
|
135
124
|
sql.exec(`INSERT INTO harness_publish_fired (step, at, mode) VALUES (?, ?, ?)`, step, at, fault.mode);
|
|
136
|
-
sql.exec(`INSERT INTO harness_publish_armed (step, at, armed) VALUES (?, ?, ?)`, step, at, armedCompensations(state));
|
|
137
125
|
// Everything written so far — including a step's own effect, for an `after` fault — is made
|
|
138
126
|
// durable first, exactly as a real crash after the write would leave it.
|
|
139
127
|
await state.storage.sync();
|
|
@@ -182,11 +170,104 @@ export function publishWorldOf(state: DurableObjectState): { kv: TestKv; world:
|
|
|
182
170
|
return { kv, world: new PublishWorld(kv) };
|
|
183
171
|
}
|
|
184
172
|
|
|
185
|
-
/**
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
173
|
+
/** The world's logs that record an external effect, by the effect name the fault matrix uses. */
|
|
174
|
+
const NOTED_EFFECTS: Record<string, string> = {
|
|
175
|
+
"hotfix-merges": "forge:merge-to-main",
|
|
176
|
+
"forward-integrations": "forge:forward-integrate",
|
|
177
|
+
"staged-originals": "r2:stage-originals",
|
|
178
|
+
"went-live": "notice:went-live",
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/** The publish world, reporting every external effect a job makes to the fault matrix. */
|
|
182
|
+
class EffectWorld extends PublishWorld {
|
|
183
|
+
constructor(
|
|
184
|
+
kv: TestKv,
|
|
185
|
+
private readonly landed: Landed,
|
|
186
|
+
) {
|
|
187
|
+
super(kv);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
override async dispatch(dispatch: CiDispatch, at = 0): Promise<void> {
|
|
191
|
+
const before = (await this.dispatches()).length;
|
|
192
|
+
let lost: unknown = null;
|
|
193
|
+
try {
|
|
194
|
+
await super.dispatch(dispatch, at);
|
|
195
|
+
} catch (thrown) {
|
|
196
|
+
// A refused dispatch recorded nothing (no run exists); a lost response came after the run did.
|
|
197
|
+
if ((await this.dispatches()).length === before) throw thrown;
|
|
198
|
+
lost = thrown;
|
|
199
|
+
}
|
|
200
|
+
await this.landed(`ci:${dispatch.workflow}`);
|
|
201
|
+
if (lost) throw lost;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
override async recordDesignation(entry: { target: string; versionId: string; digest: string }): Promise<void> {
|
|
205
|
+
await super.recordDesignation(entry);
|
|
206
|
+
await this.landed("mcp:designate");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
override async note(key: string, value: unknown): Promise<void> {
|
|
210
|
+
await super.note(key, value);
|
|
211
|
+
const effect = NOTED_EFFECTS[key];
|
|
212
|
+
if (effect) await this.landed(effect);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The object's pointer writer, reporting each write that moved a channel (`pointer:<channel>`). */
|
|
217
|
+
function effectPointers(inner: PointerWriter, landed: Landed): PointerWriter {
|
|
218
|
+
return {
|
|
219
|
+
read: (tenantId) => inner.read(tenantId),
|
|
220
|
+
...(inner.peek ? { peek: (tenantId: string) => inner.peek!(tenantId) } : {}),
|
|
221
|
+
async update(tenantId, apply) {
|
|
222
|
+
let before: PointerRecord | null = null;
|
|
223
|
+
const after = await inner.update(tenantId, (current) => {
|
|
224
|
+
before = current;
|
|
225
|
+
return apply(current);
|
|
226
|
+
});
|
|
227
|
+
const prior = before as PointerRecord | null;
|
|
228
|
+
for (const channel of ["live", "preview"] as const) {
|
|
229
|
+
if (prior?.channels?.[channel] !== after.channels?.[channel] || prior?.channelRevs?.[channel] !== after.channelRevs?.[channel]) {
|
|
230
|
+
await landed(`pointer:${channel}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return after;
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The job's KV, reporting each write of the assets pointer (`assets:live`). */
|
|
239
|
+
function effectKv(kv: TestKv, landed: Landed): TestKv {
|
|
240
|
+
return {
|
|
241
|
+
get: (key) => kv.get(key),
|
|
242
|
+
put: async (key, value) => {
|
|
243
|
+
await kv.put(key, value);
|
|
244
|
+
if (key.startsWith("cust:assets-pointer:")) await landed("assets:live");
|
|
245
|
+
},
|
|
246
|
+
...(kv.delete ? { delete: (key: string) => kv.delete!(key) } : {}),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const noEffects: Landed = async () => {};
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Register the publish kinds (fault-wrapped, over the stand-in world) into `registry`; `pointers` is
|
|
254
|
+
* the object's authority. `landed` hears every external effect the jobs make (the fault matrix).
|
|
255
|
+
*/
|
|
256
|
+
export function registerHarnessPublishKinds(
|
|
257
|
+
state: DurableObjectState,
|
|
258
|
+
registry: JobKindRegistry,
|
|
259
|
+
pointers: PointerWriter,
|
|
260
|
+
landed: Landed = noEffects,
|
|
261
|
+
): JobKindRegistry {
|
|
262
|
+
const { kv } = publishWorldOf(state);
|
|
263
|
+
const store = testVersionStore(kv, effectPointers(pointers, landed));
|
|
264
|
+
const environment = standInPublishEnvironment({
|
|
265
|
+
kv: effectKv(kv, landed),
|
|
266
|
+
world: new EffectWorld(kv, landed),
|
|
267
|
+
store,
|
|
268
|
+
now: () => Date.now(),
|
|
269
|
+
timing: PUBLISH_HARNESS_TIMING,
|
|
270
|
+
});
|
|
190
271
|
const kinds = registerPublishJobKinds(new JobKindRegistry(), async () => environment, PUBLISH_HARNESS_TUNING);
|
|
191
272
|
for (const name of kinds.list()) registry.register(withFaults(state, kinds.get(name)!, store));
|
|
192
273
|
return registry;
|