History

Warm the Start graph by replaying a real request per isolate (#1686)

Isolates ARE reused - those serving a page request had already served a median of 19 spans across 3 paths - they are just never warm for Start, because /mcp returns before fetchHandler. Two import-based warmups (#1679, #1681) moved nothing: loadEntries awaits three specifiers plus a separately cached manifest, so warming a subset leaves the request paying for the rest. Drive the real handler instead; it populates whatever those caches are and cannot drift with Start's internals.

Rhys Sullivan committed 2 months ago

Commit 46625d21 parent aff1f392 files · +64 −0Browse files

Unified · Split

2 files
  1. apps/cloud/src/env-augment.d.ts +2 −0
  2. apps/cloud/src/server.ts +62 −0
apps/cloud/src/env-augment.d.ts+2−0
@@ -95,6 +95,8 @@ declare global { · 94 unmodified lines
9595 MCP_REQUEST_STATE_KEY?: string;
9696 /** Emergency rollback for inbound MCP 2026-07-28 traffic only. */
9797 MCP_2026_07_28_ENABLED?: string;
98+ // Kill switch for the Start server-graph warmup (server.ts).
99+ START_GRAPH_WARM?: string;
98100 NODE_ENV?: string;
99101
100102 // Shared with frontend
apps/cloud/src/server.ts+62−0
@@ -178,8 +178,70 @@ const mcpAgentHandler = makeCloudMcpAgentHandler({ · 177 unmodified lines
178178 traceRequest: traceCloudMcpRequest,
179179 });
180180
181+// ---------------------------------------------------------------------------
182+// Start server-graph warmup
183+// ---------------------------------------------------------------------------
184+//
185+// Page latency was p50 19-49ms daily through 2026-08-16 and 2728ms the day
186+// after the 21:20 deploy. Not load: 2026-08-11 served MORE traffic (5.43M vs
187+// 4.38M requests) with pages at p50 41ms.
188+//
189+// What changed is where MCP work runs. The old hibernatable Agent bridge handed
190+// `/mcp` to the Durable Object almost immediately; the v2 stack authenticates
191+// and dispatches in the worker. `/mcp` returns above without touching
192+// `fetchHandler`, so an isolate can serve a lot of MCP traffic and still have
193+// never loaded the Start graph. Measured: isolates that served a page request
194+// had already served a median of 19 spans across 3 paths — they are reused,
195+// they are just cold for Start. The page request then pays `loadEntries`: p50
196+// **3.1s**, against p50 33ms for the request's own work and 9-104ms warm. The
197+// same bundle in local workerd serves the same path in ~3ms.
198+//
199+// Warm by REPLAYING A REAL REQUEST through the handler, not by importing the
200+// virtual entry ids. Two attempts at the import approach (#1679, #1681) moved
201+// nothing in production: `loadEntries` awaits THREE specifiers
202+// (`#tanstack-router-entry`, `#tanstack-start-entry`,
203+// `#tanstack-start-plugin-adapters`) plus a separately-cached manifest, so
204+// warming a subset leaves the request paying for the rest. Driving the real
205+// handler populates whatever those caches are, by construction, and cannot
206+// drift when Start changes its internals.
207+//
208+// `/robots.txt` is the cheapest Start-served route. It runs once per isolate,
209+// under `waitUntil` so it outlives the request that triggered it, and costs one
210+// extra synthetic request per isolate in telemetry.
211+// `START_GRAPH_WARM=false` disables it without a deploy.
212+// ---------------------------------------------------------------------------
213+
214+let startGraphWarmupStarted = false;
215+
216+const warmStartGraph = (env: Env, ctx: ExecutionContext): void => {
217+ if (startGraphWarmupStarted) return;
218+ if (env.START_GRAPH_WARM === "false") return;
219+ startGraphWarmupStarted = true;
220+
221+ ctx.waitUntil(
222+ (async () => {
223+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; a warmup failure must never affect the request that triggered it
224+ try {
225+ await fetchHandler(
226+ new Request("https://executor.sh/robots.txt", { method: "GET" }),
227+ env,
228+ ctx,
229+ );
230+ } catch {
231+ // Advisory only — the request path still loads the graph lazily.
232+ startGraphWarmupStarted = false;
233+ }
234+ })(),
235+ );
236+};
237+
181238 const cloudflareHandler: ExportedHandler<Env> = {
182239 fetch: async (request, env, ctx) => {
240+ // First fetch in this isolate drives one real request through Start in the
241+ // background, including for /mcp — MCP traffic is what reaches these
242+ // isolates first, and is why they are otherwise never warmed.
243+ warmStartGraph(env, ctx);
244+
183245 // Public pages must not enter TanStack Start: its first-request dynamic
184246 // import loads the entire React + Effect server graph and can take seconds
185247 // on a cold isolate. Classify and service-bind marketing at the Worker