Skip to content

Commit ba3b600

Browse files
committed
refactor(cloudflare): share one evict-on-rejection promise cache
The app and catalog fixes each open-coded the same slot: memoize a promise, then drop it if it rejects so the isolate self-heals. A third slot in the same file, `cachedSecretCodec`, kept the original memoize-the-rejection shape, so a codec failure still poisons the isolate for its lifetime even though the app around it now retries. Route all three through `IsolatePromiseCache`, which memoizes one promise per key, hands the in-flight promise to concurrent callers, and clears the slot on rejection. Net effect: the third slot is fixed and the invariant lives in one place instead of being restated per caller. Unit-test the cache directly. The worker-level regression test cannot reach the guard that lets only the owning entry clear the slot, since that path needs an older promise to reject after a newer key replaced it; dropping the guard leaves `cloudflare.test.ts` green.
1 parent a4b5c30 commit ba3b600

4 files changed

Lines changed: 180 additions & 36 deletions

File tree

src/server/cloudflare.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ describe("cloudflare worker", () => {
2626
});
2727

2828
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.
2933
vi.resetModules();
3034
const { default: isolatedWorker } = await import("./cloudflare.ts");
3135
const fallback = memoryAssets(chunkedCatalog());

src/server/cloudflare.ts

Lines changed: 13 additions & 36 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,28 +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-
let appPromise = cachedApp?.key === cacheKey ? cachedApp.app : undefined;
37-
if (!appPromise) {
38-
const createdApp = createCloudflareApp(env, publicOrigin);
39-
cachedApp = { key: cacheKey, app: createdApp };
40-
void createdApp.catch(() => {
41-
if (cachedApp?.app === createdApp) {
42-
cachedApp = undefined;
43-
}
44-
});
45-
appPromise = createdApp;
46-
}
47-
48-
const { app } = await appPromise;
36+
const { app } = await appCache.get(createCacheKey(env, publicOrigin), () => createCloudflareApp(env, publicOrigin));
4937
const response = await app.fetch(request, env);
5038
if (response.status === 404 && env.ASSETS && shouldServeAsset(request)) {
5139
return env.ASSETS.fetch(request);
@@ -127,28 +115,17 @@ function writeWorkerLog(level: "error" | "info" | "warn"): (fields: unknown, mes
127115
}
128116

129117
function loadCatalogOnce(assets: AssetsBinding): Promise<CatalogStore> {
130-
if (catalogPromise) {
131-
return catalogPromise;
132-
}
133-
134-
const createdCatalog = loadCatalogFromAssets(assets, {
135-
executableServices: Object.keys(executorModules),
136-
});
137-
catalogPromise = createdCatalog;
138-
void createdCatalog.catch(() => {
139-
if (catalogPromise === createdCatalog) {
140-
catalogPromise = undefined;
141-
}
142-
});
143-
return createdCatalog;
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+
);
144125
}
145126

146127
function createSecretCodec(encryptionKey: string | undefined): Promise<ISecretCodec> {
147-
const key = encryptionKey ?? "";
148-
if (!cachedSecretCodec || cachedSecretCodec.key !== key) {
149-
cachedSecretCodec = { key, codec: createWorkerSecretCodec(encryptionKey) };
150-
}
151-
return cachedSecretCodec.codec;
128+
return secretCodecCache.get(encryptionKey ?? "", () => createWorkerSecretCodec(encryptionKey));
152129
}
153130

154131
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)