Skip to content

Commit 56c325a

Browse files
fix(cloudflare): retry catalog load after failure (#255)
## Summary - clear rejected Cloudflare app and catalog promises so a recovered asset binding is retried - evict only the promise that is still cached, preserving concurrent request deduplication - cover concurrent failure sharing and a successful retry through `worker.fetch` Fixes #246 ## Validation - `npx vitest run src/server/cloudflare.test.ts` (5 tests) - `npm run fix-check` - `npm test` (66 files, 704 tests) - `npm run lint` - `npm run format` - `git diff --check` --------- Co-authored-by: cyphercodes <cyphercodes@users.noreply.github.com> Co-authored-by: Kevin Cui <bh@bugs.cc>
1 parent deadc1c commit 56c325a

4 files changed

Lines changed: 219 additions & 18 deletions

File tree

src/server/cloudflare.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,49 @@ describe("cloudflare worker", () => {
2525
vi.restoreAllMocks();
2626
});
2727

28+
it("deduplicates concurrent app creation and retries after a transient catalog failure", async () => {
29+
// The app and catalog caches live in module state, so this test needs an isolate of its own:
30+
// the shared `worker` above has already cached a healthy catalog. The reimported copy also
31+
// gets a fresh `guarded-fetch` module, which `vitest.setup.ts` no longer holds off real DNS —
32+
// keep this instance on local routes that perform no provider egress.
33+
vi.resetModules();
34+
const { default: isolatedWorker } = await import("./cloudflare.ts");
35+
const fallback = memoryAssets(chunkedCatalog());
36+
let indexAttempts = 0;
37+
const assets: AssetsBinding = {
38+
async fetch(request) {
39+
if (new URL(request.url).pathname === "/catalog/index.json" && ++indexAttempts === 1) {
40+
return new Response("upstream", { status: 500 });
41+
}
42+
return fallback.fetch(request);
43+
},
44+
};
45+
const env: CloudflareEnv = {
46+
DB: new UnusedD1Database(),
47+
TRANSIT_FILES: new UnusedR2Bucket(),
48+
ASSETS: assets,
49+
};
50+
const request = (): Request => new Request("https://catalog-retry.example.com/api/auth/session");
51+
const failures = await Promise.allSettled([
52+
isolatedWorker.fetch(request(), env, createExecutionContext()),
53+
isolatedWorker.fetch(request(), env, createExecutionContext()),
54+
]);
55+
56+
for (const failure of failures) {
57+
expect(failure).toMatchObject({
58+
status: "rejected",
59+
reason: {
60+
message: "Cloudflare asset catalog request failed: /catalog/index.json returned 500",
61+
},
62+
});
63+
}
64+
expect(indexAttempts).toBe(1);
65+
66+
const response = await isolatedWorker.fetch(request(), env, createExecutionContext());
67+
expect(response.status).toBe(200);
68+
expect(indexAttempts).toBe(2);
69+
});
70+
2871
it("writes connection logs to console", async () => {
2972
const info = vi.spyOn(console, "info").mockImplementation(() => {});
3073
const response = await worker.fetch(

src/server/cloudflare.ts

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { executorModules } from "../providers/registry.cloudflare.generated.ts";
1212
import { isConsoleShellPath } from "./api/console-paths.ts";
1313
import { loadCatalogFromAssets } from "./cloudflare/catalog-assets.ts";
1414
import { readPositiveInteger, resolvePublicOrigin } from "./cloudflare/cloudflare-env.ts";
15+
import { IsolatePromiseCache } from "./cloudflare/isolate-promise-cache.ts";
1516
import { createConnectApp } from "./connect-app.ts";
1617
import { KVTransitFileService } from "./files/kv-transit-files.ts";
1718
import { R2TransitFileService } from "./files/r2-transit-files.ts";
@@ -24,20 +25,15 @@ interface CloudflareExecutionContext {
2425
passThroughOnException(): void;
2526
}
2627

27-
let catalogPromise: Promise<CatalogStore> | undefined;
28-
let cachedSecretCodec: { key: string; codec: Promise<ISecretCodec> } | undefined;
29-
let cachedApp: { key: string; app: Promise<ConnectApp> } | undefined;
28+
const catalogCache = new IsolatePromiseCache<CatalogStore>();
29+
const secretCodecCache = new IsolatePromiseCache<ISecretCodec>();
30+
const appCache = new IsolatePromiseCache<ConnectApp>();
3031

3132
export default {
3233
async fetch(request: Request, env: CloudflareEnv, _ctx: CloudflareExecutionContext): Promise<Response> {
3334
setPrivateNetworkAccessAllowed(parsePrivateNetworkAccessFlag(env.OOMOL_CONNECT_ALLOW_PRIVATE_NETWORK));
3435
const publicOrigin = resolvePublicOrigin(request, env);
35-
const cacheKey = createCacheKey(env, publicOrigin);
36-
if (!cachedApp || cachedApp.key !== cacheKey) {
37-
cachedApp = { key: cacheKey, app: createCloudflareApp(env, publicOrigin) };
38-
}
39-
40-
const { app } = await cachedApp.app;
36+
const { app } = await appCache.get(createCacheKey(env, publicOrigin), () => createCloudflareApp(env, publicOrigin));
4137
const response = await app.fetch(request, env);
4238
if (response.status === 404 && env.ASSETS && shouldServeAsset(request)) {
4339
return env.ASSETS.fetch(request);
@@ -119,18 +115,17 @@ function writeWorkerLog(level: "error" | "info" | "warn"): (fields: unknown, mes
119115
}
120116

121117
function loadCatalogOnce(assets: AssetsBinding): Promise<CatalogStore> {
122-
catalogPromise ??= loadCatalogFromAssets(assets, {
123-
executableServices: Object.keys(executorModules),
124-
});
125-
return catalogPromise;
118+
// The catalog depends only on the assets binding, which is fixed for the isolate, so one slot
119+
// under a constant key covers every request.
120+
return catalogCache.get("", () =>
121+
loadCatalogFromAssets(assets, {
122+
executableServices: Object.keys(executorModules),
123+
}),
124+
);
126125
}
127126

128127
function createSecretCodec(encryptionKey: string | undefined): Promise<ISecretCodec> {
129-
const key = encryptionKey ?? "";
130-
if (!cachedSecretCodec || cachedSecretCodec.key !== key) {
131-
cachedSecretCodec = { key, codec: createWorkerSecretCodec(encryptionKey) };
132-
}
133-
return cachedSecretCodec.codec;
128+
return secretCodecCache.get(encryptionKey ?? "", () => createWorkerSecretCodec(encryptionKey));
134129
}
135130

136131
function createCacheKey(env: CloudflareEnv, publicOrigin: string): string {
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { describe, expect, it } from "vitest";
2+
import { IsolatePromiseCache } from "./isolate-promise-cache.ts";
3+
4+
describe("IsolatePromiseCache", () => {
5+
it("creates once per key and reuses the resolved promise", async () => {
6+
const cache = new IsolatePromiseCache<string>();
7+
let creations = 0;
8+
const create = async (): Promise<string> => {
9+
creations++;
10+
return "value";
11+
};
12+
13+
await expect(cache.get("k", create)).resolves.toBe("value");
14+
await expect(cache.get("k", create)).resolves.toBe("value");
15+
expect(creations).toBe(1);
16+
});
17+
18+
it("shares one in-flight promise between concurrent callers", async () => {
19+
const cache = new IsolatePromiseCache<string>();
20+
let creations = 0;
21+
let release = (): void => {};
22+
const create = (): Promise<string> => {
23+
creations++;
24+
return new Promise<string>((resolve) => {
25+
release = () => resolve("value");
26+
});
27+
};
28+
29+
const first = cache.get("k", create);
30+
const second = cache.get("k", create);
31+
expect(first).toBe(second);
32+
release();
33+
await expect(Promise.all([first, second])).resolves.toEqual(["value", "value"]);
34+
expect(creations).toBe(1);
35+
});
36+
37+
it("evicts a rejected promise so the next caller retries", async () => {
38+
const cache = new IsolatePromiseCache<string>();
39+
let attempts = 0;
40+
const create = async (): Promise<string> => {
41+
attempts++;
42+
if (attempts === 1) {
43+
throw new Error("transient");
44+
}
45+
return "recovered";
46+
};
47+
48+
await expect(cache.get("k", create)).rejects.toThrow("transient");
49+
await expect(cache.get("k", create)).resolves.toBe("recovered");
50+
expect(attempts).toBe(2);
51+
});
52+
53+
it("keeps rejecting concurrent callers of the failed attempt, then retries once", async () => {
54+
const cache = new IsolatePromiseCache<string>();
55+
let attempts = 0;
56+
const create = async (): Promise<string> => {
57+
attempts++;
58+
if (attempts === 1) {
59+
throw new Error("transient");
60+
}
61+
return "recovered";
62+
};
63+
64+
const settled = await Promise.allSettled([cache.get("k", create), cache.get("k", create)]);
65+
expect(settled.map((result) => result.status)).toEqual(["rejected", "rejected"]);
66+
expect(attempts).toBe(1);
67+
await expect(cache.get("k", create)).resolves.toBe("recovered");
68+
expect(attempts).toBe(2);
69+
});
70+
71+
it("replaces the slot when the key changes", async () => {
72+
const cache = new IsolatePromiseCache<string>();
73+
const create = (value: string) => async (): Promise<string> => value;
74+
75+
await expect(cache.get("a", create("first"))).resolves.toBe("first");
76+
await expect(cache.get("b", create("second"))).resolves.toBe("second");
77+
await expect(cache.get("a", create("third"))).resolves.toBe("third");
78+
});
79+
80+
it("does not evict a newer key when an older promise rejects afterwards", async () => {
81+
const cache = new IsolatePromiseCache<string>();
82+
let failOld = (): void => {};
83+
const oldEntry = cache.get(
84+
"old",
85+
() =>
86+
new Promise<string>((_resolve, reject) => {
87+
failOld = () => reject(new Error("stale"));
88+
}),
89+
);
90+
let newCreations = 0;
91+
const createNew = async (): Promise<string> => {
92+
newCreations++;
93+
return "fresh";
94+
};
95+
96+
await expect(cache.get("new", createNew)).resolves.toBe("fresh");
97+
failOld();
98+
await expect(oldEntry).rejects.toThrow("stale");
99+
100+
// The rejection belongs to the replaced entry, so the live "new" slot must still be memoized.
101+
await expect(cache.get("new", createNew)).resolves.toBe("fresh");
102+
expect(newCreations).toBe(1);
103+
});
104+
105+
it("does not report an unhandled rejection when nobody awaits the evicted promise", async () => {
106+
const cache = new IsolatePromiseCache<string>();
107+
const unhandled: unknown[] = [];
108+
const onUnhandled = (reason: unknown): void => {
109+
unhandled.push(reason);
110+
};
111+
process.on("unhandledRejection", onUnhandled);
112+
try {
113+
const rejected = cache.get("k", async () => {
114+
throw new Error("transient");
115+
});
116+
await expect(rejected).rejects.toThrow("transient");
117+
await new Promise((resolve) => setImmediate(resolve));
118+
await new Promise((resolve) => setImmediate(resolve));
119+
expect(unhandled).toEqual([]);
120+
} finally {
121+
process.off("unhandledRejection", onUnhandled);
122+
}
123+
});
124+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* A one-slot promise cache scoped to a Workers isolate.
3+
*
4+
* An isolate is reused across many requests, so boot work is memoized in module state. Memoizing a
5+
* *rejected* promise turns one transient failure into sustained errors until the isolate is
6+
* recycled, with no self-healing in between. Each slot therefore drops itself as soon as its
7+
* promise rejects, so the next request retries, while concurrent callers still share the in-flight
8+
* promise and do the work once.
9+
*/
10+
export class IsolatePromiseCache<T> {
11+
private entry: IsolateCacheEntry<T> | undefined;
12+
13+
/**
14+
* Return the cached promise for `key`, creating and memoizing it when the slot holds another key
15+
* or is empty. `create` runs synchronously on a miss, so a burst of concurrent callers arriving
16+
* before the first one settles all share the same promise.
17+
*/
18+
get(key: string, create: () => Promise<T>): Promise<T> {
19+
if (this.entry?.key === key) {
20+
return this.entry.value;
21+
}
22+
23+
const entry: IsolateCacheEntry<T> = { key, value: create() };
24+
this.entry = entry;
25+
// Evict only while this entry still owns the slot: a different key may have replaced it while
26+
// the promise was in flight, and that newer entry must survive.
27+
void entry.value.catch(() => {
28+
if (this.entry === entry) {
29+
this.entry = undefined;
30+
}
31+
});
32+
return entry.value;
33+
}
34+
}
35+
36+
interface IsolateCacheEntry<T> {
37+
key: string;
38+
value: Promise<T>;
39+
}

0 commit comments

Comments
 (0)