@convex-dev/workflow
Version:
Convex component for durably executing workflows.
716 lines (643 loc) • 23 kB
text/typescript
/// <reference types="vite/client" />
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { api } from "./_generated/api.js";
import { initConvexTest } from "./setup.test.js";
import type { Id } from "./_generated/dataModel.js";
import { internalMutation } from "./_generated/server.js";
import { v } from "convex/values";
describe("workflow", () => {
beforeEach(async () => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
test("can create a workflow async", async () => {
const t = initConvexTest();
const id = await t.mutation(api.workflow.create, {
workflowName: "test",
workflowHandle: "function://;workflow.test:noop",
workflowArgs: { location: "San Francisco" },
startAsync: true,
});
const workflow = await t.query(api.workflow.getStatus, { workflowId: id });
expect(workflow.workflow.name).toBe("test");
expect(workflow.workflow.args).toEqual({ location: "San Francisco" });
expect(workflow.workflow.runResult).toBeUndefined();
expect(workflow.inProgress).toHaveLength(0);
});
test("can cancel a workflow", async () => {
const t = initConvexTest();
const id = await t.mutation(api.workflow.create, {
workflowName: "test",
workflowHandle: "function://;workflow.test:noop",
workflowArgs: { location: "San Francisco" },
startAsync: true,
});
const workflow = await t.query(api.workflow.getStatus, { workflowId: id });
expect(workflow.workflow.runResult).toBeUndefined();
await t.mutation(api.workflow.cancel, { workflowId: id });
const workflow2 = await t.query(api.workflow.getStatus, { workflowId: id });
expect(workflow2.workflow.runResult).toMatchObject({ kind: "canceled" });
});
test("retry a failed workflow", async () => {
const t = initConvexTest();
const id = await t.mutation(api.workflow.create, {
workflowName: "test",
workflowHandle: "function://;workflow.test:noop",
workflowArgs: { location: "San Francisco" },
startAsync: true,
});
// Complete with failure
await t.mutation(api.workflow.complete, {
workflowId: id,
generationNumber: 0,
runResult: { kind: "failed", error: "something went wrong" },
});
const before = await t.query(api.workflow.getStatus, { workflowId: id });
expect(before.workflow.runResult).toMatchObject({ kind: "failed" });
expect(before.workflow.generationNumber).toBe(0);
// Retry
await t.mutation(api.workflow.restart, {
workflowId: id,
startAsync: true,
});
const after = await t.query(api.workflow.getStatus, { workflowId: id });
expect(after.workflow.runResult).toBeUndefined();
expect(after.workflow.generationNumber).toBe(1);
});
test("retry throws if workflow is still running", async () => {
const t = initConvexTest();
const id = await t.mutation(api.workflow.create, {
workflowName: "test",
workflowHandle: "function://;workflow.test:noop",
workflowArgs: {},
startAsync: true,
});
await expect(
t.mutation(api.workflow.restart, { workflowId: id, startAsync: true }),
).rejects.toThrow("still running");
});
test("retry from step number deletes steps from that point", async () => {
const t = initConvexTest();
const id = await t.mutation(api.workflow.create, {
workflowName: "test",
workflowHandle: "function://;workflow.test:noop",
workflowArgs: {},
startAsync: true,
});
// Insert some steps
await t.run(async (ctx) => {
for (let i = 0; i < 3; i++) {
await ctx.db.insert("steps", {
workflowId: id,
stepNumber: i,
step: {
kind: "function" as const,
functionType: "mutation" as const,
handle: "function://;workflow.test:noop",
name: `step${i}`,
inProgress: false,
argsSize: 0,
args: {},
runResult: { kind: "success", returnValue: null },
startedAt: Date.now(),
completedAt: Date.now(),
},
});
}
});
// Complete with failure
await t.mutation(api.workflow.complete, {
workflowId: id,
generationNumber: 0,
runResult: { kind: "failed", error: "step2 failed" },
});
// Retry from step 1
await t.mutation(api.workflow.restart, {
workflowId: id,
from: 1,
startAsync: true,
});
// Only step0 should remain
await t.run(async (ctx) => {
const steps = await ctx.db
.query("steps")
.withIndex("workflow", (q) => q.eq("workflowId", id))
.collect();
expect(steps).toHaveLength(1);
expect(steps[0].stepNumber).toBe(0);
expect(steps[0].step.name).toBe("step0");
});
});
test("retry from step name deletes that step and subsequent ones", async () => {
const t = initConvexTest();
const id = await t.mutation(api.workflow.create, {
workflowName: "test",
workflowHandle: "function://;workflow.test:noop",
workflowArgs: {},
startAsync: true,
});
// Insert steps with names
await t.run(async (ctx) => {
const names = ["fetch", "process", "save"];
for (let i = 0; i < names.length; i++) {
await ctx.db.insert("steps", {
workflowId: id,
stepNumber: i,
step: {
kind: "function" as const,
functionType: "action" as const,
handle: "function://;workflow.test:noop",
name: names[i],
inProgress: false,
argsSize: 0,
args: {},
runResult: { kind: "success", returnValue: null },
startedAt: Date.now(),
completedAt: Date.now(),
},
});
}
});
await t.mutation(api.workflow.complete, {
workflowId: id,
generationNumber: 0,
runResult: { kind: "failed", error: "save failed" },
});
// Retry from "process"
await t.mutation(api.workflow.restart, {
workflowId: id,
from: "process",
startAsync: true,
});
// Only "fetch" (step 0) should remain
await t.run(async (ctx) => {
const steps = await ctx.db
.query("steps")
.withIndex("workflow", (q) => q.eq("workflowId", id))
.collect();
expect(steps).toHaveLength(1);
expect(steps[0].step.name).toBe("fetch");
});
});
test("retry from unknown step name throws", async () => {
const t = initConvexTest();
const id = await t.mutation(api.workflow.create, {
workflowName: "test",
workflowHandle: "function://;workflow.test:noop",
workflowArgs: {},
startAsync: true,
});
await t.mutation(api.workflow.complete, {
workflowId: id,
generationNumber: 0,
runResult: { kind: "failed", error: "oops" },
});
await expect(
t.mutation(api.workflow.restart, {
workflowId: id,
from: "nonexistent",
startAsync: true,
}),
).rejects.toThrow('Step "nonexistent" not found');
});
test("retry from negative step number throws", async () => {
const t = initConvexTest();
const id = await t.mutation(api.workflow.create, {
workflowName: "test",
workflowHandle: "function://;workflow.test:noop",
workflowArgs: {},
startAsync: true,
});
await t.mutation(api.workflow.complete, {
workflowId: id,
generationNumber: 0,
runResult: { kind: "failed", error: "oops" },
});
await expect(
t.mutation(api.workflow.restart, {
workflowId: id,
from: -1,
startAsync: true,
}),
).rejects.toThrow("Step number cannot be negative: -1");
});
test("retry deletes associated event steps", async () => {
const t = initConvexTest();
const id = await t.mutation(api.workflow.create, {
workflowName: "test",
workflowHandle: "function://;workflow.test:noop",
workflowArgs: {},
startAsync: true,
});
// Insert an event and an event step referencing it
let eventId: any;
await t.run(async (ctx) => {
eventId = await ctx.db.insert("events", {
workflowId: id,
name: "approval",
state: { kind: "created" },
});
await ctx.db.insert("steps", {
workflowId: id,
stepNumber: 0,
step: {
kind: "event" as const,
name: "waitForApproval",
inProgress: false,
argsSize: 0,
args: { eventId },
eventId,
startedAt: Date.now(),
completedAt: Date.now(),
runResult: { kind: "success", returnValue: null },
},
});
});
await t.mutation(api.workflow.complete, {
workflowId: id,
generationNumber: 0,
runResult: { kind: "failed", error: "oops" },
});
// Retry from step 0 — should delete the event too
await t.mutation(api.workflow.restart, {
workflowId: id,
from: 0,
startAsync: true,
});
await t.run(async (ctx) => {
const steps = await ctx.db
.query("steps")
.withIndex("workflow", (q) => q.eq("workflowId", id))
.collect();
expect(steps).toHaveLength(0);
const event = await ctx.db.get("events", eventId);
expect(event).toBeNull();
});
});
test("cleaning up a workflow", async () => {
const t = initConvexTest();
const id = await t.mutation(api.workflow.create, {
workflowName: "test",
workflowHandle: "function://;workflow.test:noop",
workflowArgs: { location: "San Francisco" },
startAsync: true,
});
const workflow = await t.query(api.workflow.getStatus, { workflowId: id });
expect(workflow.workflow.runResult).toBeUndefined();
await t.mutation(api.workflow.cancel, { workflowId: id });
const workflow2 = await t.query(api.workflow.getStatus, { workflowId: id });
expect(workflow2.workflow.runResult).toMatchObject({ kind: "canceled" });
const cleaned = await t.mutation(api.workflow.cleanup, { workflowId: id });
expect(cleaned).toBe(true);
await t.run(async (ctx) => {
const workflow = await ctx.db.get("workflows", id);
expect(workflow).toBeNull();
});
});
test("cleanup deletes associated events", async () => {
const t = initConvexTest();
// Create a workflow
const workflowId = await t.mutation(api.workflow.create, {
workflowName: "test-with-event",
workflowHandle: "function://;workflow.test:noop",
workflowArgs: { location: "San Francisco" },
startAsync: true,
});
// Create an event associated with the workflow
const eventId = await t.mutation(api.event.create, {
name: "test-event",
workflowId,
});
// Create a step entry with kind "event" that references the event using startSteps API
const entries = await t.mutation(api.journal.startSteps, {
workflowId,
generationNumber: 0,
steps: [
{
step: {
kind: "event" as const,
name: "test-event-step",
inProgress: true,
argsSize: 0,
args: { eventId },
startedAt: Date.now(),
},
},
],
});
const stepId = entries[0]._id as Id<"steps">;
// Verify event exists
await t.run(async (ctx) => {
const event = await ctx.db.get("events", eventId);
expect(event).not.toBeNull();
});
// Cancel the workflow so it can be cleaned up
await t.mutation(api.workflow.cancel, { workflowId });
// Clean up the workflow
const cleaned = await t.mutation(api.workflow.cleanup, {
workflowId,
});
expect(cleaned).toBe(true);
// Verify the workflow is deleted
await t.run(async (ctx) => {
const workflow = await ctx.db.get("workflows", workflowId);
expect(workflow).toBeNull();
});
// Verify the step is deleted
await t.run(async (ctx) => {
const step = await ctx.db.get("steps", stepId);
expect(step).toBeNull();
});
// Verify the event is deleted
await t.run(async (ctx) => {
const event = await ctx.db.get("events", eventId);
expect(event).toBeNull();
});
});
test("cleanup enqueues cleanup for nested workflows", async () => {
const t = initConvexTest();
// Create a parent workflow
const parentWorkflowId = await t.mutation(api.workflow.create, {
workflowName: "parent-workflow",
workflowHandle: "function://;workflow.test:noop",
workflowArgs: { location: "San Francisco" },
startAsync: true,
});
// Create a workflow step which will automatically create a nested workflow via startSteps
// The startSteps handler creates the nested workflow when kind is "workflow"
const entries = await t.mutation(api.journal.startSteps, {
workflowId: parentWorkflowId,
generationNumber: 0,
steps: [
{
step: {
kind: "workflow" as const,
name: "nested-workflow-step",
handle: "function://;workflow.test:noop",
inProgress: true,
argsSize: 0,
args: { location: "New York" },
startedAt: Date.now(),
},
},
],
});
const stepId = entries[0]._id as Id<"steps">;
// Get the nested workflow ID that was created by startSteps
let nestedWorkflowId: Id<"workflows"> | undefined;
await t.run(async (ctx) => {
const step = await ctx.db.get("steps", stepId);
if (step && step.step.kind === "workflow") {
nestedWorkflowId = step.step.workflowId;
}
});
expect(nestedWorkflowId).toBeDefined();
// Cancel parent workflow - this should automatically cancel nested workflows
await t.mutation(api.workflow.cancel, { workflowId: parentWorkflowId });
// Verify nested workflow was also canceled automatically
await t.run(async (ctx) => {
const nested = await ctx.db.get("workflows", nestedWorkflowId!);
expect(nested?.runResult).toBeDefined();
});
// Verify both workflows exist before cleanup
await t.run(async (ctx) => {
const parent = await ctx.db.get("workflows", parentWorkflowId);
const nested = await ctx.db.get("workflows", nestedWorkflowId!);
expect(parent).not.toBeNull();
expect(nested).not.toBeNull();
});
// Clean up the parent workflow
const cleaned = await t.mutation(api.workflow.cleanup, {
workflowId: parentWorkflowId,
});
expect(cleaned).toBe(true);
// Verify the parent workflow is deleted
await t.run(async (ctx) => {
const parent = await ctx.db.get("workflows", parentWorkflowId);
expect(parent).toBeNull();
});
// Verify the step is deleted
await t.run(async (ctx) => {
const step = await ctx.db.get("steps", stepId);
expect(step).toBeNull();
});
// The nested workflow cleanup is enqueued via workpool.
// Run scheduled functions to process the cleanup.
await t.finishAllScheduledFunctions(vi.runAllTimers);
// Verify the nested workflow is also cleaned up
await t.run(async (ctx) => {
const nested = await ctx.db.get("workflows", nestedWorkflowId!);
expect(nested).toBeNull();
});
});
test("cleanup handles workflow step without workflowId", async () => {
const t = initConvexTest();
// Create a workflow
const workflowId = await t.mutation(api.workflow.create, {
workflowName: "test-workflow",
workflowHandle: "function://;workflow.test:noop",
workflowArgs: { location: "San Francisco" },
startAsync: true,
});
// Create a workflow step without workflowId (not yet started nested workflow) using startSteps
await t.mutation(api.journal.startSteps, {
workflowId,
generationNumber: 0,
steps: [
{
step: {
kind: "workflow" as const,
name: "pending-nested-workflow",
handle: "function://;workflow.test:noop",
inProgress: true,
argsSize: 0,
args: { location: "Boston" },
startedAt: Date.now(),
},
},
],
});
// Cancel the workflow
await t.mutation(api.workflow.cancel, { workflowId });
// Cleanup should succeed without errors
const cleaned = await t.mutation(api.workflow.cleanup, { workflowId });
expect(cleaned).toBe(true);
// Verify the workflow is deleted
await t.run(async (ctx) => {
const workflow = await ctx.db.get("workflows", workflowId);
expect(workflow).toBeNull();
});
});
test("cleanup handles event step without eventId", async () => {
const t = initConvexTest();
// Create a workflow
const workflowId = await t.mutation(api.workflow.create, {
workflowName: "test-workflow",
workflowHandle: "function://;workflow.test:noop",
workflowArgs: { location: "San Francisco" },
startAsync: true,
});
// Create an event step that starts waiting (no event sent yet, so it won't have a response)
await t.mutation(api.journal.startSteps, {
workflowId,
generationNumber: 0,
steps: [
{
step: {
kind: "event" as const,
name: "pending-event",
inProgress: true,
argsSize: 0,
args: {},
startedAt: Date.now(),
},
},
],
});
// Cancel the workflow
await t.mutation(api.workflow.cancel, { workflowId });
// Cleanup should succeed without errors (eventId is set by awaitEvent)
const cleaned = await t.mutation(api.workflow.cleanup, { workflowId });
expect(cleaned).toBe(true);
// Verify the workflow is deleted
await t.run(async (ctx) => {
const workflow = await ctx.db.get("workflows", workflowId);
expect(workflow).toBeNull();
});
});
test("cleanup deletes large numbers of steps via async iteration", async () => {
const t = initConvexTest();
const workflowId = await t.mutation(api.workflow.create, {
workflowName: "test-many-steps",
workflowHandle: "function://;workflow.test:noop",
workflowArgs: {},
startAsync: true,
});
// Insert many steps so the async iteration walks through them all and
// (in environments that enforce limits) any continuation kicks in.
const stepCount = 100;
await t.run(async (ctx) => {
for (let i = 0; i < stepCount; i++) {
await ctx.db.insert("steps", {
workflowId,
stepNumber: i,
step: {
kind: "function" as const,
functionType: "mutation" as const,
handle: "function://;workflow.test:noop",
name: `step${i}`,
inProgress: false,
argsSize: 0,
args: {},
runResult: { kind: "success", returnValue: null },
startedAt: Date.now(),
completedAt: Date.now(),
},
});
}
});
await t.mutation(api.workflow.cancel, { workflowId });
const cleaned = await t.mutation(api.workflow.cleanup, { workflowId });
expect(cleaned).toBe(true);
// Drain any continuations scheduled when budget was halfway consumed.
await t.finishAllScheduledFunctions(vi.runAllTimers);
await t.run(async (ctx) => {
const workflow = await ctx.db.get("workflows", workflowId);
expect(workflow).toBeNull();
const remaining = await ctx.db
.query("steps")
.withIndex("workflow", (q) => q.eq("workflowId", workflowId))
.collect();
expect(remaining).toHaveLength(0);
});
});
test("cleanup with mixed event and workflow steps", async () => {
const t = initConvexTest();
// Create parent workflow
const parentWorkflowId = await t.mutation(api.workflow.create, {
workflowName: "parent-workflow",
workflowHandle: "function://;workflow.test:noop",
workflowArgs: { location: "San Francisco" },
startAsync: true,
});
// Create an event
const eventId = await t.mutation(api.event.create, {
name: "test-event",
workflowId: parentWorkflowId,
});
// Create mixed steps using startSteps API
// The workflow step will automatically create a nested workflow
const entries = await t.mutation(api.journal.startSteps, {
workflowId: parentWorkflowId,
generationNumber: 0,
steps: [
{
step: {
kind: "event" as const,
name: "event-step",
inProgress: true,
argsSize: 0,
args: { eventId },
startedAt: Date.now(),
},
},
{
step: {
kind: "workflow" as const,
name: "workflow-step",
handle: "function://;workflow.test:noop",
inProgress: true,
argsSize: 0,
args: { location: "New York" },
startedAt: Date.now(),
},
},
],
});
// Get the nested workflow ID that was created by startSteps
const workflowStepId = entries[1]._id as Id<"steps">;
let nestedWorkflowId: Id<"workflows"> | undefined;
await t.run(async (ctx) => {
const step = await ctx.db.get("steps", workflowStepId);
if (step && step.step.kind === "workflow") {
nestedWorkflowId = step.step.workflowId;
}
});
expect(nestedWorkflowId).toBeDefined();
// Cancel parent workflow - this should automatically cancel nested workflows
await t.mutation(api.workflow.cancel, { workflowId: parentWorkflowId });
// Verify nested workflow was also canceled automatically
await t.run(async (ctx) => {
const nested = await ctx.db.get("workflows", nestedWorkflowId!);
expect(nested?.runResult).toBeDefined();
});
// Verify resources exist before cleanup
await t.run(async (ctx) => {
const parent = await ctx.db.get("workflows", parentWorkflowId);
const nested = await ctx.db.get("workflows", nestedWorkflowId!);
const event = await ctx.db.get("events", eventId);
expect(parent).not.toBeNull();
expect(nested).not.toBeNull();
expect(event).not.toBeNull();
});
// Clean up the parent workflow
const cleaned = await t.mutation(api.workflow.cleanup, {
workflowId: parentWorkflowId,
});
expect(cleaned).toBe(true);
// Run scheduled functions to process nested cleanup
await t.finishAllScheduledFunctions(vi.runAllTimers);
// Verify all resources are cleaned up
await t.run(async (ctx) => {
const parent = await ctx.db.get("workflows", parentWorkflowId);
const nested = await ctx.db.get("workflows", nestedWorkflowId!);
const event = await ctx.db.get("events", eventId);
expect(parent).toBeNull();
expect(nested).toBeNull();
expect(event).toBeNull();
});
});
});
export const noop = internalMutation({ args: v.any(), handler: () => {} });