Skip to content

Commit c35eaf3

Browse files
committed
Narrow plugin storage prefix reads in the database and scope OpenAPI operation scans
1 parent a0b0d91 commit c35eaf3

5 files changed

Lines changed: 332 additions & 141 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@executor-js/sdk": patch
3+
"@executor-js/plugin-openapi": patch
4+
---
5+
6+
Plugin storage key-prefix reads narrow in the database instead of loading the whole collection and filtering in memory. OpenAPI catalog rebuilds now read only the rebuilt integration's operations, decoding each once, where they previously loaded every OpenAPI integration's operations for each connection — the allocation that pushed Cloudflare-hosted sessions with large specs (for example Cloudflare's own API) past the Workers memory limit during tool search.

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

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1537,14 +1537,23 @@ const makePluginStorageFacade = (input: {
15371537
const tenant = String(input.owner.tenant);
15381538

15391539
const whereFor =
1540-
(collection: string, key?: string): CoreWhere =>
1540+
(collection: string, key?: string, keyPrefix?: string): CoreWhere =>
15411541
(b: AnyCb) =>
15421542
b.and(
15431543
b("plugin_id", "=", input.pluginId),
15441544
b("collection", "=", collection),
15451545
key === undefined ? true : b("key", "=", key),
1546+
keyPrefix === undefined ? true : b("key", "starts with", keyPrefix),
15461547
);
15471548

1549+
// `starts with` compiles to an unescaped LIKE on SQL adapters (and a
1550+
// case-insensitive one on SQLite), so the pushed-down prefix only narrows
1551+
// the read to a superset; `list` still applies the exact `startsWith`. A
1552+
// backslash is Postgres LIKE's default escape character and could turn the
1553+
// superset into a subset, so such prefixes are filtered in JS only.
1554+
const sqlKeyPrefix = (keyPrefix: string | undefined): string | undefined =>
1555+
keyPrefix === undefined || keyPrefix.includes("\\") ? undefined : keyPrefix;
1556+
15481557
const whereOwner = (owner: Owner, collection: string, key: string): CoreWhere => {
15491558
const os = ownerSubject(owner);
15501559
return (b: AnyCb) =>
@@ -1752,7 +1761,7 @@ const makePluginStorageFacade = (input: {
17521761
if (validationError) return yield* validationError;
17531762

17541763
const rows = yield* input.core.findMany("plugin_storage", {
1755-
where: whereFor(definition.name),
1764+
where: whereFor(definition.name, undefined, sqlKeyPrefix(queryInput?.keyPrefix)),
17561765
});
17571766
const filtered = sortByOwnerPrecedence(rows)
17581767
.filter((row) =>
@@ -1828,7 +1837,7 @@ const makePluginStorageFacade = (input: {
18281837
list: (storageInput) =>
18291838
Effect.gen(function* () {
18301839
const rows = yield* input.core.findMany("plugin_storage", {
1831-
where: whereFor(storageInput.collection),
1840+
where: whereFor(storageInput.collection, undefined, sqlKeyPrefix(storageInput.keyPrefix)),
18321841
});
18331842
return sortByOwnerPrecedence(rows)
18341843
.filter((row) =>

‎packages/core/sdk/src/plugin-storage.test.ts‎

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ const executionHistoryPlugin = definePlugin(() => ({
7979
owner,
8080
entries: keys.map((key) => ({ collection: toolCalls.name, key })),
8181
}),
82+
listByPrefix: (keyPrefix: string) =>
83+
ctx.pluginStorage.list({ collection: toolCalls.name, keyPrefix }),
8284
get: (key: string) => ctx.storage.toolCalls.get({ key }),
8385
getForOwner: (owner: Owner, key: string) => ctx.storage.toolCalls.getForOwner({ owner, key }),
8486
query: (input?: PluginStorageCollectionQueryInput<typeof toolCalls>) =>
@@ -163,6 +165,39 @@ const failPluginStorageBulkWriteAfterFirstRow = (db: FumaDb): FumaDb => {
163165
return wrap(db);
164166
};
165167

168+
// Records how many `plugin_storage` rows each adapter read hands back, so a
169+
// test can tell a prefix applied in SQL from one applied after loading every
170+
// row of the collection into memory.
171+
const countPluginStorageReads = (db: FumaDb, rowCounts: number[]): FumaDb => {
172+
const wrap = (source: FumaDb): FumaDb =>
173+
new Proxy(source, {
174+
get(target, property, receiver) {
175+
if (property === "withContext") {
176+
const withContext = target.withContext;
177+
return withContext === undefined
178+
? undefined
179+
: (context: unknown) => wrap(withContext(context));
180+
}
181+
if (property === "transaction") {
182+
const transaction: FumaDb["transaction"] = (run) =>
183+
target.transaction((transactionDb) => run(wrap(transactionDb)));
184+
return transaction;
185+
}
186+
if (property === "findMany") {
187+
const findMany: FumaDb["findMany"] = async (table, options) => {
188+
const rows = await target.findMany(table, options);
189+
if (table === "plugin_storage") rowCounts.push(rows.length);
190+
return rows;
191+
};
192+
return findMany;
193+
}
194+
return Reflect.get(target, property, receiver);
195+
},
196+
});
197+
198+
return wrap(db);
199+
};
200+
166201
describe("plugin storage collections", () => {
167202
it.effect("queries declared indexes through the executor's SQLite FumaDB target", () =>
168203
Effect.gen(function* () {
@@ -524,4 +559,72 @@ describe("plugin storage collections", () => {
524559
});
525560
}),
526561
);
562+
563+
it.effect("narrows key-prefix reads in storage and keeps the result exact", () =>
564+
Effect.gen(function* () {
565+
const config = makeTestConfig({
566+
backend: "sqlite",
567+
plugins: [executionHistoryPlugin] as const,
568+
});
569+
const rowCounts: number[] = [];
570+
const executor = yield* Effect.acquireRelease(
571+
createExecutor({ ...config, db: countPluginStorageReads(config.db, rowCounts) }),
572+
(instance) =>
573+
instance
574+
.close()
575+
.pipe(
576+
Effect.ignore,
577+
Effect.andThen(Effect.promise(() => config.testDb.close()).pipe(Effect.ignore)),
578+
),
579+
);
580+
581+
const keys = [
582+
"op.abc.1",
583+
"op.abc.2",
584+
"op.abd.3",
585+
// `_` and `%` are LIKE wildcards and SQLite LIKE ignores ASCII case, so
586+
// each exact key below has look-alikes a naive pushdown would return.
587+
"cloudflare_com.a",
588+
"cloudflareXcom.b",
589+
"CLOUDFLARE_COM.c",
590+
"cloudflare%com.d",
591+
"cloudflare-com.e",
592+
// A backslash is Postgres LIKE's default escape character.
593+
"back\\slash.f",
594+
"backslash.g",
595+
...Array.from({ length: 40 }, (_, index) => `filler-${String(index).padStart(2, "0")}`),
596+
];
597+
yield* executor.executionHistory.recordMany(
598+
"org",
599+
keys.map((key, index) => ({
600+
key,
601+
data: call({
602+
runId: "run-prefix",
603+
toolId: key,
604+
status: "ok",
605+
startedAt: new Date(Date.UTC(2026, 4, 29, 13, 0, index)).toISOString(),
606+
}),
607+
})),
608+
);
609+
610+
const listed = (keyPrefix: string) =>
611+
executor.executionHistory
612+
.listByPrefix(keyPrefix)
613+
.pipe(Effect.map((rows) => rows.map((row) => row.key).sort()));
614+
615+
rowCounts.length = 0;
616+
expect(yield* listed("op.abc.")).toEqual(["op.abc.1", "op.abc.2"]);
617+
// Only the matching rows left storage; the other 48 were never loaded.
618+
expect(rowCounts).toEqual([2]);
619+
620+
expect(yield* listed("cloudflare_com.")).toEqual(["cloudflare_com.a"]);
621+
expect(yield* listed("cloudflare%com.")).toEqual(["cloudflare%com.d"]);
622+
expect(yield* listed("back\\slash.")).toEqual(["back\\slash.f"]);
623+
624+
rowCounts.length = 0;
625+
const queried = yield* executor.executionHistory.query({ keyPrefix: "op.abc." });
626+
expect(queried.map((entry) => entry.key).sort()).toEqual(["op.abc.1", "op.abc.2"]);
627+
expect(rowCounts).toEqual([2]);
628+
}),
629+
);
527630
});

0 commit comments

Comments
 (0)