diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e2bda44..983c2731 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +- When a step fails due to an oversized result, the result is saved in file storage. The value can be read with `oversizedValues.read` for debugging. Workflow cleanup and restart delete stored values. +- Requires Convex 1.48 or newer for file storage access in mutations. + ## 0.4.10 - Updates the /test entrypoint for compatibility with convex-test's new diff --git a/README.md b/README.md index a813df40..480026d3 100644 --- a/README.md +++ b/README.md @@ -838,7 +838,7 @@ Here are a few limitations to keep in mind: - Steps can only take in and return a total of _1 MB_ of data within a single workflow execution. If you run into journal size limits, you can work around this by storing results in the database or file storage from your step - functions and passing IDs around within the the workflow. + functions and passing IDs around within the the workflow. Oversized step results are stored in file storage when possible and can be read with `oversizedValues.read` for debugging. - The workflow body is internally a mutation, with each step's return value read from the database on each subsequent step. As a result, the limits for a mutation apply and limit the number and size of steps you can perform diff --git a/example/convex/oversized.test.ts b/example/convex/oversized.test.ts index 45444e13..ec450f27 100644 --- a/example/convex/oversized.test.ts +++ b/example/convex/oversized.test.ts @@ -7,6 +7,12 @@ import { assert } from "convex-helpers"; import { getStatus } from "@convex-dev/workflow"; import { workflow } from "./example"; +function storedStepId(error: string) { + const stepId = error.match(/stepId: "([^"]+)"/)?.[1]; + assert(stepId, `No retrieval hint in: ${error}`); + return stepId; +} + describe("oversized values", () => { beforeEach(() => { vi.useFakeTimers(); @@ -55,6 +61,16 @@ describe("oversized values", () => { expect(flow.out).not.toBeNull(); expect(flow.out.kind).toBe("failed"); expect(flow.out.error).toContain("Step return value too large"); + + const stepId = storedStepId(status.error); + expect( + await t.mutation(components.workflow.oversizedValues.read, { stepId }), + ).toBe("x".repeat(900_000)); + await t.mutation(components.workflow.oversizedValues.remove, { stepId }); + await expect( + t.mutation(components.workflow.oversizedValues.read, { stepId }), + ).rejects.toThrow("No oversized value found"); + await t.mutation(components.workflow.oversizedValues.remove, { stepId }); }); test("large event value fails workflow and calls onComplete", async () => { @@ -107,5 +123,100 @@ describe("oversized values", () => { expect(flow!.out).not.toBeNull(); expect(flow!.out.kind).toBe("failed"); expect(flow!.out.error).toContain("Step return value too large"); + + const stepId = storedStepId(status2.error); + expect( + await t.mutation(components.workflow.oversizedValues.read, { stepId }), + ).toBe("y".repeat(900_000)); + expect( + await t.mutation(components.workflow.workflow.cleanup, { workflowId }), + ).toBe(true); + }); + + test("stores an oversized pre-sent event value", async () => { + const t = initConvexTest(); + const workflowId = await t.mutation(async (ctx) => { + const id = await workflow.start( + ctx, + internal.oversized.eventWorkflow, + {}, + { + startAsync: true, + }, + ); + await ctx.runMutation(internal.oversized.sendBigEvent, { + workflowId: id, + }); + return id; + }); + await t.finishAllScheduledFunctions(vi.runAllTimers); + const status = await t.run((ctx) => + getStatus(ctx, components.workflow, workflowId), + ); + assert(status.type === "failed"); + expect(status.error).toContain("Step return value too large"); + const stepId = storedStepId(status.error); + expect( + await t.mutation(components.workflow.oversizedValues.read, { stepId }), + ).toBe("y".repeat(900_000)); + }); + + test("a batch of 11 oversized pre-sent events fails the workflow", async () => { + const t = initConvexTest(); + const workflowId = await t.mutation(async (ctx) => { + const id = await workflow.start( + ctx, + internal.oversized.eventBatchWorkflow, + {}, + { startAsync: true }, + ); + await ctx.runMutation(internal.oversized.sendEventBatch, { + workflowId: id, + }); + return id; + }); + await t.finishAllScheduledFunctions(vi.runAllTimers); + const status = await t.run((ctx) => + getStatus(ctx, components.workflow, workflowId), + ); + assert(status.type === "failed"); + expect(status.error).toContain("Step return value too large"); + }); + + test("a queued result exceeding the file byte budget fails and calls onComplete", async () => { + const t = initConvexTest(); + const workflowId = await t.mutation(async (ctx) => { + const id = await workflow.start( + ctx, + internal.oversized.overStorageLimitWorkflow, + {}, + { + startAsync: true, + onComplete: internal.oversized.onComplete, + context: {}, + }, + ); + await ctx.db.insert("flows", { + workflowId: id, + in: "over storage limit", + out: null, + }); + return id; + }); + await t.finishAllScheduledFunctions(vi.runAllTimers); + const status = await t.run((ctx) => + getStatus(ctx, components.workflow, workflowId), + ); + assert(status.type === "failed"); + expect(status.error).toContain("3000002 bytes"); + expect(status.error).toContain("Preview:"); + expect(status.error).not.toContain("Retrieve it"); + const flow = await t.query((ctx) => + ctx.db + .query("flows") + .withIndex("workflowId", (q) => q.eq("workflowId", workflowId)) + .unique(), + ); + expect(flow?.out.kind).toBe("failed"); }); }); diff --git a/example/convex/oversized.ts b/example/convex/oversized.ts index 18a544e2..3d805191 100644 --- a/example/convex/oversized.ts +++ b/example/convex/oversized.ts @@ -27,6 +27,19 @@ export const largeReturnWorkflow = workflow return result; }); +export const overStorageLimitAction = internalAction({ + args: {}, + returns: v.string(), + // Escaping NUL characters produces an 18 MB JSON blob. + handler: async (): Promise => "\0".repeat(3_000_000), +}); + +export const overStorageLimitWorkflow = workflow + .define({ args: {} }) + .handler(async (step) => { + return await step.runAction(internal.oversized.overStorageLimitAction, {}); + }); + export const bigEvent = defineEvent({ name: "bigEvent", validator: v.string(), @@ -41,6 +54,29 @@ export const eventWorkflow = workflow return result; }); +export const eventBatchWorkflow = workflow + .define({ args: {} }) + .handler(async (step) => { + await Promise.all( + Array.from({ length: 11 }, (_, i) => + step.awaitEvent({ name: `batch-${i}` }), + ), + ); + }); + +export const sendEventBatch = internalMutation({ + args: { workflowId: vWorkflowId }, + handler: async (ctx, { workflowId }) => { + for (let i = 0; i < 11; i++) { + await sendEvent(ctx, components.workflow, { + workflowId, + name: `batch-${i}`, + value: "z".repeat(900_000), + }); + } + }, +}); + export const sendBigEvent = internalMutation({ args: { workflowId: vWorkflowId, diff --git a/package-lock.json b/package-lock.json index 2f7f8658..d1b43a54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,9 +21,9 @@ "@typescript-eslint/eslint-plugin": "8.71.1", "@typescript-eslint/parser": "8.71.1", "chokidar-cli": "3.0.0", - "convex": "1.46.0", + "convex": "1.48.0", "convex-helpers": "0.1.128", - "convex-test": "0.0.64", + "convex-test": "0.0.67", "cpy-cli": "7.0.0", "eslint": "10.12.0", "eslint-plugin-react-hooks": "7.1.1", @@ -40,7 +40,7 @@ }, "peerDependencies": { "@convex-dev/workpool": "^0.4.4", - "convex": "^1.46.0", + "convex": "^1.48.0", "convex-helpers": "^0.1.127" } }, @@ -2573,9 +2573,9 @@ "license": "MIT" }, "node_modules/convex": { - "version": "1.46.0", - "resolved": "https://registry.npmjs.org/convex/-/convex-1.46.0.tgz", - "integrity": "sha512-dhBwDQTZDOhi5SUGAX17EWNehCdNq6/UFrSL0eLZuGfMkdsQbCVWVRZCzbZ5CF+/pgvoGxtApbEoTSipqL4JTw==", + "version": "1.48.0", + "resolved": "https://registry.npmjs.org/convex/-/convex-1.48.0.tgz", + "integrity": "sha512-M9jEgTvCN8ziP/VgQQUH+j0ijM7g+NmQd8tFlvETK8/HMC/aaqHZwcu4hBO30X5DV2VIyYK4mOoLQGIb1766ew==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2647,9 +2647,9 @@ } }, "node_modules/convex-test": { - "version": "0.0.64", - "resolved": "https://registry.npmjs.org/convex-test/-/convex-test-0.0.64.tgz", - "integrity": "sha512-HvaFwCA7CO3i9sBWkP/+HUpCad49zXitDLheLpt+TUOHVWhsWQ7hbPAyPTOFhHZJrodzngkCP/tW9/8uDSOJqQ==", + "version": "0.0.67", + "resolved": "https://registry.npmjs.org/convex-test/-/convex-test-0.0.67.tgz", + "integrity": "sha512-UCAzL8pwo4akHs6dppAU3Rbo2Gzdp1KZp9kcsLGFBaL7JuXOfUZFxe16ywQjnyDZ+uQhawjTRx2j6Hd8FoBm5Q==", "dev": true, "license": "Apache-2.0", "peerDependencies": { diff --git a/package.json b/package.json index 281fdaa6..8af003ef 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ }, "peerDependencies": { "@convex-dev/workpool": "^0.4.4", - "convex": "^1.46.0", + "convex": "^1.48.0", "convex-helpers": "^0.1.127" }, "dependencies": { @@ -76,9 +76,9 @@ "@typescript-eslint/eslint-plugin": "8.71.1", "@typescript-eslint/parser": "8.71.1", "chokidar-cli": "3.0.0", - "convex": "1.46.0", + "convex": "1.48.0", "convex-helpers": "0.1.128", - "convex-test": "0.0.64", + "convex-test": "0.0.67", "cpy-cli": "7.0.0", "eslint": "10.12.0", "eslint-plugin-react-hooks": "7.1.1", diff --git a/src/component/_generated/component.ts b/src/component/_generated/component.ts index 3a52bea4..a60d58a6 100644 --- a/src/component/_generated/component.ts +++ b/src/component/_generated/component.ts @@ -303,6 +303,22 @@ export type ComponentApi = Name >; }; + oversizedValues: { + read: FunctionReference< + "mutation", + "internal", + { stepId: string }, + any, + Name + >; + remove: FunctionReference< + "mutation", + "internal", + { stepId: string }, + null, + Name + >; + }; workflow: { cancel: FunctionReference< "mutation", diff --git a/src/component/event.ts b/src/component/event.ts index 4e08ec7f..c4cd1ec0 100644 --- a/src/component/event.ts +++ b/src/component/event.ts @@ -6,7 +6,7 @@ import { vResultValidator } from "@convex-dev/workpool"; import type { Doc, Id } from "./_generated/dataModel.js"; import { assert } from "convex-helpers"; import { enqueueWorkflow, getWorkpool, workpoolOptions } from "./pool.js"; -import { checkForOversizedResult } from "./oversizedValues.js"; +import { storeOversizedResult } from "./oversizedValues.js"; export async function awaitEvent( ctx: MutationCtx, @@ -41,7 +41,11 @@ export async function awaitEvent( stepId: entry._id, }, }); - entry.step.runResult = checkForOversizedResult(event.state.result); + entry.step.runResult = await storeOversizedResult( + ctx, + event.state.result, + { stepId: entry._id }, + ); entry.step.inProgress = false; entry.step.completedAt = Date.now(); break; @@ -147,7 +151,9 @@ export const send = mutation({ ); assert(step.step.kind === "event", "Step is not an event"); step.step.eventId = event._id; - step.step.runResult = checkForOversizedResult(args.result); + step.step.runResult = await storeOversizedResult(ctx, args.result, { + stepId: step._id, + }); step.step.inProgress = false; step.step.completedAt = Date.now(); await ctx.db.replace("steps", step._id, step); diff --git a/src/component/oversizedValues.test.ts b/src/component/oversizedValues.test.ts new file mode 100644 index 00000000..73024b55 --- /dev/null +++ b/src/component/oversizedValues.test.ts @@ -0,0 +1,233 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { convexToJson, v } from "convex/values"; +import { api } from "./_generated/api.js"; +import { initConvexTest } from "./setup.test.js"; +import { storeOversizedResult } from "./oversizedValues.js"; +import type { TransactionLimits } from "convex/server"; +import { internalMutation } from "./_generated/server.js"; + +async function setup( + limits?: Pick, +) { + const t = initConvexTest({ transactionLimits: limits ?? true }); + const ids = await t.run(async (ctx) => { + const workflowId = await ctx.db.insert("workflows", { + name: "oversized result", + workflowHandle: "function://;oversizedValues.test:noop", + args: {}, + generationNumber: 0, + }); + const stepId = await ctx.db.insert("steps", { + workflowId, + stepNumber: 0, + step: { + kind: "function", + functionType: "action", + handle: "function://;oversizedValues.test:noop", + name: "large result", + args: {}, + argsSize: 2, + inProgress: true, + startedAt: Date.now(), + }, + }); + return { workflowId, stepId }; + }); + return { t, ...ids }; +} + +async function completedOversizedStep() { + const fixture = await setup(); + const { t, workflowId, stepId } = fixture; + const value = "x".repeat(900_000); + const storageId = await t.run(async (ctx) => { + const runResult = await storeOversizedResult( + ctx, + { kind: "success", returnValue: value }, + { stepId }, + ); + const entry = await ctx.db.get("steps", stepId); + if (!entry) throw new Error("Missing fixture step"); + await ctx.db.patch("steps", stepId, { + step: { + ...entry.step, + inProgress: false, + completedAt: Date.now(), + runResult, + }, + }); + await ctx.db.patch("workflows", workflowId, { runResult }); + const record = await ctx.db + .query("oversizedValues") + .withIndex("stepId", (q) => q.eq("stepId", stepId)) + .unique(); + if (!record) throw new Error("Missing stored value"); + return record.storageId; + }); + return { ...fixture, storageId, value }; +} + +afterEach(() => vi.restoreAllMocks()); + +describe("oversized result storage", () => { + test("uses Blob.size and accepts the exact remaining byte budget", async () => { + const value = { + text: "😀".repeat(220_000), + bytes: new Uint8Array([0, 255]).buffer, + bigint: 42n, + infinity: Infinity, + }; + const json = JSON.stringify(convexToJson(value)); + const size = new Blob([json]).size; + expect(size).toBeGreaterThan(json.length); + const { t, stepId } = await setup({ + filesWritten: 1, + fileWriteBytes: size, + }); + const result = await t.run(async (ctx) => { + return storeOversizedResult( + ctx, + { kind: "success", returnValue: value }, + { stepId }, + ); + }); + expect(result.kind).toBe("failed"); + if (result.kind === "failed") { + expect(result.error).toContain(`stepId: "${stepId}"`); + expect(result.error).not.toContain("Preview:"); + } + expect(await t.mutation(api.oversizedValues.read, { stepId })).toEqual( + value, + ); + await t.run(async (ctx) => { + const files = await ctx.db.system.query("_storage").take(2); + expect(files).toHaveLength(1); + expect(files[0].size).toBe(size); + expect(await ctx.db.query("oversizedValues").take(2)).toHaveLength(1); + }); + }); + + test.each([ + { name: "byte budget", files: 1, delta: -1 }, + { name: "file count", files: 0, delta: 0 }, + ])( + "fails without writing or scheduling when the $name is exhausted", + async ({ files, delta }) => { + const value = "x".repeat(900_000); + const size = new Blob([JSON.stringify(value)]).size; + const { t, stepId } = await setup({ + filesWritten: files, + fileWriteBytes: size + delta, + }); + const result = await t.run(async (ctx) => { + const store = vi.spyOn(ctx.storage, "store"); + const schedule = vi.spyOn(ctx.scheduler, "runAfter"); + const result = await storeOversizedResult( + ctx, + { kind: "success", returnValue: value }, + { stepId }, + ); + expect(store).not.toHaveBeenCalled(); + expect(schedule).not.toHaveBeenCalled(); + return result; + }); + expect(result.kind).toBe("failed"); + if (result.kind === "failed") { + expect(result.error).toContain("Preview:"); + expect(result.error).toContain(`${size} bytes`); + expect(result.error).not.toContain("Retrieve it"); + } + await t.run(async (ctx) => { + expect(await ctx.db.query("oversizedValues").take(1)).toEqual([]); + expect(await ctx.db.system.query("_storage").take(1)).toEqual([]); + }); + }, + ); + + test("does not swallow unrelated storage failures", async () => { + const { t, stepId } = await setup(); + await expect( + t.run(async (ctx) => { + vi.spyOn(ctx.storage, "store").mockRejectedValue( + new Error("storage unavailable"), + ); + return storeOversizedResult( + ctx, + { kind: "success", returnValue: "x".repeat(900_000) }, + { stepId }, + ); + }), + ).rejects.toThrow("storage unavailable"); + await t.run(async (ctx) => { + expect(await ctx.db.query("oversizedValues").take(1)).toEqual([]); + expect(await ctx.db.system.query("_storage").take(1)).toEqual([]); + }); + }); + + test("workflow cleanup deletes the stored record and blob", async () => { + const { t, workflowId, stepId, storageId } = await completedOversizedStep(); + expect(await t.mutation(api.workflow.cleanup, { workflowId })).toBe(true); + await t.run(async (ctx) => { + expect(await ctx.db.get("workflows", workflowId)).toBeNull(); + expect(await ctx.db.get("steps", stepId)).toBeNull(); + expect(await ctx.db.query("oversizedValues").take(1)).toEqual([]); + expect(await ctx.db.system.get("_storage", storageId)).toBeNull(); + expect(await ctx.storage.get(storageId)).toBeNull(); + }); + }); + + test("restarting from the failed step deletes its stored record and blob", async () => { + const { t, workflowId, stepId, storageId } = await completedOversizedStep(); + await t.mutation(api.workflow.restart, { workflowId, from: 0 }); + await t.run(async (ctx) => { + const workflow = await ctx.db.get("workflows", workflowId); + expect(workflow?.generationNumber).toBe(1); + expect(workflow?.runResult).toBeUndefined(); + expect(await ctx.db.get("steps", stepId)).toBeNull(); + expect(await ctx.db.query("oversizedValues").take(1)).toEqual([]); + expect(await ctx.db.system.get("_storage", storageId)).toBeNull(); + expect(await ctx.storage.get(storageId)).toBeNull(); + }); + }); + + test("rolling back removal preserves both the record and the readable blob", async () => { + const { t, stepId, storageId, value } = await completedOversizedStep(); + await expect( + t.mutation(async (ctx) => { + await ctx.runMutation(api.oversizedValues.remove, { stepId }); + throw new Error("Intentional rollback"); + }), + ).rejects.toThrow("Intentional rollback"); + expect(await t.mutation(api.oversizedValues.read, { stepId })).toBe(value); + await t.run(async (ctx) => { + const record = await ctx.db + .query("oversizedValues") + .withIndex("stepId", (q) => q.eq("stepId", stepId)) + .unique(); + expect(record?.storageId).toBe(storageId); + expect(await ctx.db.system.get("_storage", storageId)).not.toBeNull(); + }); + }); + + test.each([undefined, null, "small", 5n])( + "leaves small values unchanged (%s)", + async (value) => { + const { t, stepId } = await setup(); + await t.run(async (ctx) => { + const metrics = vi.spyOn(ctx.meta, "getTransactionMetrics"); + const result = { kind: "success" as const, returnValue: value ?? null }; + expect(await storeOversizedResult(ctx, result, { stepId })).toBe( + result, + ); + expect(metrics).not.toHaveBeenCalled(); + }); + }, + ); +}); + +// Restart fixture: the workflow handler is a no-op so the test can inspect +// cleanup before any replacement steps are created. +export const noop = internalMutation({ + args: { workflowId: v.id("workflows"), generationNumber: v.number() }, + handler: () => null, +}); diff --git a/src/component/oversizedValues.ts b/src/component/oversizedValues.ts index 2339f83a..2c28bab3 100644 --- a/src/component/oversizedValues.ts +++ b/src/component/oversizedValues.ts @@ -1,34 +1,111 @@ -import { type Value, convexToJson, getConvexSize } from "convex/values"; +import { + type Value, + convexToJson, + getConvexSize, + jsonToConvex, + v, +} from "convex/values"; import type { RunResult } from "@convex-dev/workpool"; +import { mutation, type MutationCtx } from "./_generated/server.js"; +import type { Id } from "./_generated/dataModel.js"; +import { getDefaultLogger } from "./utils.js"; export const MAX_RETURN_VALUE_SIZE = 800 << 10; const PREVIEW_SIZE = 8 << 10; -function truncatedPreview(returnValue: unknown): string { - const json = JSON.stringify(convexToJson(returnValue as Value)); +function truncatedPreview(returnValue: Value): string { + const json = JSON.stringify(convexToJson(returnValue)); if (json.length <= PREVIEW_SIZE * 2) { return json; } return json.slice(0, PREVIEW_SIZE) + "..." + json.slice(-PREVIEW_SIZE); } -export function checkReturnValueSize( - returnValue: Value | undefined, -): string | null { - const size = getConvexSize(returnValue); - if (size > MAX_RETURN_VALUE_SIZE) { - return `Step return value too large (${size} bytes). Maximum is ${MAX_RETURN_VALUE_SIZE} bytes. Preview: ${truncatedPreview(returnValue)}`; - } - return null; -} - -export function checkForOversizedResult(result: RunResult): RunResult { +export async function storeOversizedResult( + ctx: MutationCtx, + result: RunResult, + opts: { + stepId: Id<"steps">; + }, +): Promise { if (result.kind !== "success") { return result; } - const sizeError = checkReturnValueSize(result.returnValue as Value); - if (!sizeError) { + const returnValue = result.returnValue as Value; + const size = getConvexSize(returnValue); + if (size <= MAX_RETURN_VALUE_SIZE) { return result; } - return { kind: "failed", error: sizeError }; + const sizeError = `Step return value too large (${size} bytes). Maximum is ${MAX_RETURN_VALUE_SIZE} bytes.`; + const blob = new Blob([JSON.stringify(convexToJson(returnValue))], { + type: "application/json", + }); + const { filesWritten, fileWriteBytes } = + await ctx.meta.getTransactionMetrics(); + if (filesWritten.remaining < 1 || blob.size > fileWriteBytes.remaining) { + const console = await getDefaultLogger(ctx); + console.warn( + `Not storing oversized result of ${opts.stepId} (${blob.size} bytes) because the transaction has ${filesWritten.remaining} files and ${fileWriteBytes.remaining} file bytes remaining.`, + ); + return { + kind: "failed", + error: `${sizeError} Preview: ${truncatedPreview(returnValue)}`, + }; + } + const step = await ctx.db.get("steps", opts.stepId); + if (!step) { + throw new Error(`Step not found: ${opts.stepId}`); + } + const workflow = await ctx.db.get("workflows", step.workflowId); + const storageId = await ctx.storage.store(blob); + await ctx.db.insert("oversizedValues", { + workflowId: step.workflowId, + name: workflow?.name ?? "", + kind: "returnValue", + stepId: opts.stepId, + storageId, + }); + return { + kind: "failed", + error: `${sizeError} Retrieve it with ctx.runMutation(components.workflow.oversizedValues.read, { stepId: "${opts.stepId}" }).`, + }; } + +export const read = mutation({ + args: { + stepId: v.id("steps"), + }, + returns: v.any(), + handler: async (ctx, args): Promise => { + const record = await ctx.db + .query("oversizedValues") + .withIndex("stepId", (q) => q.eq("stepId", args.stepId)) + .unique(); + if (!record) { + throw new Error(`No oversized value found for step ${args.stepId}`); + } + const blob = await ctx.storage.get(record.storageId); + if (!blob) { + throw new Error(`Storage blob not found: ${record.storageId}`); + } + return jsonToConvex(JSON.parse(await blob.text())); + }, +}); + +export const remove = mutation({ + args: { + stepId: v.id("steps"), + }, + returns: v.null(), + handler: async (ctx, args): Promise => { + const record = await ctx.db + .query("oversizedValues") + .withIndex("stepId", (q) => q.eq("stepId", args.stepId)) + .first(); + if (record) { + await ctx.storage.delete(record.storageId); + await ctx.db.delete("oversizedValues", record._id); + } + return null; + }, +}); diff --git a/src/component/pool.ts b/src/component/pool.ts index 06dc11b5..664458bc 100644 --- a/src/component/pool.ts +++ b/src/component/pool.ts @@ -22,7 +22,7 @@ import { getDefaultLogger } from "./utils.js"; import { completeHandler } from "./workflow.js"; import type { Doc } from "./_generated/dataModel.js"; import { vWorkflowId, type WorkflowId } from "../types.js"; -import { checkForOversizedResult } from "./oversizedValues.js"; +import { storeOversizedResult } from "./oversizedValues.js"; export const workpoolOptions = v.object({ logLevel: v.optional(logLevel), @@ -148,7 +148,7 @@ async function onCompleteHandler( } journalEntry.step.inProgress = false; journalEntry.step.completedAt = Date.now(); - const runResult = checkForOversizedResult(args.result); + const runResult = await storeOversizedResult(ctx, args.result, { stepId }); journalEntry.step.runResult = runResult; await ctx.db.replace("steps", journalEntry._id, journalEntry); console.debug(`Completed execution of ${stepId}`, journalEntry); diff --git a/src/component/schema.ts b/src/component/schema.ts index c3250506..ef6bf007 100644 --- a/src/component/schema.ts +++ b/src/component/schema.ts @@ -122,6 +122,13 @@ export default defineSchema({ "workflowId", "state.kind", ]), + oversizedValues: defineTable({ + workflowId: v.id("workflows"), + name: v.string(), + kind: v.union(v.literal("returnValue"), v.literal("args")), + stepId: v.id("steps"), + storageId: v.id("_storage"), + }).index("stepId", ["stepId"]), onCompleteFailures: defineTable( v.union( v.object({ diff --git a/src/component/setup.test.ts b/src/component/setup.test.ts index 9c5f7754..c3ab3627 100644 --- a/src/component/setup.test.ts +++ b/src/component/setup.test.ts @@ -11,8 +11,10 @@ export { componentSchema }; export const componentModules = import.meta.glob( "../../node_modules/@convex-dev/workpool/src/component/**/*.ts", ); -export function initConvexTest() { - const t = convexTest(schema, modules); +export function initConvexTest(options?: { + transactionLimits?: Parameters[0]["transactionLimits"]; +}) { + const t = convexTest({ schema, modules, ...options }); workpool.register(t, "workpool"); return t; } diff --git a/src/component/workflow.ts b/src/component/workflow.ts index 8e181448..8d05ef2b 100644 --- a/src/component/workflow.ts +++ b/src/component/workflow.ts @@ -565,6 +565,14 @@ async function deleteSteps(ctx: MutationCtx, batch: Doc<"steps">[]) { } else if (entry.step.kind === "workflow" && entry.step.workflowId) { nestedWorkflowIds.push(entry.step.workflowId); } + const oversized = await ctx.db + .query("oversizedValues") + .withIndex("stepId", (q) => q.eq("stepId", entry._id)) + .first(); + if (oversized) { + await ctx.storage.delete(oversized.storageId); + await ctx.db.delete("oversizedValues", oversized._id); + } }), ); if (nestedWorkflowIds.length > 0) {