Skip to content

Commit eb2c85c

Browse files
committed
Keep armed faults across Durable Object eviction
Hosted instances kept faults only in memory. When Cloudflare replaced an instance, its armed faults and fault counter were gone while the store and ledger came back, so a fault armed for three requests read as spent after one. Persist faults with the rest of the instance, and store a consumed count before a delayed fault's response.
1 parent b2929ce commit eb2c85c

8 files changed

Lines changed: 112 additions & 9 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ The request ledger is a core feature, not a debug afterthought. Each entry recor
7373

7474
### Fault injection
7575

76-
Tests and development harnesses can arm one-shot faults with `POST /_emulate/faults`. A fault matches all provided criteria: `operationId` when the emulator can resolve one from its service manifest, `method`, and `pathPattern`. `pathPattern` uses a simple glob where `*` matches any characters in the request path. Matching requests short-circuit with the configured response, decrement `remaining`, and are removed at zero. Faulted requests are still written to `/_emulate/ledger` with `faulted: true` and `faultId`.
76+
Tests and development harnesses can arm one-shot faults with `POST /_emulate/faults`. A fault matches all provided criteria: `operationId` when the emulator can resolve one from its service manifest, `method`, and `pathPattern`. `pathPattern` uses a simple glob where `*` matches any characters in the request path. Matching requests short-circuit with the configured response, decrement `remaining`, and are removed at zero. Faulted requests are still written to `/_emulate/ledger` with `faulted: true` and `faultId`. On the hosted Cloudflare surface armed faults, their remaining counts and the next fault id are persisted with the instance, so a fault keeps its count when Cloudflare replaces the instance between arming it and the requests it targets.
7777

7878
```bash
7979
curl -s -X POST "$EMULATOR_URL/_emulate/faults" \
@@ -213,7 +213,7 @@ Documentation is a separate site at `https://docs.emulators.dev`. Per-service do
213213

214214
### Deployment
215215

216-
The emulator worker is named `emulate-hosts`. It serves `emulators.dev/*` (the apex catalog) and `*.emulators.dev/*`, that is every service and instance subdomain. The `EmulatorDurableObject` class is declared through a wrangler `migrations` entry (`new_classes`). Each stateful instance is backed by one `EmulatorDurableObject`; both the store snapshot and the request ledger are persisted to Durable Object storage so instances survive eviction.
216+
The emulator worker is named `emulate-hosts`. It serves `emulators.dev/*` (the apex catalog) and `*.emulators.dev/*`, that is every service and instance subdomain. The `EmulatorDurableObject` class is declared through a wrangler `migrations` entry (`new_classes`). Each stateful instance is backed by one `EmulatorDurableObject`; the store snapshot, the request ledger and armed faults are persisted to Durable Object storage so instances survive eviction.
217217

218218
The docs site (`apps/web`, worker name `emulate-docs`) serves `docs.emulators.dev`. Because that host is more specific than `*.emulators.dev`, it wins for docs traffic.
219219

‎apps/web/app/docs/architecture/page.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,6 @@ The `@emulators/cloudflare` package runs the same plugins on Cloudflare Workers
8585
- A service host such as `github.emulators.dev` serves a service-level control plane and is useful before any instance exists. It has no shared instance of its own; provider traffic requires a created instance.
8686
- An instance host such as `github.my-run.emulators.dev` (or the compatibility path form `emulators.dev/github/my-run`) serves one stateful instance.
8787

88-
Each instance is backed by one Cloudflare Durable Object. The `EmulatorDurableObject` class is declared through a wrangler `migrations` entry (`new_classes`), and both the instance state and the request ledger are snapshotted to Durable Object storage so an instance survives eviction. Instances are created lazily through `POST /_emulate/instances`.
88+
Each instance is backed by one Cloudflare Durable Object. The `EmulatorDurableObject` class is declared through a wrangler `migrations` entry (`new_classes`), and the instance state, the request ledger and armed faults are snapshotted to Durable Object storage so an instance survives eviction. Instances are created lazily through `POST /_emulate/instances`.
8989

9090
Docs are a separate site on the `emulate-docs` worker at `docs.emulators.dev`, with per-service docs at `https://docs.emulators.dev/<service>`. Because that host is more specific than the apex, it wins for the docs subdomain. See [Deployment](/docs/deployment).

