Skip to content

Commit e94b935

Browse files
committed
Let hosts bound stale tool-catalog rebuild concurrency; Cloudflare rebuilds two at a time
1 parent c35eaf3 commit e94b935

5 files changed

Lines changed: 110 additions & 67 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@executor-js/sdk": patch
3+
---
4+
5+
`ExecutorConfig.toolsSyncConcurrency` sets how many stale tool catalogs one tools read rebuilds at once (default 10, unchanged). Each in-flight rebuild holds its resolved catalog in memory until its write commits, so memory-constrained hosts can narrow the fan-out; the Cloudflare host now rebuilds two at a time, keeping a full stale fan-out over large OpenAPI specs inside the Workers isolate limit.

‎apps/host-cloudflare/src/execution.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,19 @@ export const makeCloudflarePluginsProvider = (
5151
}),
5252
});
5353

54+
// Two stale catalogs rebuild at once, not the SDK's ten: each in-flight
55+
// rebuild holds its resolved tools and schema definitions until its write
56+
// commits, and a full fan-out over a few large OpenAPI specs overruns the
57+
// 128MB Workers isolate. Writes are serialized anyway, so the narrower
58+
// fan-out costs little convergence time.
59+
const CLOUDFLARE_TOOLS_SYNC_CONCURRENCY = 2;
60+
5461
export const makeCloudflareHostConfig = (config: CloudflareConfig): Layer.Layer<HostConfig> =>
5562
Layer.succeed(HostConfig)({
5663
allowLocalNetwork: config.allowLocalNetwork,
5764
webBaseUrl: config.webBaseUrl,
5865
oauthCallbackPath: "/api/oauth/callback",
66+
toolsSyncConcurrency: CLOUDFLARE_TOOLS_SYNC_CONCURRENCY,
5967
});
6068

