Skip to content

Commit eb687fc

Browse files
claude[bot]clauded-csmatt-aitken
authored
fix(run-engine): don't mislabel DB errors as UnclassifiableWaitpointId in completeWaitpoint (#4259)
## Summary The waitpoint completion path wrapped every error from the store-resolution step (`runStore.forWaitpointCompletion`) as `UnclassifiableWaitpointId`. That step probes the database to find the owning store, so a transient connection failure surfaced as a misleading "unclassifiable waitpointId" error, hiding the real cause and losing the underlying error's type, retryability and grouping. During a brief database failover this mislabel sent an incident investigation down a false trail before the real connection error was found underneath. ## Fix The catch is narrowed: only a genuine `UnclassifiableRunId` (the documented classification-failure signal from `RunStore.forWaitpointCompletion`) becomes `UnclassifiableWaitpointId`. Every other error, including database connectivity failures, is rethrown unchanged. With the default classifier this also turns `UnclassifiableWaitpointId` into a clean signal: it no longer fires on infra noise, so any occurrence indicates a real id-routing defect worth alerting on. Recovery does not depend on this change, since lost completion side effects are re-delivered by the finalization guard from [#4849](#4849); this PR is about diagnosing failures correctly. A hermetic unit test locks both behaviors: a database error bubbles up unchanged, and a classification failure is wrapped with the original error as `cause`. --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Daniel Sutton <45313566+d-cs@users.noreply.github.com> Co-authored-by: Matt Aitken <matt@mattaitken.com>
1 parent baeff45 commit eb687fc

2 files changed

Lines changed: 106 additions & 3 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// completeWaitpoint's store-selection guard must only turn a genuine id-classification
2+
// failure into UnclassifiableWaitpointId. forWaitpointCompletion also probes the DB to
3+
// resolve the owning store, so a transient database/infra error can surface from the same
4+
// call — and those must bubble up UNCHANGED (keeping their original type, retryability, and
5+
// error grouping) rather than being mislabelled as an unclassifiable id.
6+
//
7+
// This is a hermetic unit test: the error is thrown on the very first line of
8+
// completeWaitpoint (runStore.forWaitpointCompletion), before any snapshot/enqueue work,
9+
// so we can drive it with a minimal SystemResources and a fake runStore — no DB, no Redis.
10+
import { UnclassifiableRunId } from "@trigger.dev/core/v3/isomorphic";
11+
import { expect } from "vitest";
12+
import { UnclassifiableWaitpointId } from "../errors.js";
13+
import type { SystemResources } from "../systems/systems.js";
14+
import { WaitpointSystem } from "../systems/waitpointSystem.js";
15+
16+
function createWaitpointSystem(forWaitpointCompletion: () => Promise<never>) {
17+
const runStore = { forWaitpointCompletion };
18+
19+
const resources = {
20+
runStore,
21+
logger: {
22+
error: vi.fn(),
23+
warn: vi.fn(),
24+
info: vi.fn(),
25+
debug: vi.fn(),
26+
},
27+
} as unknown as SystemResources;
28+
29+
return new WaitpointSystem({
30+
resources,
31+
// Never reached on the store-resolution error path.
32+
executionSnapshotSystem: {} as any,
33+
enqueueSystem: {} as any,
34+
});
35+
}
36+
37+
describe("completeWaitpoint store-resolution error classification", () => {
38+
it("rethrows a transient database error unchanged (never wraps it as UnclassifiableWaitpointId)", async () => {
39+
const dbError = new Error("Can't reach database server at db:5432");
40+
const waitpointSystem = createWaitpointSystem(() => Promise.reject(dbError));
41+
42+
// The original error bubbles up as-is...
43+
await expect(waitpointSystem.completeWaitpoint({ id: "waitpoint_transient" })).rejects.toBe(
44+
dbError
45+
);
46+
// ...and is NOT relabelled as a classification failure.
47+
await expect(
48+
waitpointSystem.completeWaitpoint({ id: "waitpoint_transient" })
49+
).rejects.not.toBeInstanceOf(UnclassifiableWaitpointId);
50+
});
51+
52+
it("wraps a classification failure from a foreign module instance (matched by name, not instanceof)", async () => {
53+
const waitpointId = "waitpoint_foreign_instance";
54+
const foreignError = new Error(`Unclassifiable run-ops id: ${waitpointId}`);
55+
foreignError.name = "UnclassifiableRunId";
56+
const waitpointSystem = createWaitpointSystem(() => Promise.reject(foreignError));
57+
58+
const caught = (await waitpointSystem
59+
.completeWaitpoint({ id: waitpointId })
60+
.catch((error: unknown) => error)) as UnclassifiableWaitpointId;
61+
expect(caught).toBeInstanceOf(UnclassifiableWaitpointId);
62+
expect(caught.waitpointId).toBe(waitpointId);
63+
expect(caught.cause).toBe(foreignError);
64+
});
65+
66+
it("wraps a genuine UnclassifiableRunId as UnclassifiableWaitpointId with the original as cause", async () => {
67+
const waitpointId = "waitpoint_unclassifiable";
68+
const classificationError = new UnclassifiableRunId(waitpointId);
69+
const waitpointSystem = createWaitpointSystem(() => Promise.reject(classificationError));
70+
71+
await expect(waitpointSystem.completeWaitpoint({ id: waitpointId })).rejects.toBeInstanceOf(
72+
UnclassifiableWaitpointId
73+
);
74+
75+
const caught = (await waitpointSystem
76+
.completeWaitpoint({ id: waitpointId })
77+
.catch((error: unknown) => error)) as UnclassifiableWaitpointId;
78+
expect(caught).toBeInstanceOf(UnclassifiableWaitpointId);
79+
expect(caught.waitpointId).toBe(waitpointId);
80+
expect(caught.cause).toBe(classificationError);
81+
});
82+
});

