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.
Unified · Split
| @@ -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 frontend | 102 | // Shared with frontend |
| @@ -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 | |||
| 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 | |||
| 181 | const cloudflareHandler: ExportedHandler<Env> = { | 238 | const 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 dynamic | 245 | // Public pages must not enter TanStack Start: its first-request dynamic |
| 184 | // import loads the entire React + Effect server graph and can take seconds | 246 | // import loads the entire React + Effect server graph and can take seconds |
| 185 | // on a cold isolate. Classify and service-bind marketing at the Worker | 247 | // on a cold isolate. Classify and service-bind marketing at the Worker |