Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,10 @@ To get the result of your function, you can either write to the database from
within your function, call or schedule another function from there, or use the
`onComplete` handler to respond to the job result.

If a job throws a `ConvexError`, its failed result also includes `errorData`,
containing the original `ConvexError.data`. The `error` string remains available
for logging. Ordinary errors and older recorded failures have no `errorData`.

## Get started

### Pre-requisite: Convex
Expand Down
2 changes: 2 additions & 0 deletions example/convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
*/

import type * as crons from "../crons.js";
import type * as errorData from "../errorData.js";
import type * as example from "../example.js";
import type * as staticHosting from "../staticHosting.js";
import type * as test_dashboard from "../test/dashboard.js";
Expand All @@ -35,6 +36,7 @@ import type {

declare const fullApi: ApiFromModules<{
crons: typeof crons;
errorData: typeof errorData;
example: typeof example;
staticHosting: typeof staticHosting;
"test/dashboard": typeof test_dashboard;
Expand Down
58 changes: 58 additions & 0 deletions example/convex/errorData.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { Workpool } from "@convex-dev/workpool";
import workpool from "@convex-dev/workpool/test";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { components, internal } from "./_generated/api.js";
import { initConvexTest } from "./setup.test.js";

const pool = new Workpool(components.bigPool, { logLevel: "ERROR" });
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());

test.each(["query", "mutation", "action"] as const)(
"preserves ConvexError data in the %s completion callback",
async (functionType) => {
const t = initConvexTest();
workpool.register(t, "bigPool");
const data = { code: "INVALID", count: 7 };
const options = {
onComplete: internal.errorData.onComplete,
context: null,
};
await t.mutation(async (ctx) => {
switch (functionType) {
case "query":
return await pool.enqueueQuery(
ctx,
internal.errorData.failingQuery,
{ data },
options,
);
case "mutation":
return await pool.enqueueMutation(
ctx,
internal.errorData.failingMutation,
{ data },
options,
);
case "action":
return await pool.enqueueAction(
ctx,
internal.errorData.failingAction,
{ data },
{
...options,
retry: { maxAttempts: 2, initialBackoffMs: 1, base: 1 },
Comment thread
ianmacartney marked this conversation as resolved.
Resolved
},
);
}
});
await t.finishAllScheduledFunctions(vi.runAllTimers);
const events = await t.query((ctx) => ctx.db.query("data").take(4));
expect(events.map(({ misc }) => misc.kind)).toEqual(
functionType === "action" ? ["attempt", "attempt", "failed"] : ["failed"],
);
const result = events[events.length - 1].misc;
expect(result).toMatchObject({ kind: "failed", errorData: data });
expect(typeof result.error).toBe("string");
},
);
50 changes: 50 additions & 0 deletions example/convex/errorData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { vOnCompleteArgs } from "@convex-dev/workpool";
import { ConvexError, v, type Value } from "convex/values";
import { internal } from "./_generated/api.js";
import {
internalAction,
internalMutation,
internalQuery,
} from "./_generated/server.js";

const args = { data: v.any() };
const fail = (_ctx: unknown, { data }: { data: Value }): never => {
throw new ConvexError(data);
};

export const failingQuery = internalQuery({
args,
returns: v.null(),
handler: fail,
});
export const failingMutation = internalMutation({
args,
returns: v.null(),
handler: fail,
});
export const failingAction = internalAction({
args,
returns: v.null(),
handler: async (ctx, args): Promise<never> => {
await ctx.runMutation(internal.errorData.recordActionAttempt, {});
return fail(ctx, args);
},
});

export const recordActionAttempt = internalMutation({
args: {},
returns: v.null(),
handler: async (ctx) => {
await ctx.db.insert("data", { misc: { kind: "attempt" } });
return null;
},
});

export const onComplete = internalMutation({
args: vOnCompleteArgs(v.null()),
returns: v.null(),
handler: async (ctx, { result }) => {
await ctx.db.insert("data", { misc: result });
return null;
},
});
4 changes: 4 additions & 0 deletions src/client/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,10 @@ describe("completion callbacks through the client and scheduler", () => {
expect((await events("terminal"))[1].result).toEqual({
kind: "failed",
error: "work failed",
errorData: {
__convexWorkpoolNonRetryable: true,
message: "work failed",
},
});
});

Expand Down
39 changes: 39 additions & 0 deletions src/component/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,50 @@ import {
getNonRetryableErrorMessage,
isNonRetryableError,
NonRetryableError,
failedResult,
} from "./errors.js";

class CustomTerminalError extends NonRetryableError {}

describe("errors", () => {
it.each([
null,
false,
0,
"",
"invalid input",
{ code: "INVALID", count: 7n },
])("preserves ConvexError data: %s", (data) => {
const error = new ConvexError(data);
expect(failedResult(error)).toEqual({
kind: "failed",
error: error.message,
errorData: data,
});
});

it("keeps ordinary failures as strings, even if they have a data property", () => {
expect(
failedResult(Object.assign(new Error("failure"), { data: "extra" })),
).toEqual({
kind: "failed",
error: "failure",
});
expect(failedResult("failure")).toEqual({
kind: "failed",
error: "failure",
});
});

it("preserves NonRetryableError data without changing its error message", () => {
const error = new NonRetryableError("invalid input");
expect(failedResult(error)).toEqual({
kind: "failed",
error: "invalid input",
errorData: error.data,
});
});

it("identifies non-retryable error subclasses", () => {
// Users should be able to model domain-specific terminal failures.
const error = new CustomTerminalError("invalid input");
Expand Down
13 changes: 13 additions & 0 deletions src/component/errors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,17 @@
import { ConvexError } from "convex/values";
import type { RunResult } from "./shared.js";

export function failedResult(
error: unknown,
): Extract<RunResult, { kind: "failed" }> {
return {
kind: "failed",
error:
getNonRetryableErrorMessage(error) ??
(error instanceof Error ? error.message : String(error)),
...(error instanceof ConvexError ? { errorData: error.data ?? null } : {}),
};
}

const NON_RETRYABLE_ERROR_MARKER = "__convexWorkpoolNonRetryable";

Expand Down
5 changes: 4 additions & 1 deletion src/component/shared.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Infer, Validator, VAny } from "convex/values";
import type { Infer, Validator, Value, VAny } from "convex/values";

import { v } from "convex/values";
import { type Logger, logLevel } from "./logging.js";
Expand Down Expand Up @@ -84,6 +84,7 @@ export function vRunResult<RV extends Validator<any, any, any> = VAny>(
v.object({
kind: v.literal("failed"),
error: v.string(),
errorData: v.optional(v.any()),
}),
v.object({
kind: v.literal("canceled"),
Expand All @@ -104,6 +105,8 @@ export type RunResult<Returns = unknown> =
* The error message of the run, if it failed.
*/
error: string;
/** The original ConvexError data, when the job threw a ConvexError. */
errorData?: Value;
}
| {
kind: "canceled";
Expand Down
17 changes: 3 additions & 14 deletions src/component/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
internalQuery,
type ActionCtx,
} from "./_generated/server.js";
import { getNonRetryableErrorMessage, isNonRetryableError } from "./errors.js";
import { failedResult, isNonRetryableError } from "./errors.js";
import { createLogger, type Logger, logLevel } from "./logging.js";
import type { RunResult } from "./shared.js";
import type { CompleteJob } from "./complete.js";
Expand Down Expand Up @@ -66,7 +66,7 @@ export const runMutationWrapper = internalMutation({
});
} catch (e: unknown) {
console.error(e);
const runResult = { kind: "failed" as const, error: formatError(e) };
const runResult = failedResult(e);
await ctx.scheduler.runAfter(0, internal.complete.complete, {
jobs: [
{
Expand All @@ -81,17 +81,6 @@ export const runMutationWrapper = internalMutation({
},
});

function formatError(e: unknown) {
const nonRetryableMessage = getNonRetryableErrorMessage(e);
if (nonRetryableMessage !== undefined) {
return nonRetryableMessage;
}
if (e instanceof Error) {
return e.message;
}
return String(e);
}

/** Legacy entrypoint, keeping around for graceful upgrade for scheduled functions in flight */
export const runActionWrapper = internalAction({
args: {
Expand Down Expand Up @@ -193,7 +182,7 @@ async function runOne(
console.error(e);
// We let the main loop handle the retries.
return {
runResult: { kind: "failed", error: formatError(e) } satisfies RunResult,
runResult: failedResult(e),
nonRetryable: isNonRetryableError(e),
};
}
Expand Down
Loading