Skip to content

Commit 80e4b32

Browse files
committed
fix(api): recover cold worker initialization
1 parent 61adae6 commit 80e4b32

3 files changed

Lines changed: 139 additions & 25 deletions

File tree

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { describe, expect, it } from "vitest"
2+
import { Effect, Layer, ManagedRuntime } from "effect"
3+
import { HttpRouter, HttpServerResponse } from "effect/unstable/http"
4+
import { makeRecoverablePromiseMemo } from "./recoverable-promise-memo"
5+
6+
describe("makeRecoverablePromiseMemo", () => {
7+
it("shares a concurrent build, retains success, and retries rejection", async () => {
8+
let builds = 0
9+
const memo = makeRecoverablePromiseMemo(async () => {
10+
builds++
11+
if (builds === 1) throw new Error("first build failed")
12+
return { build: builds }
13+
})
14+
15+
const first = memo.get()
16+
expect(memo.get()).toBe(first)
17+
await expect(first).rejects.toThrow("first build failed")
18+
19+
const recovered = await memo.get()
20+
expect(recovered).toEqual({ build: 2 })
21+
expect(await memo.get()).toBe(recovered)
22+
expect(builds).toBe(2)
23+
})
24+
25+
it("evicts a handler after rejected lazy layer acquisition", async () => {
26+
let acquisitions = 0
27+
const memo = makeRecoverablePromiseMemo(async () => {
28+
const acquisition = Layer.effectDiscard(
29+
Effect.sync(() => {
30+
acquisitions++
31+
if (acquisitions === 1) throw new Error("first handler acquisition failed")
32+
}),
33+
)
34+
const routes = Layer.merge(
35+
HttpRouter.use((router) => router.add("GET", "/test", HttpServerResponse.text("OK"))),
36+
acquisition,
37+
)
38+
return HttpRouter.toWebHandler(routes, { disableLogger: true })
39+
})
40+
41+
const firstPending = memo.get()
42+
expect(memo.get()).toBe(firstPending)
43+
const first = await firstPending
44+
await expect(first.handler(new Request("https://worker.invalid/test"))).rejects.toThrow(
45+
"first handler acquisition failed",
46+
)
47+
expect(memo.evict(firstPending)).toBe(true)
48+
expect(memo.evict(firstPending)).toBe(false)
49+
await first.dispose()
50+
51+
const recovered = await memo.get()
52+
expect((await recovered.handler(new Request("https://worker.invalid/test"))).status).toBe(200)
53+
expect(acquisitions).toBe(2)
54+
await recovered.dispose()
55+
})
56+
57+
it("retries a rejected ManagedRuntime layer acquisition", async () => {
58+
let acquisitions = 0
59+
const memo = makeRecoverablePromiseMemo(async () => {
60+
const runtime = ManagedRuntime.make(
61+
Layer.effectDiscard(
62+
Effect.sync(() => {
63+
acquisitions++
64+
if (acquisitions === 1) throw new Error("first runtime acquisition failed")
65+
}),
66+
),
67+
)
68+
try {
69+
await runtime.context()
70+
return runtime
71+
} catch (error) {
72+
await runtime.dispose()
73+
throw error
74+
}
75+
})
76+
77+
await expect(memo.get()).rejects.toThrow("first runtime acquisition failed")
78+
const recovered = await memo.get()
79+
expect(await recovered.runPromise(Effect.succeed("ok"))).toBe("ok")
80+
expect(acquisitions).toBe(2)
81+
await recovered.dispose()
82+
})
83+
})
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/**
2+
* Share one in-flight/successful asynchronous build and evict a rejected build.
3+
*
4+
* The identity guard matters when callers overlap: a late rejection from an
5+
* older build must never clear the newer promise that a retry already installed.
6+
*/
7+
export const makeRecoverablePromiseMemo = <Args extends ReadonlyArray<unknown>, A>(
8+
build: (...args: Args) => Promise<A>,
9+
) => {
10+
let current: Promise<A> | undefined
11+
12+
const evict = (expected: Promise<A>): boolean => {
13+
if (current !== expected) return false
14+
current = undefined
15+
return true
16+
}
17+
18+
const get = (...args: Args): Promise<A> => {
19+
if (current !== undefined) return current
20+
const pending = build(...args)
21+
current = pending
22+
void pending.catch(() => {
23+
evict(pending)
24+
})
25+
return pending
26+
}
27+
28+
return { get, evict }
29+
}

apps/api/src/worker.ts

Lines changed: 27 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { serverErrorSpanMiddleware } from "./http/server-error-span"
1919
import { v2WorkerUnavailableResponse } from "./http/v2-worker-unavailable"
2020
import { API_CORS_RESPONSE_HEADERS, apiCorsPreflightResponse } from "./http/api-cors"
2121
import { persistSession, preloadSession, type SessionsBinding } from "./mcp/lib/session-store"
22+
import { makeRecoverablePromiseMemo } from "./platform/recoverable-promise-memo"
2223
import { classifyWorkerQueue } from "./queue-dispatch"
2324

