@convex-dev/workflow
Version:
Convex component for durably executing workflows.
265 lines (259 loc) • 9.64 kB
text/typescript
import { type RunResult, type WorkpoolOptions } from "@convex-dev/workpool";
import { BaseChannel } from "async-channel";
import { assert } from "convex-helpers";
import { validate, ValidationError } from "convex-helpers/validators";
import {
createFunctionHandle,
internalMutationGeneric,
makeFunctionReference,
type FunctionHandle,
type RegisteredMutation,
} from "convex/server";
import {
asObjectValidator,
v,
type ObjectType,
type PropertyValidators,
} from "convex/values";
import { createLogger } from "../component/logging.js";
import { type JournalEntry } from "../component/schema.js";
import { formatErrorWithStack } from "../shared.js";
import { vWorkflowId, type OnCompleteArgs, type WorkflowId } from "../types.js";
import { setupEnvironment } from "./environment.js";
import type { WorkflowDefinition, WorkflowHandler } from "./index.js";
import { StepExecutor, type StepRequest, type WorkerResult } from "./step.js";
import { type WorkflowComponent } from "./types.js";
import { createWorkflowCtx } from "./workflowContext.js";
export type WorkflowArgs<V extends PropertyValidators, Context = unknown> = {
/**
* The arguments to pass to the Workflow handler.
*/
args: ObjectType<V>;
/**
* Whether to enqueue the Workflow for asynchronous execution only.
* By default it will start evaluating the handler's first step in the
* current transaction.
*/
startAsync?: boolean;
} & (
| {
/**
* A function handle (created with createFunctionHandle) that will be
* called when the Workflow completes.
*/
onComplete: FunctionHandle<"mutation", OnCompleteArgs<Context>>;
/**
* Context forwarded to the `onComplete` mutation.
*/
context: Context;
}
| {
onComplete?: undefined;
context?: undefined;
}
);
const vWorkflowArgs = v.union(
v.object({
workflowId: vWorkflowId,
generationNumber: v.number(),
}),
v.object({
fn: v.optional(v.string()),
args: v.any(),
startAsync: v.optional(v.boolean()),
onComplete: v.optional(v.string()),
context: v.optional(v.any()),
}),
);
// This function is defined in the calling component but then gets passed by
// function handle to the workflow component for execution. This function runs
// one "poll" of the workflow, replaying its execution from the journal until
// it blocks next.
export function workflowMutation<ArgsValidator extends PropertyValidators>(
component: WorkflowComponent,
registered: WorkflowDefinition<ArgsValidator> & {
handler: WorkflowHandler<ArgsValidator, any>;
},
defaultWorkpoolOptions?: WorkpoolOptions,
): RegisteredMutation<"internal", WorkflowArgs<ArgsValidator>, WorkflowId> {
const workpoolOptions = {
...defaultWorkpoolOptions,
...registered.workpoolOptions,
};
return internalMutationGeneric({
handler: async (ctx, args): Promise<WorkflowId> => {
if (!validate(vWorkflowArgs, args)) {
if (!("workflowId" in args) && !("args" in args)) {
const console = createLogger(workpoolOptions?.logLevel);
console.error(
`Invalid arguments for workflow: When calling it directly, use '{ args: { ...your workflow args } }'`,
);
}
assert(validate(vWorkflowArgs, args, { throw: true }));
}
let workflowId: WorkflowId, generationNumber: number;
// Direct call { args: {...}, onComplete?, context?, startAsync? }
if ("args" in args) {
const metadata = await ctx.meta.getFunctionMetadata();
// CLI/dashboard format { fn: "path/to:fn", args: {...} } (deprecated)
if ("fn" in args && typeof args.fn === "string") {
const console = createLogger(workpoolOptions?.logLevel);
if (args.fn !== metadata.name) {
console.error(
`[workflow] Error: calling workflow with { fn: "${args.fn}", args } ` +
`but the function name does not match the workflow name ${metadata.name}. Use { args: { ...yourArgs } } without "fn" to start this workflow, ` +
`or use the start() function.`,
);
throw new Error(`Invalid workflow function reference: ${args.fn}`);
}
console.warn(
`[workflow] Deprecation warning: calling a workflow with { fn, args } is deprecated. You no longer need to pass "fn". Use { args: { ...yourArgs } } to start a workflow directly.`,
);
}
const fn = makeFunctionReference(metadata.name);
const onComplete =
typeof args.onComplete === "string"
? { fnHandle: args.onComplete, context: args.context }
: undefined;
workflowId = (await ctx.runMutation(component.workflow.create, {
workflowName: metadata.name,
workflowHandle: await createFunctionHandle(fn),
workflowArgs: args.args,
maxParallelism: workpoolOptions.maxParallelism,
onComplete,
startAsync: args.startAsync ?? undefined,
createOnly: !args.startAsync, // either start async or run inline here
})) as WorkflowId;
if (args.startAsync) {
return workflowId;
}
generationNumber = 0;
} else {
workflowId = args.workflowId;
generationNumber = args.generationNumber;
}
const { workflow, logLevel, journalEntries, ok } = await ctx.runQuery(
component.journal.load,
{ workflowId, shortCircuit: true },
);
const inProgress = journalEntries.filter(({ step }) => step.inProgress);
const console = createLogger(logLevel);
if (!ok) {
console.error(`Failed to load journal for ${workflowId}`);
await ctx.runMutation(component.workflow.complete, {
workflowId,
generationNumber,
runResult: { kind: "failed", error: "Failed to load journal" },
});
return workflowId;
}
if (workflow.generationNumber !== generationNumber) {
console.error(
`Invalid generation number: ${generationNumber} running workflow ${workflow.name} (${workflowId})`,
);
return workflowId;
}
if (workflow.runResult?.kind === "success") {
console.log(`Workflow ${workflowId} completed, returning.`);
return workflowId;
}
if (inProgress.length > 0) {
console.log(
`Workflow ${workflowId} blocked by ` +
inProgress
.map((entry) => `${entry.step.name} (${entry._id})`)
.join(", "),
);
return workflowId;
}
for (const journalEntry of journalEntries) {
assert(
!journalEntry.step.inProgress,
`Assertion failed: not blocked but have in-progress journal entry`,
);
}
const channel = new BaseChannel<StepRequest>(
workpoolOptions.maxParallelism ?? 10,
);
const step = createWorkflowCtx(workflowId, channel);
const executor = new StepExecutor(
workflowId,
generationNumber,
ctx,
component,
journalEntries as JournalEntry[],
channel,
Date.now(),
workpoolOptions,
);
const restoreEnvironment = setupEnvironment(
executor.getGenerationState.bind(executor),
workflowId,
);
try {
const handlerWorker = async (): Promise<WorkerResult> => {
let runResult: RunResult;
try {
if (registered.args) {
validate(v.object(registered.args), workflow.args, {
throw: true,
db: ctx.db,
});
}
const returnValue =
(await registered.handler(step, workflow.args)) ?? null;
runResult = { kind: "success", returnValue };
if (registered.returns) {
try {
validate(asObjectValidator(registered.returns), returnValue, {
throw: true,
});
} catch (error) {
const message =
error instanceof ValidationError
? error.message
: formatErrorWithStack(error);
console.error(
"Workflow handler returned invalid return value: ",
message,
);
runResult = {
kind: "failed",
error: "Invalid return value: " + message,
};
}
}
} catch (error) {
const message = formatErrorWithStack(error);
console.error(message);
runResult = { kind: "failed", error: message };
}
return { type: "handlerDone", runResult };
};
const executorWorker = async (): Promise<WorkerResult> => {
return await executor.run();
};
const result = await Promise.race([handlerWorker(), executorWorker()]);
switch (result.type) {
case "handlerDone": {
await ctx.runMutation(component.workflow.complete, {
workflowId,
generationNumber,
runResult: result.runResult,
});
break;
}
case "executorBlocked": {
// Nothing to do, we already started steps in the StepExecutor.
break;
}
}
} finally {
restoreEnvironment();
}
return workflowId;
},
});
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const console = "THIS IS A REMINDER TO USE createLogger";