History

Pre-warm the app plane on requests that do not need it (#2072)

Rhys Sullivan committed 1 month ago

Commit 1aa6fd91 parent c6d10831 file · +64 −10Browse files

Unified · Split

apps/cloud/src/server.ts+64−10
@@ -243,19 +243,64 @@ const markStartGraphEntered = (): void => { · 242 unmodified lines
243243 // `servedByAppPlane` (./app-paths) decides which paths qualify — two under
244244 // `/api` are claimed by Start's middleware first and must keep their old route.
245245
246−// Instantiated on the first request that needs it and memoized per isolate,
247−// mirroring `start.ts`'s `getApp`. The import stays dynamic so an isolate that
248−// only serves pages or proxies never evaluates the app graph at all.
249−let appPlane: ReturnType<typeof import("./app").cloudApiHandler> | undefined;
246+// Instantiated once per isolate and memoized as a promise, mirroring
247+// `start.ts`'s `getApp`. The import stays dynamic so the Worker's static
248+// startup closure does not include the app graph; the promise memo means a
249+// pre-warm and a real request racing on a fresh isolate share one import.
250+type AppPlane = ReturnType<typeof import("./app").cloudApiHandler>;
251+let appPlanePromise: Promise<AppPlane> | undefined;
250252 let appGraphEntered = false;
251253
252−const getAppPlane = async (): Promise<NonNullable<typeof appPlane>> => {
253− if (appPlane === undefined) {
254− const { cloudApiHandler } = await import("./app");
255− appPlane = cloudApiHandler();
256− appGraphEntered = true;
254+const getAppPlane = (): Promise<AppPlane> => {
255+ if (appPlanePromise === undefined) {
256+ appPlanePromise = import("./app").then(
257+ ({ cloudApiHandler }) => {
258+ const plane = cloudApiHandler();
259+ appGraphEntered = true;
260+ return plane;
261+ },
262+ (cause: unknown) => {
263+ // Do not memoize a failure: the next request re-imports, as the
264+ // un-memoized version did, instead of failing every request after.
265+ appPlanePromise = undefined;
266+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: re-raise the import failure to the awaiting request
267+ throw cause;
268+ },
269+ );
257270 }
258− return appPlane;
271+ return appPlanePromise;
272+};
273+
274+// ---------------------------------------------------------------------------
275+// Pre-warming the app plane.
276+// ---------------------------------------------------------------------------
277+//
278+// Measured on production 2026-09-18: 30% of `/api/*` dispatches landed on an
279+// isolate that had not yet evaluated the app graph, and paid ~2s (p50) for it
280+// against ~100ms warm. Only 43% of those isolates were under 5s old. The rest
281+// had been alive for seconds to minutes serving `/mcp`, discovery documents,
282+// or proxies, none of which enter the app graph, so the dashboard's first
283+// call was the one that paid. Isolates live about a minute at the median, so
284+// there is rarely a second dashboard request to benefit.
285+//
286+// So any request that does NOT need the app plane starts its import in the
287+// background. The request itself returns as before; the import runs under
288+// `waitUntil`, so the isolate stays up until it finishes. A dashboard call
289+// arriving afterwards finds the graph evaluated. The truly fresh isolate
290+// (first request IS a dashboard call) still pays; that cost is the graph's
291+// evaluation itself, addressed separately.
292+//
293+// Failure is swallowed on purpose: a pre-warm that fails must not fail the
294+// request that triggered it, and the next real app-plane request re-imports
295+// through the same memo and surfaces the error where it belongs.
296+const prewarmAppPlane = (ctx: ExecutionContext): void => {
297+ if (appGraphEntered) return;
298+ ctx.waitUntil(
299+ getAppPlane().then(
300+ () => undefined,
301+ () => undefined,
302+ ),
303+ );
259304 };
260305
261306 const cloudflareHandler: ExportedHandler<Env> = {
@@ -266,6 +311,12 @@ const cloudflareHandler: ExportedHandler<Env> = { · 4 unmodified lines
266311 // import loads the entire React + Effect server graph and can take seconds
267312 // on a cold isolate. Classify and service-bind marketing at the Worker
268313 // entry, before telemetry or fetchHandler touches that graph.
314+ // Everything that returns before the app-plane dispatch below leaves the
315+ // graph unevaluated for the next request; warm it in the background.
316+ if (!servedByAppPlane(new URL(request.url).pathname, request.method)) {
317+ prewarmAppPlane(ctx);
318+ }
319+
269320 const marketingRequest = marketingProxyRequest(request);
270321 const marketing: Fetcher | undefined = env.MARKETING;
271322 if (marketingRequest && marketing) return marketing.fetch(marketingRequest);
@@ -445,6 +496,9 @@ const cloudflareHandler: ExportedHandler<Env> = { · 173 unmodified lines
445496 // isolate goes idle.
446497 scheduled: async (_controller, _env, ctx) => {
447498 installTracerProvider();
499+ // The cron fires every minute, often on an isolate that has served no
500+ // dashboard request yet: the cheapest pre-warm there is.
501+ prewarmAppPlane(ctx);
448502 await runWorkOsEventsSync();
449503 ctx.waitUntil(flushTracerProvider());
450504 },