Skip to content

Commit 5dd886c

Browse files
committed
perf(sdk): batch invoke-path lookups with Effect RequestResolver
Concurrent execute calls in the same microtask window now share one query per table (tool, connection, integration) plus one policy rule-set snapshot instead of four point queries per call, killing the N+1 pattern under code-mode fan-out and parallel MCP tool calls. - invoke-batching.ts: Request types + resolvers keyed by plain data; batch loaders serve mixed tuples with a single OR-of-tuples query and dedupe equal keys before hitting storage. No cross-batch caching, so read-your-writes behavior is unchanged. - Transactional callers bypass the batch window (a shared batch fiber would read through the wrong activeFumaDbRef) and keep exact per-transaction semantics. - Proof test counts real queries through a spying FumaDb wrapper: 25 concurrent executes touch each table at most twice; sequential calls still issue the same per-call point reads as before.
1 parent 1d6363f commit 5dd886c

4 files changed

Lines changed: 573 additions & 15 deletions

File tree

.changeset/wild-buses-repeat.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"executor": patch
3+
---
4+
5+
Batch the SDK invoke path with DataLoader semantics. Concurrent `execute` calls
6+
in the same microtask window now share one storage query per table (tool,
7+
connection, integration) and one policy-rule-set snapshot instead of four point
8+
queries per call, so naive fan-out code (`Promise.all` over tool calls, code
9+
mode loops, parallel MCP tool calls) no longer produces N+1 query storms.
10+
Sequential calls are unchanged, and transactional reads bypass the batch window
11+
so transaction isolation is preserved.

packages/core/sdk/src/executor.ts

Lines changed: 58 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ import {
147147
type OAuthEndpointUrlPolicy,
148148
} from "./oauth-helpers";
149149
import { connectionIdentifier } from "./connection-name-identifier";
150+
import { makeInvokeBatching } from "./invoke-batching";
150151
import { annotateToolResultOutcome } from "./tool-result";
151152

152153
const PLUGIN_STORAGE_DELETE_KEY_BATCH_SIZE = 90;
@@ -2532,6 +2533,51 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
25322533
),
25332534
);
25342535

