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
95 MCP_REQUEST_STATE_KEY?: string;95 MCP_REQUEST_STATE_KEY?: string;
96 /** Emergency rollback for inbound MCP 2026-07-28 traffic only. */96 /** Emergency rollback for inbound MCP 2026-07-28 traffic only. */
97 MCP_2026_07_28_ENABLED?: string;97 MCP_2026_07_28_ENABLED?: string;
98 // Kill switch for the Start server-graph warmup (server.ts).
99 START_GRAPH_WARM?: string;
98 NODE_ENV?: string;100 NODE_ENV?: string;
99 101
100 // Shared with frontend102 // Shared with frontend
apps/cloud/src/server.ts+62−0
@@ -178,8 +178,70 @@ const mcpAgentHandler = makeCloudMcpAgentHandler({ · 177 unmodified lines
178 traceRequest: traceCloudMcpRequest,178 traceRequest: traceCloudMcpRequest,
179});179});
180 180
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
214let startGraphWarmupStarted = false;
215
216const 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
181const cloudflareHandler: ExportedHandler<Env> = {238const cloudflareHandler: ExportedHandler<Env> = {
182 fetch: async (request, env, ctx) => {239 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
183 // Public pages must not enter TanStack Start: its first-request dynamic245 // Public pages must not enter TanStack Start: its first-request dynamic
184 // import loads the entire React + Effect server graph and can take seconds246 // import loads the entire React + Effect server graph and can take seconds
185 // on a cold isolate. Classify and service-bind marketing at the Worker247 // on a cold isolate. Classify and service-bind marketing at the Worker