Skip to content

Commit aa9c25e

Browse files
committed
fix(sdk): transact bulk plugin storage writes
1 parent 1ca307d commit aa9c25e

2 files changed

Lines changed: 136 additions & 38 deletions

File tree

packages/core/sdk/src/executor.ts

Lines changed: 40 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -996,6 +996,8 @@ type LooseStorageDb = {
996996
const asLooseStorageDb = (db: unknown): LooseStorageDb => db as LooseStorageDb;
997997

998998
const makeCoreDb = (fuma: ReturnType<typeof makeFumaClient>) => ({
999+
transaction: <A, E>(effect: Effect.Effect<A, E>): Effect.Effect<A, E | StorageFailure> =>
1000+
fuma.transaction(effect),
9991001
count: <TName extends CoreTableName>(
10001002
tableName: TName,
10011003
options?: { readonly where?: CoreWhere },
@@ -1358,44 +1360,46 @@ const makePluginStorageFacade = (input: {
13581360
readonly data: unknown;
13591361
}[],
13601362
) =>
1361-
Effect.gen(function* () {
1362-
const os = ownerSubject(owner);
1363-
if (!os) {
1364-
return yield* new StorageError({
1365-
message: `Cannot write plugin storage for owner "user": executor has no subject.`,
1366-
cause: undefined,
1367-
});
1368-
}
1369-
const entriesById = new Map(
1370-
entries.map((entry) => [
1371-
pluginStorageId({
1372-
pluginId: input.pluginId,
1363+
input.core.transaction(
1364+
Effect.gen(function* () {
1365+
const os = ownerSubject(owner);
1366+
if (!os) {
1367+
return yield* new StorageError({
1368+
message: `Cannot write plugin storage for owner "user": executor has no subject.`,
1369+
cause: undefined,
1370+
});
1371+
}
1372+
const entriesById = new Map(
1373+
entries.map((entry) => [
1374+
pluginStorageId({
1375+
pluginId: input.pluginId,
1376+
collection: entry.collection,
1377+
key: entry.key,
1378+
}),
1379+
entry,
1380+
]),
1381+
);
1382+
const uniqueEntries = [...entriesById.values()];
1383+
if (uniqueEntries.length === 0) return;
1384+
1385+
const now = new Date();
1386+
yield* input.core.upsertMany("plugin_storage", {
1387+
target: ["tenant", "owner", "subject", "plugin_id", "collection", "key"],
1388+
update: ["data", "updated_at"],
1389+
values: uniqueEntries.map((entry) => ({
1390+
tenant,
1391+
owner: os.owner,
1392+
subject: os.subject,
1393+
plugin_id: input.pluginId,
13731394
collection: entry.collection,
13741395
key: entry.key,
1375-
}),
1376-
entry,
1377-
]),
1378-
);
1379-
const uniqueEntries = [...entriesById.values()];
1380-
if (uniqueEntries.length === 0) return;
1381-
1382-
const now = new Date();
1383-
yield* input.core.upsertMany("plugin_storage", {
1384-
target: ["tenant", "owner", "subject", "plugin_id", "collection", "key"],
1385-
update: ["data", "updated_at"],
1386-
values: uniqueEntries.map((entry) => ({
1387-
tenant,
1388-
owner: os.owner,
1389-
subject: os.subject,
1390-
plugin_id: input.pluginId,
1391-
collection: entry.collection,
1392-
key: entry.key,
1393-
data: entry.data,
1394-
created_at: now,
1395-
updated_at: now,
1396-
})),
1397-
});
1398-
});
1396+
data: entry.data,
1397+
created_at: now,
1398+
updated_at: now,
1399+
})),
1400+
});
1401+
}),
1402+
);
13991403

14001404
const removeManyImpl = (
14011405
owner: Owner,

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

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { describe, expect, it } from "@effect/vitest";
22
import { Cause, Effect, Exit, Schema } from "effect";
33

4-
import { StorageError } from "./fuma-runtime";
4+
import { createExecutor } from "./executor";
5+
import { StorageError, type FumaDb } from "./fuma-runtime";
56
import { Owner } from "./ids";
67
import { definePlugin } from "./plugin";
78
import {
@@ -10,7 +11,7 @@ import {
1011
type PluginStorageCollectionQueryInput,
1112
type PluginStorageCollectionWhere,
1213
} from "./plugin-storage";
13-
import { makeTestExecutor } from "./testing";
14+
import { makeTestConfig, makeTestExecutor } from "./testing";
1415

1516
const ToolCall = Schema.Struct({
1617
runId: Schema.String,
@@ -109,6 +110,48 @@ const call = (input: {
109110
durationMs: input.durationMs ?? 0,
110111
});
111112

113+
const failPluginStorageBulkWriteAfterFirstRow = (db: FumaDb): FumaDb => {
114+
const wrap = (source: FumaDb, failBulkWrite: boolean): FumaDb =>
115+
new Proxy(source, {
116+
get(target, property, receiver) {
117+
if (property === "withContext") {
118+
const withContext = target.withContext;
119+
return withContext === undefined
120+
? undefined
121+
: (context: unknown) => wrap(withContext(context), failBulkWrite);
122+
}
123+
if (property === "transaction") {
124+
const transaction: FumaDb["transaction"] = (run) =>
125+
target.transaction((transactionDb) => run(wrap(transactionDb, true)));
126+
return transaction;
127+
}
128+
if (property === "upsertMany" && failBulkWrite) {
129+
const upsertMany: FumaDb["upsertMany"] = async (table, options) => {
130+
if (table !== "plugin_storage" || options.values.length < 2) {
131+
return target.upsertMany(table, options);
132+
}
133+
134+
await target.upsertMany(table, {
135+
...options,
136+
values: options.values.slice(0, 1),
137+
});
138+
// oxlint-disable-next-line executor/no-promise-reject -- boundary: fault-injecting FumaDB adapter must reject to exercise transaction rollback
139+
return Promise.reject(
140+
new StorageError({
141+
message: "Injected plugin storage bulk-write failure.",
142+
cause: undefined,
143+
}),
144+
);
145+
};
146+
return upsertMany;
147+
}
148+
return Reflect.get(target, property, receiver);
149+
},
150+
});
151+
152+
return wrap(db, false);
153+
};
154+
112155
describe("plugin storage collections", () => {
113156
it.effect("queries declared indexes through the executor's SQLite FumaDB target", () =>
114157
Effect.gen(function* () {
@@ -218,6 +261,57 @@ describe("plugin storage collections", () => {
218261
}),
219262
);
220263

264+
it.effect("rolls back every plugin storage row when a bulk write fails", () =>
265+
Effect.gen(function* () {
266+
const config = makeTestConfig({
267+
backend: "sqlite",
268+
plugins: [executionHistoryPlugin] as const,
269+
});
270+
const executor = yield* Effect.acquireRelease(
271+
createExecutor({
272+
...config,
273+
db: failPluginStorageBulkWriteAfterFirstRow(config.db),
274+
}),
275+
(instance) =>
276+
instance
277+
.close()
278+
.pipe(
279+
Effect.ignore,
280+
Effect.andThen(Effect.promise(() => config.testDb.close()).pipe(Effect.ignore)),
281+
),
282+
);
283+
284+
const exit = yield* Effect.exit(
285+
executor.executionHistory.recordMany("org", [
286+
{
287+
key: "call-first",
288+
data: call({
289+
runId: "run-rollback",
290+
toolId: "browser",
291+
status: "ok",
292+
startedAt: "2026-05-29T12:00:00.000Z",
293+
}),
294+
},
295+
{
296+
key: "call-second",
297+
data: call({
298+
runId: "run-rollback",
299+
toolId: "shell",
300+
status: "ok",
301+
startedAt: "2026-05-29T12:01:00.000Z",
302+
}),
303+
},
304+
]),
305+
);
306+
expect(Exit.isFailure(exit)).toBe(true);
307+
308+
const stored = yield* executor.executionHistory.query({
309+
where: { runId: "run-rollback" },
310+
});
311+
expect(stored).toEqual([]);
312+
}),
313+
);
314+
221315
it.effect(
222316
"bulk puts large plugin storage row sets in bounded batches",
223317
() =>

0 commit comments

Comments
 (0)