| @@ -178,8 +178,59 @@ const mcpAgentHandler = makeCloudMcpAgentHandler({ · 177 unmodified lines |
| 178 | 178 | traceRequest: traceCloudMcpRequest, |
| 179 | 179 | }); |
| 180 | 180 | |
| 181 | +// --------------------------------------------------------------------------- |
| 182 | +// Start server-graph warmup |
| 183 | +// --------------------------------------------------------------------------- |
| 184 | +// |
| 185 | +// Page latency was p50 19-49ms every day up to 2026-08-16 and 2728ms the day |
| 186 | +// after the 21:20 UTC deploy. It is not load: 2026-08-11 served MORE total |
| 187 | +// traffic (5.43M requests vs 4.38M) with pages at p50 41ms, so the |
| 188 | +// reconnect-storm/volume explanation does not hold. |
| 189 | +// |
| 190 | +// What changed is where MCP work runs. The old hibernatable Agent bridge |
| 191 | +// handed `/mcp` to the Durable Object almost immediately; the v2 stack |
| 192 | +// authenticates and dispatches in the WORKER (`mcp.auth.jwt_verify` alone runs |
| 193 | +// ~35k times per 6h there). That spins up far more worker isolates, and `/mcp` |
| 194 | +// returns above without ever touching `fetchHandler` — so those isolates never |
| 195 | +// load the Start graph. Page requests then land on them cold and pay |
| 196 | +// `loadEntries`: measured p50 **3.1s**, against p50 33ms for the request's own |
| 197 | +// work and 9-104ms for a warm isolate. The same bundle in local workerd serves |
| 198 | +// the same path in ~3ms, so this is a cold-isolate cost, not slow code. |
| 199 | +// |
| 200 | +// So warm on the isolate's FIRST fetch, in the background: MCP traffic then |
| 201 | +// pre-warms an isolate before a page request reaches it. This is #1628, which |
| 202 | +// worked; it was reverted (#1634) when memory-limit kills jumped, but that |
| 203 | +// landed during the 2026-08-17 storm — 11.67M requests in a day against a |
| 204 | +// 2.6-4.3M baseline. Volume is back to baseline and `exceededMemory` is |
| 205 | +// currently zero, so the condition that made it expensive is gone. |
| 206 | +// `START_GRAPH_WARM=false` disables it without a deploy. |
| 207 | +// |
| 208 | +// Deliberately NOT at module scope: a full warmup there trips workerd's |
| 209 | +// global-scope I/O restriction, and DO-only isolates should not carry the SSR |
| 210 | +// graph. |
| 211 | +// --------------------------------------------------------------------------- |
| 212 | + |
| 213 | +let startGraphWarmupStarted = false; |
| 214 | + |
| 215 | +const warmStartGraph = (env: Env): void => { |
| 216 | + if (startGraphWarmupStarted) return; |
| 217 | + if (env.START_GRAPH_WARM === "false") return; |
| 218 | + startGraphWarmupStarted = true; |
| 219 | + // oxlint-disable-next-line executor/no-promise-catch -- adapter boundary; fire-and-forget warmup outside any Effect runtime |
| 220 | + void Promise.all([import("#tanstack-router-entry"), import("#tanstack-start-entry")]).catch( |
| 221 | + () => { |
| 222 | + // Advisory only — the request path still loads the graph lazily. |
| 223 | + startGraphWarmupStarted = false; |
| 224 | + }, |
| 225 | + ); |
| 226 | +}; |
| 227 | + |
| 181 | 228 | const cloudflareHandler: ExportedHandler<Env> = { |
| 182 | 229 | fetch: async (request, env, ctx) => { |
| 230 | + // First fetch in this isolate kicks the graph load off in the background, |
| 231 | + // including for /mcp — MCP traffic is what reaches these isolates first. |
| 232 | + warmStartGraph(env); |
| 233 | + |
| 183 | 234 | // Public pages must not enter TanStack Start: its first-request dynamic |
| 184 | 235 | // import loads the entire React + Effect server graph and can take seconds |
| 185 | 236 | // on a cold isolate. Classify and service-bind marketing at the Worker |