Skip to content

Commit 861545e

Browse files
committed
Rebuild tool catalogs by upsert-then-prune so reads never see an emptied catalog
1 parent 08203f3 commit 861545e

4 files changed

Lines changed: 477 additions & 13 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+
Tool-catalog rebuilds no longer empty the catalog while they run. A rebuild now upserts the new tool and definition rows and then prunes only the names the upstream stopped listing, instead of deleting every row and re-inserting. On databases without interactive transactions (Cloudflare D1), each statement commits on its own, so a search during a rebuild used to find zero tools for that connection, and a rebuild cut off partway (or overlapping another session's rebuild) left the catalog partial; both now keep a complete catalog, and an interrupted rebuild stays stale and retries. Catalog rows are written in size-bounded calls, so a large spec's catalog (Cloudflare's own API) no longer exceeds D1's 32MiB batch limit.
Lines changed: 383 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,383 @@
1+
import { describe, expect, it } from "@effect/vitest";
2+
import { Effect, Fiber } from "effect";
3+
import { withQueryContext } from "@executor-js/fumadb/query";
4+
5+
import { collectTables, createExecutor } from "./executor";
6+
import { createExecutorFumaDb } from "./executor-fuma-db";
7+
import { StorageError, type FumaDb } from "./fuma-runtime";
8+
import {
9+
AuthTemplateSlug,
10+
ConnectionName,
11+
IntegrationSlug,
12+
ProviderItemId,
13+
ProviderKey,
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 { makeTestConfig } from "./testing";
20+
21+
// ---------------------------------------------------------------------------
22+
// Catalog rebuilds on an auto-commit adapter.
23+
//
24+
// Cloudflare D1 has no interactive transactions (`interactiveTransactions:
25+
// false`), so every statement of a catalog rebuild commits on its own and is
26+
// visible to concurrent reads, and a rebuild cut off partway leaves exactly
27+
// the statements that ran. These cases run the rebuild on that adapter shape
28+
// and pin what a reader and a failed rebuild may observe.
29+
// ---------------------------------------------------------------------------
30+
31+
const INTEG = IntegrationSlug.make("catalog");
32+
const TEMPLATE = AuthTemplateSlug.make("apiKey");
33+
const MAIN = ConnectionName.make("main");
34+
35+
interface CatalogTool {
36+
readonly name: string;
37+
readonly description: string;
38+
}
39+
40+
const memoryProvider = (): CredentialProvider => {
41+
const store = new Map<string, string>();
42+
return {
43+
key: ProviderKey.make("memory"),
44+
writable: true,
45+
get: (id) => Effect.sync(() => store.get(String(id)) ?? null),
46+
set: (id, value) => Effect.sync(() => void store.set(String(id), value)),
47+
has: (id) => Effect.sync(() => store.has(String(id))),
48+
list: () =>
49+
Effect.sync(() =>
50+
Array.from(store.keys()).map((key) => ({ id: ProviderItemId.make(key), name: key })),
51+
),
52+
};
53+
};
54+
55+
// D1 runs a multi-statement bulk write as one native batch RPC and rejects it
56+
// past 32MiB. The harness adapter enforces the same kind of cap, scaled down
57+
// so a test catalog can cross it.
58+
const BULK_WRITE_CAP_BYTES = 2 * 1024 * 1024;
59+
60+
// Hooks the tests arm on the adapter handle the executor writes through.
61+
interface PersistHooks {
62+
// Pause right after the rebuild's first write to `tool` commits.
63+
pause: {
64+
readonly reached: PromiseWithResolvers<void>;
65+
readonly release: PromiseWithResolvers<void>;
66+
} | null;
67+
// Reject every write of new `tool` rows (inserts and upserts; deletes
68+
// still run), as when a rebuild is cut off before its rows land.
69+
failToolInserts: boolean;
70+
// Tables each `deleteMany` targeted.
71+
readonly deletes: string[];
72+
// Resolved when a connection's `tools_synced_at` is stamped.
73+
stamped: PromiseWithResolvers<void> | null;
74+
}
75+
76+
const hookCatalogWrites = (db: FumaDb, hooks: PersistHooks): FumaDb => {
77+
const toolWrite = async <T>(
78+
table: string,
79+
write: () => Promise<T>,
80+
kind: "insert" | "delete",
81+
rows: readonly unknown[] = [],
82+
): Promise<T> => {
83+
if (JSON.stringify(rows).length > BULK_WRITE_CAP_BYTES) {
84+
// oxlint-disable-next-line executor/no-promise-reject -- boundary: fault-injecting FumaDB adapter must reject to emulate D1's batch payload cap
85+
return Promise.reject(
86+
new StorageError({
87+
message: "Bulk write exceeds the batch payload cap.",
88+
cause: undefined,
89+
}),
90+
);
91+
}
92+
if (table === "tool" && kind === "insert" && hooks.failToolInserts) {
93+
// oxlint-disable-next-line executor/no-promise-reject -- boundary: fault-injecting FumaDB adapter must reject to exercise a failed rebuild
94+
return Promise.reject(
95+
new StorageError({ message: "Injected tool write failure.", cause: undefined }),
96+
);
97+
}
98+
const result = await write();
99+
const pause = table === "tool" ? hooks.pause : null;
100+
if (pause) {
101+
hooks.pause = null;
102+
pause.reached.resolve();
103+
await pause.release.promise;
104+
}
105+
return result;
106+
};
107+
// The proxy target is an empty stand-in: the ORM handle's own `withContext`
108+
// is a non-configurable property, which a Proxy over it may not replace.
109+
const wrap = (source: FumaDb): FumaDb =>
110+
new Proxy({} as FumaDb, {
111+
get(_target, property) {
112+
if (property === "withContext") {
113+
const withContext = source.withContext?.bind(source);
114+
return withContext === undefined
115+
? undefined
116+
: (context: unknown) => wrap(withContext(context));
117+
}
118+
if (property === "transaction") {
119+
const transaction: FumaDb["transaction"] = (run) =>
120+
source.transaction((transactionDb) => run(wrap(transactionDb)));
121+
return transaction;
122+
}
123+
if (property === "createMany") {
124+
const createMany: FumaDb["createMany"] = (table, rows) =>
125+
toolWrite(table, () => source.createMany(table, rows), "insert", rows);
126+
return createMany;
127+
}
128+
if (property === "upsertMany") {
129+
const upsertMany: FumaDb["upsertMany"] = (table, options) =>
130+
toolWrite(table, () => source.upsertMany(table, options), "insert", options.values);
131+
return upsertMany;
132+
}
133+
if (property === "deleteMany") {
134+
const deleteMany: FumaDb["deleteMany"] = (table, options) => {
135+
hooks.deletes.push(table);
136+
return toolWrite(table, () => source.deleteMany(table, options), "delete");
137+
};
138+
return deleteMany;
139+
}
140+
if (property === "updateMany") {
141+
const updateMany: FumaDb["updateMany"] = async (table, options) => {
142+
await source.updateMany(table, options);
143+
if (table === "connection" && options.set.tools_synced_at != null) {
144+
hooks.stamped?.resolve();
145+
}
146+
};
147+
return updateMany;
148+
}
149+
const value: unknown = Reflect.get(source, property);
150+
return typeof value === "function" ? value.bind(source) : value;
151+
},
152+
});
153+
return wrap(db);
154+
};
155+
156+
const makeCatalogExecutor = (options: { readonly toolsSyncGraceMs: number | null }) =>
157+
Effect.gen(function* () {
158+
const catalog: { tools: readonly CatalogTool[]; definitions: Record<string, unknown> } = {
159+
tools: [],
160+
definitions: {},
161+
};
162+
const plugin = definePlugin(() => ({
163+
id: "catalog" as const,
164+
credentialProviders: [memoryProvider()],
165+
storage: () => ({}),
166+
resolveTools: () =>
167+
Effect.sync(() => ({
168+
tools: catalog.tools.map((tool) => ({
169+
name: ToolName.make(tool.name),
170+
description: tool.description,
171+
})),
172+
definitions: catalog.definitions,
173+
})),
174+
invokeTool: ({ toolRow }) => Effect.succeed({ ran: toolRow.name }),
175+
extension: (ctx) => ({
176+
seed: () =>
177+
ctx.core.integrations.register({ slug: INTEG, description: "Catalog", config: {} }),
178+
}),
179+
}))();
180+
181+
const config = makeTestConfig({ plugins: [plugin] as const });
182+
const sqlite = yield* Effect.acquireRelease(
183+
Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })),
184+
(handle) => Effect.promise(() => handle.close()),
185+
);
186+
// The D1 host's adapter options (apps/host-cloudflare/src/db/d1.ts).
187+
const d1 = createExecutorFumaDb(sqlite.drizzle, {
188+
tables: collectTables(),
189+
namespace: "executor_test",
190+
version: "1.0.0",
191+
provider: "sqlite",
192+
interactiveTransactions: false,
193+
maxBoundParameters: 100,
194+
});
195+
const hooks: PersistHooks = { pause: null, failToolInserts: false, deletes: [], stamped: null };
196+
// Hooks sit under the query context: the context wrapper's own
197+
// `withContext` is non-configurable, so it cannot be proxied itself.
198+
const db = withQueryContext(hookCatalogWrites(d1.db as FumaDb, hooks), {
199+
tenant: "test-tenant",
200+
subject: "test-subject",
201+
}) as FumaDb;
202+
const executor = yield* Effect.acquireRelease(
203+
createExecutor({
204+
...config,
205+
db,
206+
toolsSyncGraceMs: options.toolsSyncGraceMs,
207+
}),
208+
(instance) => instance.close().pipe(Effect.ignore),
209+
);
210+
211+
const markStale = Effect.promise(() =>
212+
db.updateMany("connection", {
213+
where: (b) => b.and(b("integration", "=", String(INTEG)), b("name", "=", String(MAIN))),
214+
set: { tools_synced_at: null },
215+
}),
216+
);
217+
const syncedAt = Effect.promise(() =>
218+
db.findFirst("connection", {
219+
where: (b) => b.and(b("integration", "=", String(INTEG)), b("name", "=", String(MAIN))),
220+
}),
221+
).pipe(Effect.map((row) => row?.tools_synced_at ?? null));
222+
const listTools = executor.tools
223+
.list({ integration: INTEG })
224+
.pipe(
225+
Effect.map((tools) =>
226+
tools.map((tool) => ({ name: String(tool.name), description: tool.description })),
227+
),
228+
);
229+
const definitions = Effect.promise(() =>
230+
db.findMany("definition", { where: (b) => b("integration", "=", String(INTEG)) }),
231+
).pipe(
232+
Effect.map((rows) =>
233+
Object.fromEntries(rows.map((row) => [String(row.name), row.schema] as const)),
234+
),
235+
);
236+
237+
const seed = (tools: readonly CatalogTool[], defs: Record<string, unknown>) =>
238+
Effect.gen(function* () {
239+
catalog.tools = tools;
240+
catalog.definitions = defs;
241+
yield* executor.catalog.seed();
242+
yield* executor.connections.create({
243+
owner: "org",
244+
name: MAIN,
245+
integration: INTEG,
246+
template: TEMPLATE,
247+
value: "secret-token",
248+
});
249+
});
250+
251+
return { catalog, hooks, seed, markStale, syncedAt, listTools, definitions };
252+
});
253+
254+
const byName = (tools: readonly CatalogTool[]) =>
255+
[...tools].sort((left, right) => left.name.localeCompare(right.name));
256+
257+
describe("catalog rebuild on an auto-commit adapter", () => {
258+
it.effect("a read during a rebuild sees the full catalog, and the rebuild lands exactly", () =>
259+
Effect.scoped(
260+
Effect.gen(function* () {
261+
// No grace budget: reads answer from the persisted rows immediately
262+
// while the rebuild runs detached, which is what lets a read land in
263+
// the middle of it.
264+
const harness = yield* makeCatalogExecutor({ toolsSyncGraceMs: 0 });
265+
yield* harness.seed(
266+
[
267+
{ name: "deploy", description: "deploy v1" },
268+
{ name: "list", description: "list" },
269+
{ name: "legacy", description: "legacy" },
270+
],
271+
{ Shared: { type: "string" }, LegacyOnly: { type: "number" } },
272+
);
273+
expect(byName(yield* harness.listTools).map((tool) => tool.name)).toEqual([
274+
"deploy",
275+
"legacy",
276+
"list",
277+
]);
278+
279+
// The upstream now drops `legacy`, adds `fresh`, and revises `deploy`
280+
// and the `Shared` definition.
281+
harness.catalog.tools = [
282+
{ name: "deploy", description: "deploy v2" },
283+
{ name: "list", description: "list" },
284+
{ name: "fresh", description: "fresh" },
285+
];
286+
harness.catalog.definitions = { Shared: { type: "boolean" }, FreshOnly: { type: "null" } };
287+
const pause = {
288+
reached: Promise.withResolvers<void>(),
289+
release: Promise.withResolvers<void>(),
290+
};
291+
harness.hooks.pause = pause;
292+
harness.hooks.stamped = Promise.withResolvers<void>();
293+
yield* harness.markStale;
294+
295+
const trigger = yield* Effect.forkChild(harness.listTools);
296+
yield* Effect.promise(() => pause.reached.promise);
297+
298+
// Mid-rebuild: every tool of the previous catalog is still listed.
299+
const during = (yield* harness.listTools).map((tool) => tool.name);
300+
expect(during).toEqual(expect.arrayContaining(["deploy", "legacy", "list"]));
301+
302+
pause.release.resolve();
303+
yield* Fiber.join(trigger);
304+
yield* Effect.promise(() => harness.hooks.stamped!.promise);
305+
306+
expect(byName(yield* harness.listTools)).toEqual([
307+
{ name: "deploy", description: "deploy v2" },
308+
{ name: "fresh", description: "fresh" },
309+
{ name: "list", description: "list" },
310+
]);
311+
expect(yield* harness.definitions).toEqual({
312+
Shared: { type: "boolean" },
313+
FreshOnly: { type: "null" },
314+
});
315+
expect(yield* harness.syncedAt).not.toBeNull();
316+
}),
317+
),
318+
);
319+
320+
it.effect("rebuilding an unchanged catalog deletes nothing", () =>
321+
Effect.scoped(
322+
Effect.gen(function* () {
323+
const harness = yield* makeCatalogExecutor({ toolsSyncGraceMs: null });
324+
const tools = [
325+
{ name: "deploy", description: "deploy" },
326+
{ name: "list", description: "list" },
327+
];
328+
yield* harness.seed(tools, { Shared: { type: "string" } });
329+
330+
harness.hooks.deletes.length = 0;
331+
yield* harness.markStale;
332+
expect(byName(yield* harness.listTools)).toEqual(tools);
333+
334+
expect(harness.hooks.deletes).toEqual([]);
335+
expect(yield* harness.syncedAt).not.toBeNull();
336+
}),
337+
),
338+
);
339+
340+
it.effect("a rebuild that fails partway keeps every row and stays stale", () =>
341+
Effect.scoped(
342+
Effect.gen(function* () {
343+
const harness = yield* makeCatalogExecutor({ toolsSyncGraceMs: null });
344+
const tools = [
345+
{ name: "deploy", description: "deploy" },
346+
{ name: "list", description: "list" },
347+
];
348+
yield* harness.seed(tools, { Shared: { type: "string" } });
349+
350+
harness.catalog.tools = [{ name: "fresh", description: "fresh" }];
351+
harness.catalog.definitions = { FreshOnly: { type: "null" } };
352+
harness.hooks.failToolInserts = true;
353+
yield* harness.markStale;
354+
355+
// The failed rebuild is logged and swallowed; the read answers from
356+
// whatever rows the rebuild left behind.
357+
expect(byName(yield* harness.listTools)).toEqual(tools);
358+
expect(yield* harness.syncedAt).toBeNull();
359+
}),
360+
),
361+
);
362+
363+
it.effect("a catalog larger than one bulk-write payload still lands in full", () =>
364+
Effect.scoped(
365+
Effect.gen(function* () {
366+
const harness = yield* makeCatalogExecutor({ toolsSyncGraceMs: null });
367+
yield* harness.seed([{ name: "deploy", description: "deploy" }], {});
368+
369+
// ~3MB of tool rows: past the adapter's per-call payload cap, the way
370+
// Cloudflare's own API catalog is past D1's.
371+
harness.catalog.tools = Array.from({ length: 400 }, (_, index) => ({
372+
name: `op_${String(index).padStart(3, "0")}`,
373+
description: "x".repeat(8 * 1024),
374+
}));
375+
yield* harness.markStale;
376+
377+
const tools = yield* harness.listTools;
378+
expect(tools).toHaveLength(400);
379+
expect(yield* harness.syncedAt).not.toBeNull();
380+
}),
381+
),
382+
);
383+
});

0 commit comments

Comments
 (0)