2536+
// ------------------------------------------------------------------
2537+
// Invoke-path batching. Concurrent `execute` calls within one microtask
2538+
// window share ONE query per table (tool / connection / integration) and
2539+
// one policy-rule-set snapshot instead of 4×N point queries — the
2540+
// DataLoader pattern, via Effect Request/RequestResolver. Loaders serve
2541+
// a batch of mixed tuples with a single OR-of-tuples query; the memory
2542+
// and SQL adapters both plan it as one round trip.
2543+
// ------------------------------------------------------------------
2544+
2545+
const invokeBatching = makeInvokeBatching({
2546+
loadToolRows: (requests) =>
2547+
core.findMany("tool", {
2548+
where: (b: AnyCb) =>
2549+
b.or(
2550+
...requests.map((request) =>
2551+
b.and(
2552+
byOwner(request.owner)(b),
2553+
b("integration", "=", String(request.integration)),
2554+
b("connection", "=", String(request.connection)),
2555+
b("name", "=", String(request.tool)),
2556+
),
2557+
),
2558+
),
2559+
select: TOOL_INVOCATION_COLUMNS,
2560+
}),
2561+
loadConnectionRows: (requests) =>
2562+
core.findMany("connection", {
2563+
where: (b: AnyCb) =>
2564+
b.or(
2565+
...requests.map((request) =>
2566+
b.and(
2567+
byOwner(request.owner)(b),
2568+
b("integration", "=", String(request.integration)),
2569+
b("name", "=", String(request.name)),
2570+
),
2571+
),
2572+
),
2573+
}),
2574+
loadIntegrationRows: (slugs) =>
2575+
core.findMany("integration", {
2576+
where: (b: AnyCb) => b("slug", "in", slugs.map(String)),
2577+
}),
2578+
loadPolicyRuleSet: () => listActivePolicyRuleSet(),
2579+
});
2580+
25352581
// ------------------------------------------------------------------
25362582
// Tools (read surface)
25372583
// ------------------------------------------------------------------
@@ -3035,7 +3081,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
30353081
// not the 5-segment dynamic form.
30363082
const staticEntry = staticTools.get(String(address));
30373083
if (staticEntry) {
3038-
const policyRules = yield* listActivePolicyRuleSet();
3084+
const policyRules = yield* invokeBatching.getPolicyRuleSet();
30393085
const policy = yield* resolvePolicyFromRuleSet(
30403086
String(address),
30413087
policyRules,
@@ -3064,16 +3110,13 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
30643110

30653111
// Find the tool row — projected: invoke needs routing/policy fields
30663112
// only, never the multi-KB input/output schema JSON (`tools.schema`
3067-
// is the schema-bearing surface).
3068-
const row = yield* core.findFirst("tool", {
3069-
where: (b: AnyCb) =>
3070-
b.and(
3071-
byOwner(parsed.owner)(b),
3072-
b("integration", "=", String(parsed.integration)),
3073-
b("connection", "=", String(parsed.connection)),
3074-
b("name", "=", String(parsed.tool)),
3075-
),
3076-
select: TOOL_INVOCATION_COLUMNS,
3113+
// is the schema-bearing surface). Batched: concurrent executes in the
3114+
// same window share one query.
3115+
const row = yield* invokeBatching.getToolRow({
3116+
owner: parsed.owner,
3117+
integration: parsed.integration,
3118+
connection: parsed.connection,
3119+
tool: parsed.tool,
30773120
});
30783121
if (!row) {
30793122
const searchMatches = yield* searchToolRowsForConnection(parsed);
@@ -3087,7 +3130,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
30873130

30883131
// Resolve policy (owner-ranked).
30893132
const toolForPolicy = rowToTool(row);
3090-
const policyRules = yield* listActivePolicyRuleSet();
3133+
const policyRules = yield* invokeBatching.getPolicyRuleSet();
30913134
const annotations = decodeJsonColumn(row.annotations) as ToolAnnotations | undefined;
30923135
const policy = yield* resolvePolicyFromRuleSet(
30933136
normalizedPolicyId(toolForPolicy),
@@ -3115,8 +3158,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
31153158
});
31163159
}
31173160

3118-
// Find the connection row.
3119-
const connectionRow = yield* findConnectionRow({
3161+
// Find the connection row (batched with peer executes).
3162+
const connectionRow = yield* invokeBatching.getConnectionRow({
31203163
owner: parsed.owner,
31213164
integration: parsed.integration,
31223165
name: parsed.connection,
@@ -3147,7 +3190,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
31473190
// Resolve every named credential input (`variable → value`); `value` is
31483191
// the primary `token` for single-input + OAuth callers.
31493192
const values = yield* resolveConnectionValues(connectionRow);
3150-
const integrationRow = yield* findIntegrationRow(parsed.integration);
3193+
const integrationRow = yield* invokeBatching.getIntegrationRow(parsed.integration);
31513194
const credential: ToolInvocationCredential = {
31523195
owner: parsed.owner,
31533196
integration: parsed.integration,
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import { describe, expect, it } from "@effect/vitest";
2+
import { Effect, Predicate, Result } from "effect";
3+
4+
import { createExecutor, collectTables } from "./executor";
5+
import {
6+
AuthTemplateSlug,
7+
ConnectionName,
8+
IntegrationSlug,
9+
ProviderItemId,
10+
ProviderKey,
11+
Subject,
12+
Tenant,
13+
ToolAddress,
14+
ToolName,
15+
} from "./ids";
16+
import { definePlugin } from "./plugin";
17+
import type { CredentialProvider } from "./provider";
18+
import { createSqliteTestFumaDb } from "./sqlite-test-db";
19+
import { withQueryContext } from "@executor-js/fumadb/query";
20+
import type { FumaDb } from "./fuma-runtime";
21+
22+
// ---------------------------------------------------------------------------
23+
// Query-counting FumaDb wrapper. Counts reads per `${method}:${table}` while
24+
// forwarding everything else, and re-wraps `withContext` results so the
25+
// counter survives the executor's own context application.
26+
// ---------------------------------------------------------------------------
27+
28+
type QueryCounts = Map<string, number>;
29+
30+
const COUNTED_METHODS = new Set(["findFirst", "findMany", "count"]);
31+
32+
const countingDb = (db: FumaDb, counts: QueryCounts): FumaDb => {
33+
// oxlint-disable-next-line executor/no-double-cast -- boundary: test spy walks the ORM object's own members reflectively
34+
const record = db as unknown as Record<string, unknown>;
35+
const wrapper: Record<string, unknown> = {};
36+
// Copy every member (methods included) off the real query object; a Proxy
37+
// can't stand in because FumaDb's properties are non-configurable (proxy
38+
// invariant violation on `get`). `withContext` and friends are
39+
// non-enumerable, so walk own property names, not Object.keys.
40+
for (const key of Object.getOwnPropertyNames(record)) {
41+
const value = record[key];
42+
if (typeof value !== "function") {
43+
wrapper[key] = value;
44+
continue;
45+
}
46+
const fn = value as (...a: unknown[]) => unknown;
47+
if (COUNTED_METHODS.has(key)) {
48+
wrapper[key] = (table: string, ...rest: unknown[]) => {
49+
const countKey = `${key}:${table}`;
50+
counts.set(countKey, (counts.get(countKey) ?? 0) + 1);
51+
return fn.call(db, table, ...rest);
52+
};
53+
} else if (key === "withContext") {
54+
wrapper[key] = (context: unknown) =>
55+
countingDb((fn as (c: unknown) => FumaDb).call(db, context), counts);
56+
} else {
57+
wrapper[key] = (...args: unknown[]) => fn.call(db, ...args);
58+
}
59+
}
60+
// oxlint-disable-next-line executor/no-double-cast -- boundary: the wrapper delegates every FumaDb member 1:1
61+
return wrapper as unknown as FumaDb;
62+
};
63+
64+
const totalFor = (counts: QueryCounts, table: string): number => {
65+
let total = 0;
66+
for (const [key, count] of counts) {
67+
if (key.endsWith(`:${table}`)) total += count;
68+
}
69+
return total;
70+
};
71+
72+
// ---------------------------------------------------------------------------
73+
// Fixture: a plugin with one integration and two tools.
74+
// ---------------------------------------------------------------------------
75+
76+
const INTEG = IntegrationSlug.make("demo");
77+
const CONN = ConnectionName.make("main");
78+
79+
const memoryProvider = (): CredentialProvider => {
80+
const store = new Map<string, string>();
81+
return {
82+
key: ProviderKey.make("memory"),
83+
writable: true,
84+
get: (id) => Effect.sync(() => store.get(String(id)) ?? null),
85+
set: (id, value) => Effect.sync(() => void store.set(String(id), value)),
86+
};
87+
};
88+
89+
const demoPlugin = definePlugin(() => ({
90+
id: "demo" as const,
91+
credentialProviders: [memoryProvider()],
92+
storage: () => ({}),
93+
resolveTools: () =>
94+
Effect.succeed({
95+
tools: [
96+
{ name: ToolName.make("alpha"), description: "alpha" },
97+
{ name: ToolName.make("beta"), description: "beta" },
98+
],
99+
}),
100+
invokeTool: ({ toolRow, args }) => Effect.succeed({ ran: toolRow.name, args }),
101+
extension: (ctx) => ({
102+
seed: () => ctx.core.integrations.register({ slug: INTEG, description: "Demo", config: {} }),
103+
}),
104+
}))();
105+
106+
const addr = (tool: string): ToolAddress => ToolAddress.make(`tools.${INTEG}.org.${CONN}.${tool}`);
107+
108+
const TENANT = "test-tenant";
109+
const SUBJECT = "test-subject";
110+
111+
const makeCountedExecutor = Effect.fnUntraced(function* () {
112+
const counts: QueryCounts = new Map();
113+
const tables = collectTables();
114+
const testDb = yield* Effect.promise(() =>
115+
createSqliteTestFumaDb({ tables, namespace: "executor_test" }),
116+
);
117+
const db = withQueryContext(countingDb(testDb.db, counts), {
118+
tenant: TENANT,
119+
subject: SUBJECT,
120+
});
121+
const executor = yield* createExecutor({
122+
tenant: Tenant.make(TENANT),
123+
subject: Subject.make(SUBJECT),
124+
db,
125+
plugins: [demoPlugin] as const,
126+
onElicitation: "accept-all",
127+
});
128+
yield* executor.demo.seed();
129+
yield* executor.connections.create({
130+
owner: "org",
131+
name: CONN,
132+
integration: INTEG,
133+
template: AuthTemplateSlug.make("apiKey"),
134+
from: { provider: ProviderKey.make("memory"), id: ProviderItemId.make("v") },
135+
});
136+
return { executor, counts };
137+
});
138+
139+
describe("invoke-path batching", () => {
140+
it.effect("N concurrent executes share one query per table (no N+1)", () =>
141+
Effect.gen(function* () {
142+
const { executor, counts } = yield* makeCountedExecutor();
143+
144+
counts.clear();
145+
const N = 25;
146+
const results = yield* Effect.all(
147+
Array.from({ length: N }, (_, i) =>
148+
executor.execute(addr(i % 2 === 0 ? "alpha" : "beta"), { i }),
149+
),
150+
{ concurrency: "unbounded" },
151+
);
152+
153+
expect(results).toHaveLength(N);
154+
expect(results[0]).toEqual({ ran: "alpha", args: { i: 0 } });
155+
156+
// The batch window collapses all N lookups into one query per table.
157+
// Allow a little slack (the runtime may split across two microtask
158+
// windows under load) but the point is O(1), not O(N).
159+
expect(totalFor(counts, "tool")).toBeLessThanOrEqual(2);
160+
expect(totalFor(counts, "connection")).toBeLessThanOrEqual(2);
161+
expect(totalFor(counts, "integration")).toBeLessThanOrEqual(2);
162+
expect(totalFor(counts, "tool_policy")).toBeLessThanOrEqual(2);
163+
}),
164+
);
165+
166+
it.effect("sequential executes still behave (batch of one)", () =>
167+
Effect.gen(function* () {
168+
const { executor, counts } = yield* makeCountedExecutor();
169+
170+
counts.clear();
171+
const first = yield* executor.execute(addr("alpha"), { seq: 1 });
172+
const second = yield* executor.execute(addr("beta"), { seq: 2 });
173+
174+
expect(first).toEqual({ ran: "alpha", args: { seq: 1 } });
175+
expect(second).toEqual({ ran: "beta", args: { seq: 2 } });
176+
// Two sequential invokes = two windows: exactly the per-call point
177+
// queries the unbatched path made, no regression.
178+
expect(totalFor(counts, "tool")).toBe(2);
179+
expect(totalFor(counts, "connection")).toBe(2);
180+
}),
181+
);
182+
183+
it.effect("a missing tool inside a batch fails alone, peers succeed", () =>
184+
Effect.gen(function* () {
185+
const { executor } = yield* makeCountedExecutor();
186+
187+
const [ok, missing] = yield* Effect.all(
188+
[
189+
Effect.result(executor.execute(addr("alpha"), {})),
190+
Effect.result(executor.execute(addr("nope"), {})),
191+
],
192+
{ concurrency: "unbounded" },
193+
);
194+
195+
expect(Result.isSuccess(ok)).toBe(true);
196+
expect(Result.isFailure(missing)).toBe(true);
197+
if (!Result.isFailure(missing)) return;
198+
expect(Predicate.isTagged(missing.failure, "ToolNotFoundError")).toBe(true);
199+
}),
200+
);
201+
});

0 commit comments

Comments
 (0)