‎apps/web/app/docs/deployment/page.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ A bare service host such as `https://github.emulators.dev`, with no instance, se
7777

7878
## Instance model
7979

80-
The `emulate-hosts` worker serves `emulators.dev/*` (the apex catalog) and `*.emulators.dev/*` (service and instance hosts). Each stateful instance is backed by one Cloudflare Durable Object. The `EmulatorDurableObject` class is declared through a wrangler `migrations` entry (`new_classes`), and both the instance state and the request ledger are snapshotted to Durable Object storage, so an instance survives eviction.
80+
The `emulate-hosts` worker serves `emulators.dev/*` (the apex catalog) and `*.emulators.dev/*` (service and instance hosts). Each stateful instance is backed by one Cloudflare Durable Object. The `EmulatorDurableObject` class is declared through a wrangler `migrations` entry (`new_classes`), and the instance state, the request ledger and armed faults are snapshotted to Durable Object storage, so an instance survives eviction.
8181

8282
```text
8383
POST https://github.emulators.dev/_emulate/instances

‎packages/@emulators/cloudflare/src/__tests__/worker.test.ts‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -598,6 +598,58 @@ describe("cloudflare durable object control plane", () => {
598598
expect(((await exchange.json()) as { error: string }).error).toBe("invalid_grant");
599599
});
600600

601+
// Executor's slow-Autumn e2e arms a delayed fault for three requests, sends one, gives up on
602+
// it, and reads the remaining count. Cloudflare can replace the instance at any point of
603+
// that, so the count and the ids must outlive it.
604+
it("keeps armed faults, their counts and their ids across eviction", async () => {
605+
const { storage, state } = makeState();
606+
const first = new EmulatorDurableObject(state, {});
607+
const arm = (durableObject: EmulatorDurableObject, delayMs: number) =>
608+
durableObject
609+
.fetch(
610+
new Request("https://github.my-run.emulators.dev/_emulate/faults", {
611+
method: "POST",
612+
headers: idHeaders({ "content-type": "application/json" }),
613+
body: JSON.stringify({
614+
match: { method: "GET", pathPattern: "/repos/*" },
615+
response: { status: 503, body: { message: "synthetic outage" } },
616+
times: 3,
617+
delayMs,
618+
}),
619+
}),
620+
)
621+
.then(async (res) => ((await res.json()) as { fault: { id: string } }).fault.id);
622+
const remaining = async (durableObject: EmulatorDurableObject) => {
623+
const res = await durableObject.fetch(
624+
new Request("https://github.my-run.emulators.dev/_emulate/faults", { headers: idHeaders() }),
625+
);
626+
const body = (await res.json()) as { faults: Array<{ id: string; remaining: number }> };
627+
return new Map(body.faults.map((fault) => [fault.id, fault.remaining]));
628+
};
629+
630+
const delayed = await arm(first, 300);
631+
// The caller abandons this request; its consumed count is stored before the delay ends.
632+
const pending = first.fetch(
633+
new Request("https://github.my-run.emulators.dev/repos/acme/widget", { headers: idHeaders() }),
634+
);
635+
for (let attempt = 0; attempt < 50; attempt++) {
636+
const stored = storage.get("faults") as { faults: Array<{ remaining: number }> } | undefined;
637+
if (stored?.faults[0]?.remaining === 2) break;
638+
await new Promise((resolve) => setTimeout(resolve, 5));
639+
}
640+
641+
const evicted = new EmulatorDurableObject(state, {});
642+
expect((await remaining(evicted)).get(delayed)).toBe(2);
643+
const next = await arm(evicted, 0);
644+
expect(next).not.toBe(delayed);
645+
const faulted = await evicted.fetch(
646+
new Request("https://github.my-run.emulators.dev/repos/acme/widget", { headers: idHeaders() }),
647+
);
648+
expect(faulted.status).toBe(503);
649+
expect((await remaining(evicted)).get(delayed)).toBe(1);
650+
expect((await pending).status).toBe(503);
651+
});
652+
601653
it("reports the real instance id in the manifest", async () => {
602654
const { state } = makeState();
603655
const durableObject = new EmulatorDurableObject(state, {});

‎packages/@emulators/cloudflare/src/durable-object.ts‎

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import {
22
createServer,
33
type AppKeyResolver,
4+
type FaultRegistry,
5+
type FaultRegistrySnapshot,
46
type LedgerEntry,
57
type LedgerSnapshot,
68
type RequestLedger,
@@ -51,6 +53,7 @@ interface Live {
5153
store: Store;
5254
tokenMap: TokenMap;
5355
ledger: RequestLedger;
56+
faults: FaultRegistry;
5457
service: string;
5558
instance: string;
5659
baseUrl: string;
@@ -65,6 +68,9 @@ const SNAPSHOT_DATA_PREFIX = "snapshot:data:";
6568
const LEDGER_META_KEY = "ledger:meta";
6669
const LEDGER_ENTRY_PREFIX = "ledger:entry:";
6770
const MINTED_PREFIX = "minted:";
71+
// Armed faults and the next fault id. Cloudflare can replace an instance at any time, even
72+
// between a test arming a fault and the request it targets, so faults persist with the store.
73+
const FAULTS_KEY = "faults";
6874

6975
interface SnapshotCollectionMeta {
7076
autoId: number;
@@ -308,6 +314,7 @@ export class EmulatorDurableObject {
308314
const strict = persisted.strict ?? true;
309315
const snapshot = await this.readStoreSnapshot(persisted.snapshot);
310316
const ledgerSnapshot = await this.readLedger(persisted.ledger);
317+
const faultSnapshot = await this.state.storage.get<FaultRegistrySnapshot>(FAULTS_KEY);
311318
const minted = await this.readMinted(persisted.minted);
312319
const tokens = this.buildTokens(persisted.seed, strict, minted);
313320

@@ -323,7 +330,7 @@ export class EmulatorDurableObject {
323330
: entry.defaultFallback(persisted.seed?.[service] as Record<string, unknown> | undefined);
324331

325332
let resetService = async () => {};
326-
const { app, store, tokenMap, webhooks, ledger } = createServer(entry.plugin, {
333+
const { app, store, tokenMap, webhooks, ledger, faults } = createServer(entry.plugin, {
327334
baseUrl,
328335
tokens,
329336
appKeyResolver,
@@ -332,6 +339,7 @@ export class EmulatorDurableObject {
332339
instance,
333340
ledgerPersistent: true,
334341
reset: () => resetService(),
342+
onFaultConsumed: () => this.persist(),
335343
seed: async (seed) => {
336344
if (seed && entry.seedFromConfig) {
337345
entry.seedFromConfig(store, baseUrl, seed as Record<string, unknown>);
@@ -360,6 +368,7 @@ export class EmulatorDurableObject {
360368
if (snapshot) store.restore(snapshot);
361369
else this.seedInto(store, service, baseUrl, persisted.seed);
362370
ledger.restore(ledgerSnapshot);
371+
faults.restore(faultSnapshot);
363372
resetService = async () => {
364373
store.reset();
365374
webhooks.clear();
@@ -379,7 +388,17 @@ export class EmulatorDurableObject {
379388
}
380389
}
381390

382-
this.live = { app, store, tokenMap: tokenMap as TokenMap, ledger, service, instance, baseUrl, reset: resetService };
391+
this.live = {
392+
app,
393+
store,
394+
tokenMap: tokenMap as TokenMap,
395+
ledger,
396+
faults,
397+
service,
398+
instance,
399+
baseUrl,
400+
reset: resetService,
401+
};
383402
if (entry.prepare) {
384403
// Persist prepared state now: a read-only first request would not persist it,
385404
// and a rebuild must not replace state a client has already observed.
@@ -408,6 +427,7 @@ export class EmulatorDurableObject {
408427
counter: ledger.counter,
409428
ids: ledger.entries.map((entry) => entry.id),
410429
} satisfies LedgerMeta);
430+
entries.set(FAULTS_KEY, live.faults.serialize());
411431
return entries;
412432
}
413433

@@ -416,7 +436,7 @@ export class EmulatorDurableObject {
416436
for (const prefix of [SNAPSHOT_ITEM_PREFIX, SNAPSHOT_DATA_PREFIX, LEDGER_ENTRY_PREFIX]) {
417437
for (const [key, value] of await this.state.storage.list({ prefix })) written.set(key, JSON.stringify(value));
418438
}
419-
for (const key of [SNAPSHOT_META_KEY, LEDGER_META_KEY]) {
439+
for (const key of [SNAPSHOT_META_KEY, LEDGER_META_KEY, FAULTS_KEY]) {
420440
const value = await this.state.storage.get(key);
421441
if (value !== undefined) written.set(key, JSON.stringify(value));
422442
}
@@ -469,6 +489,7 @@ export class EmulatorDurableObject {
469489
await this.clearStoreSnapshot();
470490
await this.clearLedger();
471491
await this.clearMinted();
492+
await this.state.storage.delete(FAULTS_KEY);
472493
await this.writeStateMeta({ seed, strict: strict !== false });
473494
this.live = undefined;
474495
this.written = undefined;

‎packages/@emulators/core/src/faults.ts‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,25 @@ export interface FaultLedgerMarker {
3737
faultId: string;
3838
}
3939

40+
/** Every armed fault and the next id, so a host can keep them across a restart. */
41+
export interface FaultRegistrySnapshot {
42+
counter: number;
43+
faults: ArmedFault[];
44+
}
45+
4046
export class FaultRegistry {
4147
private faults: ArmedFault[] = [];
4248
private counter = 1;
4349

50+
serialize(): FaultRegistrySnapshot {
51+
return { counter: this.counter, faults: this.faults.map(cloneFault) };
52+
}
53+
54+
restore(snapshot: FaultRegistrySnapshot | undefined): void {
55+
this.faults = (snapshot?.faults ?? []).map(cloneFault);
56+
this.counter = snapshot?.counter ?? 1;
57+
}
58+
4459
arm(input: FaultArmInput): ArmedFault {
4560
validateFaultInput(input);
4661
const fault: ArmedFault = {
@@ -87,7 +102,15 @@ export class FaultRegistry {
87102
}
88103
}
89104

90-
export function createFaultMiddleware(faults: FaultRegistry, manifest: ServiceManifest): MiddlewareHandler<AppEnv> {
105+
export function createFaultMiddleware(
106+
faults: FaultRegistry,
107+
manifest: ServiceManifest,
108+
/**
109+
* Awaited after a request consumes a fault and before its delay, so a host that persists
110+
* faults stores the new count before a delayed response, which its caller may abandon.
111+
*/
112+
onConsumed?: () => void | Promise<void>,
113+
): MiddlewareHandler<AppEnv> {
91114
const operations = manifest.specs.flatMap((spec) => spec.operations ?? []);
92115

93116
return async (c, next) => {
@@ -113,6 +136,7 @@ export function createFaultMiddleware(faults: FaultRegistry, manifest: ServiceMa
113136
}
114137

115138
c.set("fault", { faulted: true, faultId: fault.id });
139+
await onConsumed?.();
116140
if (fault.delayMs && fault.delayMs > 0) {
117141
await new Promise((resolve) => setTimeout(resolve, fault.delayMs));
118142
}

‎packages/@emulators/core/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export {
7979
type FaultArmInput,
8080
type FaultLedgerMarker,
8181
type FaultMatch,
82+
type FaultRegistrySnapshot,
8283
type FaultResponse,
8384
} from "./faults.js";
8485
export {

‎packages/@emulators/core/src/server.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ export interface ServerOptions {
3333
reset?: () => void | Promise<void>;
3434
seed?: (seed: unknown) => void | Promise<void>;
3535
issueCredential?: (request: CredentialRequest) => IssuedCredential | Promise<IssuedCredential>;
36+
/** Awaited after a request consumes a fault, before its delay (see `createFaultMiddleware`). */
37+
onFaultConsumed?: () => void | Promise<void>;
3638
}
3739

3840
export function createServer(plugin: ServicePlugin, options: ServerOptions = {}) {
@@ -90,7 +92,10 @@ export function createServer(plugin: ServicePlugin, options: ServerOptions = {})
9092
}
9193

9294
app.use("*", createLedgerMiddleware(ledger, { webhooks }));
93-
app.use("*", createFaultMiddleware(faults, options.manifest ?? createDefaultManifest(plugin.name)));
95+
app.use(
96+
"*",
97+
createFaultMiddleware(faults, options.manifest ?? createDefaultManifest(plugin.name), options.onFaultConsumed),
98+
);
9499

95100
const rateLimitCounters = new Map<string, { remaining: number; resetAt: number }>();
96101
let lastPruneAt = Math.floor(Date.now() / 1000);

0 commit comments

Comments
 (0)