2425
const WorkerFileSystemLive = FileSystem.layerNoop({})
@@ -137,16 +138,7 @@ const buildHandler = async () => {
137138
// Memoized via the build promise so concurrent first requests share one build.
138139
// A rejected build is cleared after those callers observe it, allowing a later
139140
// request to recover instead of pinning the isolate to a rejected promise.
140-
let handlerPromise: ReturnType<typeof buildHandler> | undefined
141-
const getHandler = (): ReturnType<typeof buildHandler> => {
142-
if (handlerPromise !== undefined) return handlerPromise
143-
const pending = buildHandler()
144-
handlerPromise = pending
145-
void pending.catch(() => {
146-
if (handlerPromise === pending) handlerPromise = undefined
147-
})
148-
return pending
149-
}
141+
const handlerMemo = makeRecoverablePromiseMemo(buildHandler)
150142

151143
// RPC has no HttpApi request to construct the application services for it, so
152144
// it gets a sibling isolate-wide ManagedRuntime. Its headless service graph
@@ -156,7 +148,7 @@ const buildRpcRuntime = async (env: Record<string, unknown>) => {
156148
import("./runtime/mcp-service-graph"),
157149
import("@/platform/DatabasePgLive"),
158150
])
159-
return ManagedRuntime.make(
151+
const runtime = ManagedRuntime.make(
160152
InvestigationServicesLive.pipe(
161153
Layer.provideMerge(WorkerPlatformLive),
162154
Layer.provideMerge(layerPg),
@@ -165,18 +157,19 @@ const buildRpcRuntime = async (env: Record<string, unknown>) => {
165157
Layer.provideMerge(WorkerConfigProviderLayer),
166158
),
167159
)
160+
try {
161+
// ManagedRuntime also acquires lazily and retains a failed build fiber.
162+
// Acquire before resolving the recoverable outer promise so a later RPC
163+
// can construct a fresh runtime after an initialization failure.
164+
await runtime.context()
165+
return runtime
166+
} catch (error) {
167+
await runtime.dispose()
168+
throw error
169+
}
168170
}
169171

170-
let rpcRuntimePromise: ReturnType<typeof buildRpcRuntime> | undefined
171-
const getRpcRuntime = (env: Record<string, unknown>): ReturnType<typeof buildRpcRuntime> => {
172-
if (rpcRuntimePromise !== undefined) return rpcRuntimePromise
173-
const pending = buildRpcRuntime(env)
174-
rpcRuntimePromise = pending
175-
void pending.catch(() => {
176-
if (rpcRuntimePromise === pending) rpcRuntimePromise = undefined
177-
})
178-
return pending
179-
}
172+
const rpcRuntimeMemo = makeRecoverablePromiseMemo(buildRpcRuntime)
180173

181174
type InternalRpcMethod = "listMcpTools" | "callMcpTool" | "submitDiagnosis"
182175

@@ -207,7 +200,7 @@ const runInternalRpc = async (
207200
ctx: ExecutionContext,
208201
) => {
209202
const [runtime, { callMcpToolRpc, listMcpToolsRpc, submitDiagnosisRpc }] = await Promise.all([
210-
getRpcRuntime(env),
203+
rpcRuntimeMemo.get(env),
211204
import("./internal-rpc"),
212205
])
213206
let exit: Exit.Exit<unknown, unknown>
@@ -326,7 +319,7 @@ const handle = async (
326319
// Start the expensive cold handler build and the independent KV read before
327320
// buffering an MCP body. Warm requests resolve both promises immediately;
328321
// cold MCP requests hide module evaluation and KV latency behind body I/O.
329-
const pendingHandler = getHandler()
322+
const pendingHandler = handlerMemo.get()
330323
const pendingSession = kv && reqSid ? preloadSession(kv, reqSid) : undefined
331324

332325
// MCP diagnostics: buffer the body so we can peek the JSON-RPC method/id
@@ -350,10 +343,19 @@ const handle = async (
350343
}
351344

352345
try {
353-
const { handler } = pendingSession
346+
const built = pendingSession
354347
? (await Promise.all([pendingHandler, pendingSession]))[0]
355348
: await pendingHandler
356-
const response = await handler(forwardRequest, HandlerContext)
349+
let response: Response
350+
try {
351+
response = await built.handler(forwardRequest, HandlerContext)
352+
} catch (error) {
353+
// `toWebHandler` acquires lazily and pins a rejected inner build.
354+
// Evict only the exact wrapper used by this request so the next real
355+
// request can rebuild it; overlapping failures cannot clear a retry.
356+
if (handlerMemo.evict(pendingHandler)) await built.dispose()
357+
throw error
358+
}
357359
if (kv && isMcp) {
358360
const resSid = response.headers.get("mcp-session-id")
359361
// Only persist when the server issued a new session — i.e. on

0 commit comments

Comments
 (0)