internal-packages/run-engine/src/engine/waitpointCoordinator/legacyPostgresCoordinator.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import type { RunStore } from "@internal/run-store";
22
import { tryCatch } from "@trigger.dev/core/v3";
3-
import { mintWaitpointIdFor, mintWaitpointIdForShard } from "@trigger.dev/core/v3/isomorphic";
3+
import {
4+
mintWaitpointIdFor,
5+
mintWaitpointIdForShard,
6+
UnclassifiableRunId,
7+
} from "@trigger.dev/core/v3/isomorphic";
48
import type { Logger } from "@trigger.dev/core/logger";
59
import type { PrismaClient, Waitpoint } from "@trigger.dev/database";
610
import { boundedIn, Prisma } from "@trigger.dev/database";
@@ -118,11 +122,28 @@ export class LegacyPostgresWaitpointCoordinator implements WaitpointCoordinator
118122
try {
119123
store = await this.runStore.forWaitpointCompletion(waitpointId, { routeKind: "MANUAL" });
120124
} catch (error) {
121-
this.logger.error("completeWaitpoint: unclassifiable waitpointId", {
125+
// Only a genuine id-classification failure should become UnclassifiableWaitpointId.
126+
// forWaitpointCompletion also probes the DB to resolve the owning store, so a transient
127+
// database/infra error (e.g. can't reach the database) can surface here too. Those MUST
128+
// bubble up unchanged so they keep their original type, retryability, and error grouping
129+
// instead of being mislabelled as an unclassifiable id.
130+
const isClassificationFailure =
131+
error instanceof UnclassifiableRunId ||
132+
(error instanceof Error && error.name === "UnclassifiableRunId");
133+
134+
if (isClassificationFailure) {
135+
this.logger.error("completeWaitpoint: unclassifiable waitpointId", {
136+
waitpointId,
137+
error,
138+
});
139+
throw new UnclassifiableWaitpointId(waitpointId, { cause: error });
140+
}
141+
142+
this.logger.error("completeWaitpoint: error resolving waitpoint store", {
122143
waitpointId,
123144
error,
124145
});
125-
throw new UnclassifiableWaitpointId(waitpointId, { cause: error });
146+
throw error;
126147
}
127148

128149
// 1. Complete the Waitpoint (if not completed)

0 commit comments

Comments
 (0)