@convex-dev/workflow
Version:
Convex component for durably executing workflows.
163 lines • 6.6 kB
JavaScript
import { resultValidator, vRetryBehavior, workIdValidator, Workpool, } from "@convex-dev/workpool";
import { assert } from "convex-helpers";
import { validate } from "convex-helpers/validators";
import { v } from "convex/values";
import { api, components, internal } from "./_generated/api.js";
import { internalMutation } from "./_generated/server.js";
import { logLevel } from "./logging.js";
import { getWorkflow } from "./model.js";
import { getDefaultLogger } from "./utils.js";
export const workpoolOptions = v.object({
logLevel: v.optional(logLevel),
maxParallelism: v.optional(v.number()),
defaultRetryBehavior: v.optional(vRetryBehavior),
retryActionsByDefault: v.optional(v.boolean()),
});
// type check
const _ = {};
export const DEFAULT_MAX_PARALLELISM = 25;
export const DEFAULT_RETRY_BEHAVIOR = {
maxAttempts: 5,
initialBackoffMs: 500,
base: 2,
};
export async function getWorkpool(ctx, opts) {
// nit: can fetch config only if necessary
const config = await ctx.db.query("config").first();
const logLevel = opts?.logLevel ?? config?.logLevel;
const maxParallelism = opts?.maxParallelism ?? config?.maxParallelism ?? DEFAULT_MAX_PARALLELISM;
return new Workpool(components.workpool, {
logLevel,
maxParallelism,
defaultRetryBehavior: opts?.defaultRetryBehavior ?? DEFAULT_RETRY_BEHAVIOR,
retryActionsByDefault: opts?.retryActionsByDefault ?? false,
});
}
export const onCompleteContext = v.object({
generationNumber: v.number(),
stepId: v.id("steps"),
workpoolOptions: v.optional(workpoolOptions),
});
export const onComplete = internalMutation({
args: {
workId: workIdValidator,
result: resultValidator,
context: v.any(), // Ensure we can catch invalid context to fail workflow.
},
returns: v.null(),
handler: async (ctx, args) => {
const console = await getDefaultLogger(ctx);
const stepId = args.context.stepId;
if (!validate(v.id("steps"), stepId, { db: ctx.db })) {
// Write to failures table and return
// So someone can investigate if this ever happens
console.error("Invalid onComplete context", args.context);
await ctx.db.insert("onCompleteFailures", args);
return;
}
const journalEntry = await ctx.db.get(stepId);
assert(journalEntry, `Journal entry not found: ${stepId}`);
const workflowId = journalEntry.workflowId;
const error = !validate(onCompleteContext, args.context)
? `Invalid onComplete context for workId ${args.workId}` +
JSON.stringify(args.context)
: !journalEntry.step.inProgress
? `Journal entry not in progress: ${stepId}`
: undefined;
if (error) {
await ctx.db.patch(workflowId, {
runResult: {
kind: "failed",
error,
},
});
return;
}
const { generationNumber } = args.context;
const workflow = await getWorkflow(ctx, workflowId, generationNumber);
journalEntry.step.inProgress = false;
journalEntry.step.completedAt = Date.now();
console.event("stepCompleted", {
workflowId,
workflowName: workflow.name,
status: args.result.kind,
stepName: journalEntry.step.name,
stepNumber: journalEntry.stepNumber,
durationMs: journalEntry.step.completedAt - journalEntry.step.startedAt,
});
switch (args.result.kind) {
case "success":
journalEntry.step.runResult = {
kind: "success",
returnValue: args.result.returnValue,
};
break;
case "failed":
journalEntry.step.runResult = {
kind: "failed",
error: args.result.error,
};
break;
case "canceled":
journalEntry.step.runResult = {
kind: "canceled",
};
break;
}
await ctx.db.replace(journalEntry._id, journalEntry);
console.debug(`Completed execution of ${stepId}`, journalEntry);
if (workflow.runResult === undefined) {
// TODO: Technically this doesn't obey the workpool, but...
// it's better than calling it directly, and enqueuing can now happen
// in the root component.
const workpool = await getWorkpool(ctx, args.context.workpoolOptions);
await workpool.enqueueMutation(ctx, workflow.workflowHandle, { workflowId: workflow._id, generationNumber }, {
onComplete: internal.pool.handlerOnComplete,
context: { workflowId, generationNumber },
});
}
else {
console.error(`Workflow not running: ${workflowId} when completing ${stepId}`);
}
},
});
const handlerOnCompleteContext = v.object({
workflowId: v.id("workflows"),
generationNumber: v.number(),
});
export const handlerOnComplete = internalMutation({
args: {
workId: workIdValidator,
result: resultValidator,
context: v.any(),
},
returns: v.null(),
handler: async (ctx, args) => {
if (args.result.kind !== "success") {
const console = await getDefaultLogger(ctx);
if (!validate(handlerOnCompleteContext, args.context)) {
console.error("Invalid handlerOnComplete context", args.context);
if (validate(v.id("workflows"), args.context.workflowId, { db: ctx.db })) {
await ctx.db.patch(args.context.workflowId, {
runResult: {
kind: "failed",
error: "Invalid handlerOnComplete context: " +
JSON.stringify(args.context),
},
});
}
return;
}
const { workflowId, generationNumber } = args.context;
await ctx.runMutation(api.workflow.complete, {
workflowId,
generationNumber,
runResult: args.result,
now: Date.now(),
});
}
},
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const console = "THIS IS A REMINDER TO USE getDefaultLogger";
//# sourceMappingURL=pool.js.map