6169
/**

‎packages/core/api/src/server/scoped-executor.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,13 @@ export interface HostConfigShape {
126126
* operator knob.
127127
*/
128128
readonly toolsSyncTtlMs?: number | null;
129+
/**
130+
* Forwarded verbatim to `ExecutorConfig.toolsSyncConcurrency`: how many
131+
* stale tool catalogs one read rebuilds at once. Omit to take the SDK
132+
* default; memory-constrained hosts lower it because every in-flight
133+
* rebuild holds its resolved catalog until its write commits.
134+
*/
135+
readonly toolsSyncConcurrency?: number;
129136
/**
130137
* Forwarded to `ExecutorConfig.waitUntil`: the host's keep-alive
131138
* for background work that outlives a request (stale tool-catalog rebuilds
@@ -334,6 +341,9 @@ export const makeScopedExecutor = <
334341
fetch: hostedFetch,
335342
onIntegrationChange: config.onIntegrationChange,
336343
...(config.toolsSyncTtlMs !== undefined ? { toolsSyncTtlMs: config.toolsSyncTtlMs } : {}),
344+
...(config.toolsSyncConcurrency !== undefined
345+
? { toolsSyncConcurrency: config.toolsSyncConcurrency }
346+
: {}),
337347
...(waitUntil !== undefined ? { waitUntil } : {}),
338348
onElicitation: "accept-all",
339349
...(options?.orgWrites === undefined ? {} : { orgWrites: options.orgWrites }),

‎packages/core/sdk/src/executor.ts‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -795,6 +795,16 @@ export interface ExecutorConfig<TPlugins extends readonly AnyPlugin[] = readonly
795795
* mode: a read then always reflects a fully converged catalog).
796796
*/
797797
readonly toolsSyncGraceMs?: number | null;
798+
/**
799+
* How many stale connection catalogs one tools read rebuilds at once.
800+
* Defaults to {@link STALE_TOOLS_SYNC_CONCURRENCY}. Every in-flight rebuild
801+
* holds its connection's resolved tool set and schema definitions in memory
802+
* until its catalog write gets the single persist permit, so hosts with a
803+
* small memory ceiling (Cloudflare Workers' 128MB isolate) lower it; hosts
804+
* whose catalogs mostly come from slow remote listings keep the default so
805+
* those listings overlap.
806+
*/
807+
readonly toolsSyncConcurrency?: number;
798808
/**
799809
* Host keep-alive for background work that outlives a request — the
800810
* platform `waitUntil` on Cloudflare Workers, where I/O started inside a
@@ -5546,6 +5556,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
55465556
const toolsSyncTtlMs =
55475557
config.toolsSyncTtlMs === undefined ? DEFAULT_TOOLS_SYNC_TTL_MS : config.toolsSyncTtlMs;
55485558

5559+
// How many stale catalogs a tools read rebuilds at once
5560+
// (`ExecutorConfig.toolsSyncConcurrency`).
5561+
const toolsSyncConcurrency = config.toolsSyncConcurrency ?? STALE_TOOLS_SYNC_CONCURRENCY;
5562+
55495563
// Rebuild any visible connection whose persisted tool catalog is stale.
55505564
// Three triggers:
55515565
// - stale-marked: `tools_synced_at` is NULL (`connections.markToolsStale`
@@ -5681,15 +5695,13 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
56815695
}
56825696
if (deferred.length > 0) {
56835697
const background = yield* Effect.forkDetach(
5684-
Effect.all(deferred, { concurrency: STALE_TOOLS_SYNC_CONCURRENCY }),
5698+
Effect.all(deferred, { concurrency: toolsSyncConcurrency }),
56855699
);
56865700
config.waitUntil?.(
56875701
new Promise<void>((resolve) => background.addObserver(() => resolve(undefined))),
56885702
);
56895703
}
5690-
yield* Effect.all(urgent, {
5691-
concurrency: STALE_TOOLS_SYNC_CONCURRENCY,
5692-
});
5704+
yield* Effect.all(urgent, { concurrency: toolsSyncConcurrency });
56935705
});
56945706

56955707
// How long a tools read waits for the stale sync before answering from

‎packages/plugins/mcp/src/sdk/catalog-sync.test.ts‎

Lines changed: 71 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -297,15 +297,13 @@ describe("MCP tools/list pagination", () => {
297297
//
298298
// The fixture below refuses to answer any listing until the bound is reached,
299299
// which pins both edges at once: a serial refresh parks on the first listing
300-
// and never finishes, while an unbounded refresh puts more than
301-
// STALE_TOOLS_SYNC_CONCURRENCY listings in flight. The stale set is deliberately
302-
// one larger than the bound, so the last connection can only be served after an
303-
// earlier one completes.
300+
// and never finishes, while an unbounded refresh puts more than the bound
301+
// (STALE_TOOLS_SYNC_CONCURRENCY, or the host's `toolsSyncConcurrency`) in
302+
// flight. The stale set is deliberately one larger than the bound, so the last
303+
// connection can only be served after an earlier one completes.
304304
// ---------------------------------------------------------------------------
305305

306-
const STALE_CONNECTIONS = STALE_TOOLS_SYNC_CONCURRENCY + 1;
307-
308-
const serveLatchedListServer = () =>
306+
const serveLatchedListServer = (bound: number) =>
309307
Effect.gen(function* () {
310308
const armed = yield* Ref.make(false);
311309
const listings = yield* Ref.make(0);
@@ -341,7 +339,7 @@ const serveLatchedListServer = () =>
341339
// refresh parks on the first one and never reaches the bound.
342340
if (yield* Ref.get(armed)) {
343341
const arrived = yield* Ref.updateAndGet(listings, (n) => n + 1);
344-
if (arrived >= STALE_TOOLS_SYNC_CONCURRENCY) {
342+
if (arrived >= bound) {
345343
yield* Deferred.succeed(atLimit, undefined);
346344
}
347345
yield* Deferred.await(release);
@@ -361,67 +359,77 @@ const serveLatchedListServer = () =>
361359
} as const;
362360
});
363361

362+
const expectBoundedStaleRefresh = (options: { readonly toolsSyncConcurrency?: number }) =>
363+
Effect.gen(function* () {
364+
const bound = options.toolsSyncConcurrency ?? STALE_TOOLS_SYNC_CONCURRENCY;
365+
const staleConnections = bound + 1;
366+
const fixture = yield* serveLatchedListServer(bound);
367+
const executor = yield* createExecutor({
368+
...makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }),
369+
// Everything is expired on every read, so a single tools read has the
370+
// whole set to rebuild.
371+
toolsSyncTtlMs: 0,
372+
// Strict mode: the assertions below synchronize on the read fiber
373+
// completing only after every rebuild has finished. With a grace
374+
// budget the read would return early and `Fiber.join` would no longer
375+
// order the final listing before the count assertion.
376+
toolsSyncGraceMs: null,
377+
...options,
378+
});
379+
380+
for (let index = 0; index < staleConnections; index++) {
381+
const slug = IntegrationSlug.make(`latched_mcp_${index}`);
382+
yield* executor.mcp.addServer({
383+
name: `latched-mcp-${index}`,
384+
endpoint: fixture.endpoint(index),
385+
slug: String(slug),
386+
});
387+
yield* executor.connections.create({
388+
owner: "org",
389+
name: CONNECTION,
390+
integration: slug,
391+
template: TEMPLATE,
392+
value: "",
393+
});
394+
}
395+
396+
// Warm every catalog while the fixture still answers freely, so the
397+
// latched read below is purely the stale-refresh fan-out.
398+
yield* executor.tools.list();
399+
yield* fixture.arm;
400+
401+
const readFiber = yield* Effect.forkChild(executor.tools.list());
402+
403+
// Timeouts are well inside the harness limit, so a broken fan-out fails
404+
// on an assertion here rather than as an opaque test-runner timeout.
405+
// A serial refresh never saturates the bound and fails on this line.
406+
const saturated = yield* fixture.awaitLimit.pipe(Effect.timeoutOption("10 seconds"));
407+
expect(Option.isSome(saturated)).toBe(true);
408+
409+
// The bound is reached and every one of those listings is still parked.
410+
// Give an unbounded fan-out ample time to dial the remaining connection:
411+
// it never may, because no permit has been given back yet.
412+
yield* Effect.sleep("500 millis");
413+
expect(yield* fixture.listings).toBe(bound);
414+
415+
// Releasing the parked listings frees permits, and only then does the
416+
// last connection get dialled.
417+
yield* fixture.release;
418+
const refreshed = yield* Fiber.join(readFiber).pipe(Effect.timeoutOption("10 seconds"));
419+
expect(Option.isSome(refreshed)).toBe(true);
420+
expect(yield* fixture.listings).toBe(staleConnections);
421+
});
422+
364423
describe("MCP stale-catalog refresh", () => {
365424
// `it.live` (real clock): proving that nothing beyond the bound is dialled
366425
// means giving a real HTTP round trip a real window to happen in, and the
367426
// timeouts below must actually fire. The TestClock advances neither.
368427
it.live("rebuilds stale connections concurrently up to the bound, then queues the rest", () =>
369-
Effect.gen(function* () {
370-
const fixture = yield* serveLatchedListServer();
371-
const executor = yield* createExecutor({
372-
...makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }),
373-
// Everything is expired on every read, so a single tools read has the
374-
// whole set to rebuild.
375-
toolsSyncTtlMs: 0,
376-
// Strict mode: the assertions below synchronize on the read fiber
377-
// completing only after every rebuild has finished. With a grace
378-
// budget the read would return early and `Fiber.join` would no longer
379-
// order the final listing before the count assertion.
380-
toolsSyncGraceMs: null,
381-
});
382-
383-
for (let index = 0; index < STALE_CONNECTIONS; index++) {
384-
const slug = IntegrationSlug.make(`latched_mcp_${index}`);
385-
yield* executor.mcp.addServer({
386-
name: `latched-mcp-${index}`,
387-
endpoint: fixture.endpoint(index),
388-
slug: String(slug),
389-
});
390-
yield* executor.connections.create({
391-
owner: "org",
392-
name: CONNECTION,
393-
integration: slug,
394-
template: TEMPLATE,
395-
value: "",
396-
});
397-
}
398-
399-
// Warm every catalog while the fixture still answers freely, so the
400-
// latched read below is purely the stale-refresh fan-out.
401-
yield* executor.tools.list();
402-
yield* fixture.arm;
403-
404-
const readFiber = yield* Effect.forkChild(executor.tools.list());
405-
406-
// Timeouts are well inside the harness limit, so a broken fan-out fails
407-
// on an assertion here rather than as an opaque test-runner timeout.
408-
// A serial refresh never saturates the bound and fails on this line.
409-
const saturated = yield* fixture.awaitLimit.pipe(Effect.timeoutOption("10 seconds"));
410-
expect(Option.isSome(saturated)).toBe(true);
411-
412-
// The bound is reached and every one of those listings is still parked.
413-
// Give an unbounded fan-out ample time to dial the remaining connection:
414-
// it never may, because no permit has been given back yet.
415-
yield* Effect.sleep("500 millis");
416-
expect(yield* fixture.listings).toBe(STALE_TOOLS_SYNC_CONCURRENCY);
428+
expectBoundedStaleRefresh({}),
429+
);
417430

418-
// Releasing the parked listings frees permits, and only then does the
419-
// last connection get dialled.
420-
yield* fixture.release;
421-
const refreshed = yield* Fiber.join(readFiber).pipe(Effect.timeoutOption("10 seconds"));
422-
expect(Option.isSome(refreshed)).toBe(true);
423-
expect(yield* fixture.listings).toBe(STALE_CONNECTIONS);
424-
}),
431+
it.live("bounds the stale rebuild fan-out at the host's toolsSyncConcurrency", () =>
432+
expectBoundedStaleRefresh({ toolsSyncConcurrency: 2 }),
425433
);
426434
});
427435

0 commit comments

Comments
 (0)