History

Warm the Start server graph on isolate first fetch (#1681)

Page latency was p50 19-49ms daily up to 2026-08-16 and 2728ms the day after the 21:20 deploy. Not load: 2026-08-11 served more total traffic (5.43M vs 4.38M) with pages at p50 41ms, so the volume explanation does not hold. What changed is where MCP work runs. The old hibernatable Agent bridge handed /mcp to the DO almost immediately; the v2 stack authenticates and dispatches in the worker, spinning far more worker isolates. /mcp returns before fetchHandler, so those isolates never load the Start graph, and page requests land on them cold paying loadEntries: p50 3.1s, against 33ms for the request's own work and 9-104ms warm. Re-lands #1628. Its revert came during the Aug 17 storm (11.67M requests against a 2.6-4.3M baseline); volume is back to baseline and exceededMemory is currently zero. START_GRAPH_WARM=false disables it.

Rhys Sullivan committed 2 months ago

Commit 29a0aea1 parent 332955f3 files · +62 −0Browse files

Unified · Split

3 files
  1. apps/cloud/src/env-augment.d.ts +3 −0
  2. apps/cloud/src/server.ts +51 −0
  3. apps/cloud/src/start-virtual-entries.d.ts +8 −0
apps/cloud/src/env-augment.d.ts+3−0
@@ -95,6 +95,9 @@ 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). Set to
99+ // "false" to disable without a deploy if memory pressure returns.
100+ START_GRAPH_WARM?: string;
98101 NODE_ENV?: string;
99102
100103 // Shared with frontend
apps/cloud/src/server.ts+51−0
@@ -178,8 +178,59 @@ 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 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+
181228 const cloudflareHandler: ExportedHandler<Env> = {
182229 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+
183234 // Public pages must not enter TanStack Start: its first-request dynamic
184235 // import loads the entire React + Effect server graph and can take seconds
185236 // on a cold isolate. Classify and service-bind marketing at the Worker
apps/cloud/src/start-virtual-entries.d.ts+8−0
@@ -0,0 +1,8 @@
1+// TanStack Start's internal virtual server-entry modules (registered by the
2+// Start vite plugin; the same ids `start-server-core`'s `loadEntries`
3+// imports). server.ts imports them for the isolate warmup — only the
4+// module-evaluation side effect matters there, so the value shape is left
5+// untyped. Kept in a standalone declaration file: shorthand ambient modules
6+// only register from a non-module file (env-augment.d.ts is a module).
7+declare module "#tanstack-router-entry";
8+declare module "#tanstack-start-entry";