@convex-dev/workflow
Version:
Convex component for durably executing workflows.
170 lines • 6.46 kB
JavaScript
import { vOnComplete, vResultValidator } from "@convex-dev/workpool";
import { assert } from "convex-helpers";
import { v } from "convex/values";
import { mutation, query } from "./_generated/server.js";
import { createLogger, logLevel } from "./logging.js";
import { getWorkflow } from "./model.js";
import { getWorkpool } from "./pool.js";
import { journalDocument, workflowDocument } from "./schema.js";
import { getDefaultLogger } from "./utils.js";
export const create = mutation({
args: {
workflowName: v.string(),
workflowHandle: v.string(),
workflowArgs: v.any(),
maxParallelism: v.optional(v.number()),
onComplete: v.optional(vOnComplete),
// TODO: ttl
},
returns: v.string(),
handler: async (ctx, args) => {
const now = Date.now();
const console = await getDefaultLogger(ctx);
await updateMaxParallelism(ctx, console, args.maxParallelism);
const workflowId = await ctx.db.insert("workflows", {
name: args.workflowName,
workflowHandle: args.workflowHandle,
args: args.workflowArgs,
generationNumber: 0,
onComplete: args.onComplete,
});
console.debug(`Created workflow ${workflowId}:`, args.workflowArgs, args.workflowHandle);
// If we can't start it, may as well not create it, eh? Fail fast...
await ctx.runMutation(args.workflowHandle, {
workflowId,
generationNumber: 0,
});
return workflowId;
},
});
export const getStatus = query({
args: {
workflowId: v.id("workflows"),
},
returns: v.object({
workflow: workflowDocument,
inProgress: v.array(journalDocument),
logLevel: logLevel,
}),
handler: getStatusHandler,
});
export async function getStatusHandler(ctx, args) {
const workflow = await ctx.db.get(args.workflowId);
assert(workflow, `Workflow not found: ${args.workflowId}`);
const console = await getDefaultLogger(ctx);
const result = [];
const inProgressEntries = await ctx.db
.query("steps")
.withIndex("inProgress", (q) => q.eq("step.inProgress", true).eq("workflowId", args.workflowId))
.collect();
result.push(...inProgressEntries);
console.debug(`${args.workflowId} blocked by`, result);
return { workflow, inProgress: result, logLevel: console.logLevel };
}
export const cancel = mutation({
args: {
workflowId: v.id("workflows"),
},
returns: v.null(),
handler: async (ctx, { workflowId }) => {
const { workflow, inProgress, logLevel } = await getStatusHandler(ctx, {
workflowId,
});
const console = createLogger(logLevel);
if (inProgress.length > 0) {
const workpool = await getWorkpool(ctx, {});
for (const step of inProgress) {
if (step.step.workId) {
await workpool.cancel(ctx, step.step.workId);
}
}
}
assert(workflow.runResult === undefined, `Not running: ${workflowId}`);
workflow.runResult = { kind: "canceled" };
workflow.generationNumber += 1;
console.debug(`Canceled workflow ${workflowId}:`, workflow);
// TODO: Call onComplete hook
// TODO: delete everything unless ttl is set
await ctx.db.replace(workflow._id, workflow);
},
});
export const complete = mutation({
args: {
workflowId: v.id("workflows"),
generationNumber: v.number(),
runResult: vResultValidator,
now: v.number(),
},
returns: v.null(),
handler: async (ctx, args) => {
const workflow = await getWorkflow(ctx, args.workflowId, args.generationNumber);
const console = await getDefaultLogger(ctx);
if (workflow.runResult) {
throw new Error(`Workflow not running: ${workflow}`);
}
workflow.runResult = args.runResult;
console.event("completed", {
workflowId: workflow._id,
name: workflow.name,
status: workflow.runResult.kind,
overallDurationMs: Date.now() - workflow._creationTime,
});
if (workflow.onComplete) {
await ctx.runMutation(workflow.onComplete.fnHandle, {
workflowId: workflow._id,
result: workflow.runResult,
context: workflow.onComplete.context,
});
}
// TODO: delete everything unless ttl is set
console.debug(`Completed workflow ${workflow._id}:`, workflow);
await ctx.db.replace(workflow._id, workflow);
},
});
export const cleanup = mutation({
args: {
workflowId: v.string(),
},
returns: v.boolean(),
handler: async (ctx, args) => {
const workflowId = ctx.db.normalizeId("workflows", args.workflowId);
if (!workflowId) {
throw new Error(`Invalid workflow ID: ${args.workflowId}`);
}
const workflow = await ctx.db.get(workflowId);
if (!workflow) {
return false;
}
const logger = await getDefaultLogger(ctx);
if (workflow.runResult?.kind !== "success") {
logger.debug(`Can't clean up workflow ${workflowId} since it hasn't completed.`);
return false;
}
logger.debug(`Cleaning up workflow ${workflowId}`, workflow);
await ctx.db.delete(workflowId);
const journalEntries = await ctx.db
.query("steps")
.withIndex("workflow", (q) => q.eq("workflowId", workflowId))
.collect();
for (const journalEntry of journalEntries) {
logger.debug("Deleting journal entry", journalEntry);
await ctx.db.delete(journalEntry._id);
}
return true;
},
});
async function updateMaxParallelism(ctx, console, maxParallelism) {
const config = await ctx.db.query("config").first();
if (config) {
if (maxParallelism && maxParallelism !== config.maxParallelism) {
console.warn("Updating max parallelism to", maxParallelism);
await ctx.db.patch(config._id, { maxParallelism });
}
}
else {
await ctx.db.insert("config", { maxParallelism });
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const console = "THIS IS A REMINDER TO USE getDefaultLogger";
//# sourceMappingURL=workflow.js.map