Skip to content

Commit 35cb1b4

Browse files
committed
Cache compiled specs for request-path OpenAPI fallbacks
The health-check endpoints, candidate listing, and the tools/invoke fallbacks recompiled the full OpenAPI document on every request. The UI now auto-fires health checks on page mounts, so a large spec was parsed into a fresh multi-MB object graph over and over, growing the dev server heap until the process hit the V8 limit and every subsequent request failed (the CI shard wedge behind the login 500 cascade). Compile through a small module-level LRU instead, keyed by the config's content-addressed specHash: same hash means byte-identical text, and a spec update writes a new hash so stale entries age out. Capacity is four compiled documents; legacy configs without a hash bypass the cache. Add and update paths keep compiling fresh input directly.
1 parent 1c5bb76 commit 35cb1b4

2 files changed

Lines changed: 124 additions & 4 deletions

File tree

packages/plugins/openapi/src/sdk/backing.ts

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,56 @@ export const compileOpenApiSpec = (
248248
return yield* compileOpenApiDocument(doc);
249249
});
250250

251+
/**
252+
* Bounded reuse of compiled specs for the serve-side fallback paths (health
253+
* checks, candidate listing, tools/invoke legacy fallbacks). These paths run
254+
* per request, and the UI auto-fires the health endpoints on every page mount,
255+
* so recompiling a multi-MB spec on each call grows the dev server heap until
256+
* the process hits the V8 limit. The stored spec is content-addressed: the
257+
* config's `specHash` keys the blob the text is loaded from, so the hash is a
258+
* free, already-computed identity (same hash means byte-identical text means
259+
* identical compile output), and a spec update writes a new hash so stale
260+
* entries age out naturally.
261+
*
262+
* An insertion-ordered Map serves as a tiny LRU: hits are re-inserted to
263+
* refresh recency and the oldest entry is evicted past capacity. Capacity 4
264+
* covers the handful of integrations a page's probes touch at once while
265+
* keeping worst-case retention to a few compiled documents.
266+
*/
267+
const COMPILED_SPEC_CACHE_CAPACITY = 4;
268+
const compiledSpecCache = new Map<string, CompiledOpenApiSpec>();
269+
270+
/** Test-only reset so unit tests can observe cold-cache behavior. */
271+
export const clearCompiledOpenApiSpecCache = (): void => {
272+
compiledSpecCache.clear();
273+
};
274+
275+
/** Compile through the module-level LRU. `specHash` is the content hash the
276+
* spec text was loaded by (`OpenApiIntegrationConfig.specHash`). A missing
277+
* hash (legacy config shape) bypasses the cache rather than paying to hash
278+
* multi-MB text on the request path. */
279+
export const compileOpenApiSpecCached = (
280+
specHash: string | null | undefined,
281+
specText: string,
282+
): Effect.Effect<CompiledOpenApiSpec, OpenApiParseError | OpenApiExtractionError> =>
283+
Effect.gen(function* () {
284+
if (specHash == null) return yield* compileOpenApiSpec(specText);
285+
const hit = compiledSpecCache.get(specHash);
286+
if (hit !== undefined) {
287+
compiledSpecCache.delete(specHash);
288+
compiledSpecCache.set(specHash, hit);
289+
return hit;
290+
}
291+
const compiled = yield* compileOpenApiSpec(specText);
292+
compiledSpecCache.set(specHash, compiled);
293+
while (compiledSpecCache.size > COMPILED_SPEC_CACHE_CAPACITY) {
294+
const oldest = compiledSpecCache.keys().next().value;
295+
if (oldest === undefined) break;
296+
compiledSpecCache.delete(oldest);
297+
}
298+
return compiled;
299+
});
300+
251301
export const openApiToolDefsFromCompiled = (compiled: CompiledOpenApiSpec): readonly ToolDef[] =>
252302
compiled.definitions.map((def): ToolDef => {
253303
const returnsFile = Option.match(def.operation.responseBody, {
@@ -543,7 +593,7 @@ export const resolveOpenApiBackedTools = ({
543593
}
544594
const specText = yield* loadOpenApiSpecText(storage, openApiConfig);
545595
if (specText == null) return { tools: [], definitions: {} };
546-
const compiled = yield* compileOpenApiSpec(specText).pipe(
596+
const compiled = yield* compileOpenApiSpecCached(openApiConfig.specHash, specText).pipe(
547597
Effect.catch(() => Effect.succeed(null)),
548598
);
549599
if (!compiled) return { tools: [], definitions: {} };
@@ -577,7 +627,9 @@ export const invokeOpenApiBackedTool = (input: {
577627
const compiled =
578628
specText == null
579629
? null
580-
: yield* compileOpenApiSpec(specText).pipe(Effect.catch(() => Effect.succeed(null)));
630+
: yield* compileOpenApiSpecCached(config.specHash, specText).pipe(
631+
Effect.catch(() => Effect.succeed(null)),
632+
);
581633
binding = compiled
582634
? openApiStoredOperationsFromCompiled(integration, compiled).find(
583635
(op) => op.toolName === input.toolRow.name,
@@ -716,7 +768,7 @@ const resolveHealthCheckBinding = (
716768
Effect.catch(() => Effect.succeed(null)),
717769
);
718770
if (specText == null) return undefined;
719-
const compiled = yield* compileOpenApiSpec(specText).pipe(
771+
const compiled = yield* compileOpenApiSpecCached(config.specHash, specText).pipe(
720772
Effect.catch(() => Effect.succeed(null)),
721773
);
722774
if (!compiled) return undefined;
@@ -878,7 +930,9 @@ export const listHealthCheckCandidatesOpenApi = (input: {
878930
const compiled =
879931
specText == null
880932
? null
881-
: yield* compileOpenApiSpec(specText).pipe(Effect.catch(() => Effect.succeed(null)));
933+
: yield* compileOpenApiSpecCached(config.specHash, specText).pipe(
934+
Effect.catch(() => Effect.succeed(null)),
935+
);
882936
if (compiled) {
883937
for (const def of compiled.definitions) {
884938
const summary =
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { describe, expect, it } from "@effect/vitest";
2+
import { Effect } from "effect";
3+
4+
import { clearCompiledOpenApiSpecCache, compileOpenApiSpecCached } from "./backing";
5+
6+
const specText = (title: string): string =>
7+
JSON.stringify({
8+
openapi: "3.0.3",
9+
info: { title, version: "1.0.0" },
10+
servers: [{ url: "https://api.example.com" }],
11+
paths: {
12+
"/me": {
13+
get: {
14+
operationId: "getMe",
15+
responses: {
16+
"200": {
17+
description: "ok",
18+
content: {
19+
"application/json": {
20+
schema: { type: "object", properties: { email: { type: "string" } } },
21+
},
22+
},
23+
},
24+
},
25+
},
26+
},
27+
},
28+
});
29+
30+
describe("compileOpenApiSpecCached", () => {
31+
it("reuses the compiled result for the same spec hash", () => {
32+
clearCompiledOpenApiSpecCache();
33+
const text = specText("Cached API");
34+
const first = Effect.runSync(compileOpenApiSpecCached("hash-a", text));
35+
const second = Effect.runSync(compileOpenApiSpecCached("hash-a", text));
36+
// Reference equality proves the second call skipped recompilation.
37+
expect(second).toBe(first);
38+
});
39+
40+
it("recompiles when the spec hash changes", () => {
41+
clearCompiledOpenApiSpecCache();
42+
const first = Effect.runSync(compileOpenApiSpecCached("hash-a", specText("V1")));
43+
const second = Effect.runSync(compileOpenApiSpecCached("hash-b", specText("V2")));
44+
expect(second).not.toBe(first);
45+
expect(second.title).toBe("V2");
46+
});
47+
48+
it("bypasses the cache when no hash is available", () => {
49+
clearCompiledOpenApiSpecCache();
50+
const text = specText("No hash");
51+
const first = Effect.runSync(compileOpenApiSpecCached(undefined, text));
52+
const second = Effect.runSync(compileOpenApiSpecCached(undefined, text));
53+
expect(second).not.toBe(first);
54+
});
55+
56+
it("evicts the oldest entry past capacity", () => {
57+
clearCompiledOpenApiSpecCache();
58+
const first = Effect.runSync(compileOpenApiSpecCached("hash-0", specText("Zero")));
59+
for (let index = 1; index <= 4; index++) {
60+
Effect.runSync(compileOpenApiSpecCached(`hash-${index}`, specText(`Spec ${index}`)));
61+
}
62+
// hash-0 was evicted by the four newer entries, so it recompiles.
63+
const again = Effect.runSync(compileOpenApiSpecCached("hash-0", specText("Zero")));
64+
expect(again).not.toBe(first);
65+
});
66+
});

0 commit comments

Comments
 (0)