UNPKG

@convex-dev/workflow

Version:

Convex component for durably executing workflows.

615 lines (543 loc) 17.9 kB
import { describe, it, expect, test } from "vitest"; import { BaseChannel } from "async-channel"; import type { RunResult } from "@convex-dev/workpool"; import type { StepRequest } from "./step.js"; import { StepExecutor } from "./step.js"; import type { JournalEntry } from "../component/schema.js"; import { createWorkflowCtx } from "./workflowContext.js"; import type { WorkflowId } from "../types.js"; import { anyApi, type FunctionReference } from "convex/server"; import { initConvexTest } from "./setup.test.js"; // Fake function reference that satisfies the type constraints. function fakeFuncRef(name: string) { return anyApi[name].default as FunctionReference<any, "internal">; } // Build a completed journal entry for replay. function journalEntry( overrides: { name?: string; kind?: "function" | "workflow" | "event"; args?: Record<string, unknown>; runResult?: RunResult; stepNumber?: number; } = {}, ): JournalEntry { const kind = overrides.kind ?? "function"; const base = { _id: `step-${Math.random().toString(36).slice(2)}`, _creationTime: Date.now(), workflowId: "wf-test", stepNumber: overrides.stepNumber ?? 0, }; const stepCommon = { name: overrides.name ?? "test", inProgress: false, argsSize: 10, args: overrides.args ?? {}, runResult: overrides.runResult ?? { kind: "success" as const, returnValue: "ok", }, startedAt: 1000, completedAt: 2000, }; if (kind === "function") { return { ...base, step: { kind: "function", functionType: "action", handle: "handle", ...stepCommon, }, } as unknown as JournalEntry; } if (kind === "event") { return { ...base, step: { kind: "event", ...stepCommon, args: overrides.args ?? { eventId: undefined }, }, } as JournalEntry; } return { ...base, step: { kind: "workflow", handle: "handle", ...stepCommon, }, } as JournalEntry; } // Simulate the StepExecutor replay loop: read messages from the channel and // resolve them from the journal, without needing a real Convex ctx. async function replayFromJournal( receiver: BaseChannel<StepRequest>, entries: JournalEntry[], ) { for (const entry of entries) { const message = await receiver.get(); // Mirrors StepExecutor.completeMessage if (entry.step.runResult === undefined) { throw new Error( "Assertion failed: no outcome for completed function call", ); } message.resolve(entry.step.runResult); } } describe("StepExecutor + WorkflowCtx integration", () => { it("resolves a successful step", async () => { const channel = new BaseChannel<StepRequest>(0); const ctx = createWorkflowCtx("wf-1" as WorkflowId, channel); const entry = journalEntry({ name: "test", runResult: { kind: "success", returnValue: 42 }, }); const [result] = await Promise.all([ ctx.runAction(fakeFuncRef("test"), {}), replayFromJournal(channel, [entry]), ]); expect(result).toBe(42); }); it("throws on a failed step and the error is catchable", async () => { const channel = new BaseChannel<StepRequest>(0); const ctx = createWorkflowCtx("wf-2" as any, channel); const entry = journalEntry({ name: "test", runResult: { kind: "failed", error: "something broke" }, }); const [error] = await Promise.all([ ctx.runAction(fakeFuncRef("test"), {}).catch((e: Error) => e), replayFromJournal(channel, [entry]), ]); expect(error).toBeInstanceOf(Error); expect((error as Error).message).toBe("something broke"); }); it("throws on a canceled step", async () => { const channel = new BaseChannel<StepRequest>(0); const ctx = createWorkflowCtx("wf-3" as any, channel); const entry = journalEntry({ name: "test", runResult: { kind: "canceled" }, }); const [error] = await Promise.all([ ctx.runAction(fakeFuncRef("test"), {}).catch((e: Error) => e), replayFromJournal(channel, [entry]), ]); expect(error).toBeInstanceOf(Error); expect((error as Error).message).toBe("Canceled"); }); it("handles sequential steps", async () => { const channel = new BaseChannel<StepRequest>(0); const ctx = createWorkflowCtx("wf-4" as any, channel); const entries = [ journalEntry({ name: "step1", args: { x: 1 }, runResult: { kind: "success", returnValue: "first" }, stepNumber: 0, }), journalEntry({ name: "step2", args: { x: 2 }, runResult: { kind: "success", returnValue: "second" }, stepNumber: 1, }), ]; const handler = async () => { const a = await ctx.runAction(fakeFuncRef("step1"), { x: 1 }); const b = await ctx.runAction(fakeFuncRef("step2"), { x: 2 }); return [a, b]; }; const [results] = await Promise.all([ handler(), replayFromJournal(channel, entries), ]); expect(results).toEqual(["first", "second"]); }); it("catches an error mid-workflow and continues", async () => { const channel = new BaseChannel<StepRequest>(0); const ctx = createWorkflowCtx("wf-5" as any, channel); const entries = [ journalEntry({ name: "failing", runResult: { kind: "failed", error: "boom" }, stepNumber: 0, }), journalEntry({ name: "recovery", runResult: { kind: "success", returnValue: "recovered" }, stepNumber: 1, }), ]; const handler = async () => { let caught: string | undefined; try { await ctx.runAction(fakeFuncRef("failing"), {}); } catch (e) { caught = (e as Error).message; } const result = await ctx.runAction(fakeFuncRef("recovery"), {}); return { caught, result }; }; const [outcome] = await Promise.all([ handler(), replayFromJournal(channel, entries), ]); expect(outcome.caught).toBe("boom"); expect(outcome.result).toBe("recovered"); }); it("handles parallel steps via Promise.all", async () => { const channel = new BaseChannel<StepRequest>(0); const ctx = createWorkflowCtx("wf-6" as any, channel); const entries = [ journalEntry({ name: "a", args: { v: "a" }, runResult: { kind: "success", returnValue: 1 }, stepNumber: 0, }), journalEntry({ name: "b", args: { v: "b" }, runResult: { kind: "success", returnValue: 2 }, stepNumber: 1, }), ]; const handler = async () => { return Promise.all([ ctx.runAction(fakeFuncRef("a"), { v: "a" }), ctx.runAction(fakeFuncRef("b"), { v: "b" }), ]); }; const [results] = await Promise.all([ handler(), replayFromJournal(channel, entries), ]); expect(results).toEqual([1, 2]); }); it("one failure in Promise.all rejects the batch", async () => { const channel = new BaseChannel<StepRequest>(0); const ctx = createWorkflowCtx("wf-7" as any, channel); const entries = [ journalEntry({ name: "ok", args: { v: "ok" }, runResult: { kind: "success", returnValue: "fine" }, stepNumber: 0, }), journalEntry({ name: "bad", args: { v: "bad" }, runResult: { kind: "failed", error: "partial failure" }, stepNumber: 1, }), ]; const handler = async () => { return Promise.all([ ctx.runAction(fakeFuncRef("ok"), { v: "ok" }), ctx.runAction(fakeFuncRef("bad"), { v: "bad" }), ]); }; const [error] = await Promise.all([ handler().catch((e: Error) => e), replayFromJournal(channel, entries), ]); expect(error).toBeInstanceOf(Error); expect((error as Error).message).toBe("partial failure"); }); it("error is thrown from run(), not from completeMessage", async () => { const channel = new BaseChannel<StepRequest>(0); const ctx = createWorkflowCtx("wf-8" as any, channel); const entry = journalEntry({ name: "test", runResult: { kind: "failed", error: "validation error" }, }); const [error] = await Promise.all([ ctx.runAction(fakeFuncRef("test"), {}).catch((e: Error) => e), replayFromJournal(channel, [entry]), ]); expect(error).toBeInstanceOf(Error); expect((error as Error).message).toBe("validation error"); // The error should originate from run() in workflowContext, not from // completeMessage in step.ts — this is the key change that gives users // their code in the stack trace. expect((error as Error).stack).toContain("workflowContext"); expect((error as Error).stack).not.toContain("completeMessage"); }); it("runMutation works the same as runAction", async () => { const channel = new BaseChannel<StepRequest>(0); const ctx = createWorkflowCtx("wf-9" as any, channel); const entry = journalEntry({ name: "mut", runResult: { kind: "success", returnValue: "mutated" }, }); const [result] = await Promise.all([ ctx.runMutation(fakeFuncRef("mut"), {}), replayFromJournal(channel, [entry]), ]); expect(result).toBe("mutated"); }); it("runQuery works the same as runAction", async () => { const channel = new BaseChannel<StepRequest>(0); const ctx = createWorkflowCtx("wf-10" as any, channel); const entry = journalEntry({ name: "qry", runResult: { kind: "success", returnValue: [1, 2, 3] }, }); const [result] = await Promise.all([ ctx.runQuery(fakeFuncRef("qry"), {}), replayFromJournal(channel, [entry]), ]); expect(result).toEqual([1, 2, 3]); }); }); describe("unstableArgs", () => { function makeMessage(opts: { name: string; kind: "function" | "workflow"; args: Record<string, unknown>; unstableArgs: boolean; }): StepRequest { return { name: opts.name, target: opts.kind === "function" ? { kind: "function", functionType: "action", function: fakeFuncRef(opts.name), args: opts.args, } : { kind: "workflow", function: fakeFuncRef(opts.name), args: opts.args, }, retry: undefined, inline: false, unstableArgs: opts.unstableArgs, schedulerOptions: {}, transactionLimits: undefined, resolve: () => {}, }; } function makeExecutor(entries: JournalEntry[]) { return new StepExecutor( "wf-test", 0, {} as any, {} as any, entries, new BaseChannel<StepRequest>(0), Date.now(), undefined, ); } const kinds = ["function", "workflow"] as const; describe.each(kinds)("%s", (kind) => { test("mismatched args fails without unstableArgs", () => { const entry = journalEntry({ name: "step", kind, args: { x: 1 }, }); const executor = makeExecutor([entry]); const message = makeMessage({ name: "step", kind, args: { x: 2 }, unstableArgs: false, }); expect(() => executor.completeMessage(message, entry)).toThrow( "Journal entry mismatch", ); }); test("mismatched args succeeds with unstableArgs", () => { const entry = journalEntry({ name: "step", kind, args: { x: 1 }, }); const executor = makeExecutor([entry]); const message = makeMessage({ name: "step", kind, args: { x: 2 }, unstableArgs: true, }); expect(() => executor.completeMessage(message, entry)).not.toThrow(); }); test("matching args succeeds with unstableArgs", () => { const entry = journalEntry({ name: "step", kind, args: { x: 1 }, }); const executor = makeExecutor([entry]); const message = makeMessage({ name: "step", kind, args: { x: 1 }, unstableArgs: true, }); expect(() => executor.completeMessage(message, entry)).not.toThrow(); }); }); test("still validates name even with unstableArgs", () => { const entry = journalEntry({ name: "original", args: { x: 1 } }); const executor = makeExecutor([entry]); const message = makeMessage({ name: "different", kind: "function", args: { x: 2 }, unstableArgs: true, }); expect(() => executor.completeMessage(message, entry)).toThrow( "Journal entry mismatch", ); }); test("unstableArgs passes through and defaults correctly", async () => { const channel = new BaseChannel<StepRequest>(0); const ctx = createWorkflowCtx("wf-test" as any, channel); const calls: StepRequest[] = []; const drain = async () => { for (let i = 0; i < 8; i++) { const msg = await channel.get(); calls.push(msg); msg.resolve({ kind: "success", returnValue: null }); } }; await Promise.all([ (async () => { // With unstableArgs: true await ctx.runQuery(fakeFuncRef("q"), {}, { unstableArgs: true }); await ctx.runMutation(fakeFuncRef("m"), {}, { unstableArgs: true }); await ctx.runAction(fakeFuncRef("a"), {}, { unstableArgs: true }); await ctx.runWorkflow(fakeFuncRef("w"), {}, { unstableArgs: true }); // Without unstableArgs (defaults to false) await ctx.runQuery(fakeFuncRef("q2"), {}); await ctx.runMutation(fakeFuncRef("m2"), {}); await ctx.runAction(fakeFuncRef("a2"), {}); await ctx.runWorkflow(fakeFuncRef("w2"), {}); })(), drain(), ]); expect(calls[0].unstableArgs).toBe(true); expect(calls[1].unstableArgs).toBe(true); expect(calls[2].unstableArgs).toBe(true); expect(calls[3].unstableArgs).toBe(true); expect(calls[4].unstableArgs).toBe(false); expect(calls[5].unstableArgs).toBe(false); expect(calls[6].unstableArgs).toBe(false); expect(calls[7].unstableArgs).toBe(false); }); }); describe("transactionLimits", () => { const limits = { documentsRead: 5, bytesWritten: 100 }; test("passes through inline runQuery/runMutation into the StepRequest", async () => { const channel = new BaseChannel<StepRequest>(0); const ctx = createWorkflowCtx("wf-test" as any, channel); const calls: StepRequest[] = []; const drain = async () => { for (let i = 0; i < 3; i++) { const msg = await channel.get(); calls.push(msg); msg.resolve({ kind: "success", returnValue: null }); } }; await Promise.all([ (async () => { await ctx.runQuery( fakeFuncRef("q"), {}, { inline: true, transactionLimits: limits }, ); await ctx.runMutation( fakeFuncRef("m"), {}, { inline: true, transactionLimits: limits }, ); // Inline without transactionLimits defaults to undefined. await ctx.runQuery(fakeFuncRef("q2"), {}, { inline: true }); })(), drain(), ]); expect(calls[0].transactionLimits).toEqual(limits); expect(calls[1].transactionLimits).toEqual(limits); expect(calls[2].transactionLimits).toBeUndefined(); }); test("forwards transactionLimits to ctx.runQuery during inline execution", async () => { const recorded: Array<{ args: unknown; opts: unknown }> = []; const fakeCtx = { runQuery: (_fn: unknown, args: unknown, opts: unknown) => { recorded.push({ args, opts }); return Promise.resolve(123); }, // The journal persistence call (component.journal.startSteps) also goes // through runMutation; return an empty set of entries for it. runMutation: () => Promise.resolve([]), }; const executor = new StepExecutor( "wf-test", 0, fakeCtx as any, { journal: { startSteps: "handle" } } as any, [], new BaseChannel<StepRequest>(0), Date.now(), undefined, ); const message: StepRequest = { name: "q", target: { kind: "function", functionType: "query", function: fakeFuncRef("q"), args: { a: 1 }, }, retry: undefined, inline: true, unstableArgs: false, transactionLimits: limits, schedulerOptions: {}, resolve: () => {}, }; // Inline execution calls `createFunctionHandle`, whose syscall is only // available inside a backend context, so run within convex-test's `t.run`. const t = initConvexTest(); await t.run(() => executor.startSteps([message])); expect(recorded).toHaveLength(1); expect(recorded[0].args).toEqual({ a: 1 }); expect(recorded[0].opts).toEqual({ transactionLimits: limits }); }); test("rejects transactionLimits / inline where unsupported", async () => { const channel = new BaseChannel<StepRequest>(0); const ctx = createWorkflowCtx("wf-test" as any, channel); // Actions cannot run inline. await expect( (ctx.runAction as any)(fakeFuncRef("a"), {}, { inline: true }), ).rejects.toThrow("Cannot run an action inline."); // transactionLimits is only valid for inline steps. await expect( (ctx.runMutation as any)( fakeFuncRef("m"), {}, { transactionLimits: limits }, ), ).rejects.toThrow( "Cannot set transaction limits for non-inline functions.", ); // inline cannot be combined with scheduling. await expect( (ctx.runQuery as any)( fakeFuncRef("q"), {}, { inline: true, runAfter: 1000 }, ), ).rejects.toThrow("Cannot combine `inline` with `runAt` or `runAfter`."); }); });