Files
triggerdotdev--trigger.dev/apps/webapp/app/v3/services/createCheckpoint.server.ts
Daniel Sutton e4ae8cbcd4 fix(run-engine,run-store,webapp): stop split-mode waits hanging on resume (#4164)
## Summary

On the run-ops database split, a run that waits (`triggerAndWait`,
`batchTriggerAndWait`, `wait.forToken`) could hang forever after its
wait had already completed. The runner reads a resume from
`/snapshots/since` exactly once: if that read returned the resume
snapshot without its completed-waitpoints, the runner logged "executing
without completed waitpoints", advanced its cursor, and never re-read
it, so the awaiting run never continued.

## Root cause

The resume snapshot and its completed-waitpoint rows were written as two
separate commits. This regressed when the split replaced Prisma's atomic
nested `connect` with an FK-free insert (in
[#4163](https://github.com/triggerdotdev/trigger.dev/pull/4163)), and
`/snapshots/since` is served from a read replica. A fetch landing in the
sub-millisecond gap between the two commits, or a multi-reader replica
serving the snapshot from a different point in time than its join rows,
delivered an empty resume. Because the runner consumes each snapshot
once and treats an empty resume as terminal, a single stale read was
fatal and produced a permanent, nondeterministic hang.

## Fixes

- Commit a snapshot and its completed-waitpoint links in one
transaction, restoring the atomicity the split removed.
- Repair the completed-waitpoints from the owning primary when a
multi-reader replica serves the snapshot without its join rows. This
covers single-waitpoint resumes, which carry no
`completedWaitpointOrder` and so were missed by the count-based repair.
- Read the primary in the checkpoint `WAIT_FOR_BATCH` pre-check, so a
batch that already resumed is not re-suspended into a stall.
- Fall back to the primary when a waitpoint token misses both read
replicas, so a token completed immediately after it was minted no longer
returns a spurious 404.
- Route batch-item creation by `batchTaskRunId`, consistent with the
batch-completion count and the row's foreign key.
- Reject control-plane-only relation selects on the dedicated schema
with a clear error instead of an opaque Prisma failure, and stop
`createDateTimeWaitpoint` bypassing residency routing through a caller
transaction.

Verified against the deployed split topology: a resume snapshot and its
completed-waitpoints are now always delivered together, so the runner
can no longer drop a resume.
2026-07-06 10:55:02 +00:00

445 lines
12 KiB
TypeScript

import type { CoordinatorToPlatformMessages, ManualCheckpointMetadata } from "@trigger.dev/core/v3";
import type { InferSocketMessageSchema } from "@trigger.dev/core/v3/zodSocket";
import type { Checkpoint, CheckpointRestoreEvent } from "@trigger.dev/database";
import { logger } from "~/services/logger.server";
import { marqs } from "~/v3/marqs/index.server";
import { isFreezableAttemptStatus, isFreezableRunStatus } from "../taskStatus";
import { BaseService } from "./baseService.server";
import { CreateCheckpointRestoreEventService } from "./createCheckpointRestoreEvent.server";
import { ResumeBatchRunService } from "./resumeBatchRun.server";
import { ResumeDependentParentsService } from "./resumeDependentParents.server";
import { CheckpointId } from "@trigger.dev/core/v3/isomorphic";
export class CreateCheckpointService extends BaseService {
public async call(
params: Omit<
InferSocketMessageSchema<typeof CoordinatorToPlatformMessages, "CHECKPOINT_CREATED">,
"version"
>
): Promise<
| {
success: true;
checkpoint: Checkpoint;
event: CheckpointRestoreEvent;
keepRunAlive: boolean;
}
| {
success: false;
keepRunAlive?: boolean;
}
> {
logger.debug(`Creating checkpoint`, params);
const attempt = await this._prisma.taskRunAttempt.findFirst({
where: {
friendlyId: params.attemptFriendlyId,
},
include: {
taskRun: true,
backgroundWorker: {
select: {
id: true,
deployment: {
select: {
imageReference: true,
},
},
},
},
},
});
if (!attempt) {
logger.error("Attempt not found", params);
return {
success: false,
};
}
if (
!isFreezableAttemptStatus(attempt.status) ||
!isFreezableRunStatus(attempt.taskRun.status)
) {
logger.error("Unfreezable state", {
attempt: {
id: attempt.id,
status: attempt.status,
},
run: {
id: attempt.taskRunId,
status: attempt.taskRun.status,
},
params,
});
return {
success: false,
keepRunAlive: true,
};
}
const imageRef = attempt.backgroundWorker.deployment?.imageReference;
if (!imageRef) {
logger.error("Missing deployment or image ref", {
attemptId: attempt.id,
workerId: attempt.backgroundWorker.id,
params,
});
return {
success: false,
};
}
const { reason } = params;
// Check if we should accept this checkpoint
switch (reason.type) {
case "MANUAL": {
// Always accept manual checkpoints
break;
}
case "WAIT_FOR_DURATION": {
// Always accept duration checkpoints
break;
}
case "WAIT_FOR_TASK": {
const childRun = await this._prisma.taskRun.findFirst({
where: {
friendlyId: reason.friendlyId,
},
select: {
dependency: {
select: {
resumedAt: true,
},
},
},
});
if (!childRun) {
logger.error("CreateCheckpointService: Pre-check - WAIT_FOR_TASK child run not found", {
friendlyId: reason.friendlyId,
params,
});
return {
success: false,
keepRunAlive: false,
};
}
if (childRun.dependency?.resumedAt) {
logger.info("CreateCheckpointService: Child run already resumed", {
childRun,
params,
});
return {
success: false,
keepRunAlive: true,
};
}
break;
}
case "WAIT_FOR_BATCH": {
// Routed by friendlyId so a run-ops id (NEW-resident) batch is found on the owning DB;
// env-scoped to the dependent attempt's run (a batch shares its dependent's env). Read the
// primary: a batch that just resumed the parent may lag the replica, and a stale resumedAt
// (null) would checkpoint (suspend) an already-resumed run -> it stalls until a sweep.
const batchRun = await this.runStore.findBatchTaskRunByFriendlyId(
reason.batchFriendlyId,
attempt.taskRun.runtimeEnvironmentId,
undefined,
this._prisma
);
if (!batchRun) {
logger.error("CreateCheckpointService: Pre-check - Batch not found", {
batchFriendlyId: reason.batchFriendlyId,
params,
});
return {
success: false,
keepRunAlive: false,
};
}
if (batchRun.resumedAt) {
logger.info("CreateCheckpointService: Batch already resumed", {
batchRun,
params,
});
return {
success: false,
keepRunAlive: true,
};
}
break;
}
default: {
break;
}
}
//sleep to test slow checkpoints
// Sleep a random value between 4 and 30 seconds
// await new Promise((resolve) => {
// const waitSeconds = Math.floor(Math.random() * 26) + 4;
// logger.log(`Sleep for ${waitSeconds} seconds`);
// setTimeout(resolve, waitSeconds * 1000);
// });
let metadata: string;
if (params.reason.type === "MANUAL") {
metadata = JSON.stringify({
...params.reason,
attemptId: attempt.id,
previousAttemptStatus: attempt.status,
previousRunStatus: attempt.taskRun.status,
} satisfies ManualCheckpointMetadata);
} else {
metadata = JSON.stringify(params.reason);
}
const checkpoint = await this._prisma.checkpoint.create({
data: {
...CheckpointId.generate(),
runtimeEnvironmentId: attempt.taskRun.runtimeEnvironmentId,
projectId: attempt.taskRun.projectId,
attemptId: attempt.id,
attemptNumber: attempt.number,
runId: attempt.taskRunId,
location: params.location,
type: params.docker ? "DOCKER" : "KUBERNETES",
reason: params.reason.type,
metadata,
imageRef,
},
});
const eventService = new CreateCheckpointRestoreEventService(this._prisma);
await this._prisma.taskRunAttempt.update({
where: {
id: attempt.id,
},
data: {
status: params.reason.type === "RETRYING_AFTER_FAILURE" ? undefined : "PAUSED",
taskRun: {
update: {
status: "WAITING_TO_RESUME",
},
},
},
});
let checkpointEvent: CheckpointRestoreEvent | undefined;
switch (reason.type) {
case "MANUAL":
case "WAIT_FOR_DURATION": {
let restoreAtUnixTimeMs: number;
if (reason.type === "MANUAL") {
// Restore immediately if not specified, useful for live migration
restoreAtUnixTimeMs = reason.restoreAtUnixTimeMs ?? Date.now();
} else {
restoreAtUnixTimeMs = reason.now + reason.ms;
}
checkpointEvent = await eventService.checkpoint({
checkpointId: checkpoint.id,
});
if (checkpointEvent) {
await marqs.requeueMessage(
attempt.taskRunId,
{
type: "RESUME_AFTER_DURATION",
resumableAttemptId: attempt.id,
checkpointEventId: checkpointEvent.id,
},
restoreAtUnixTimeMs,
"resume"
);
return {
success: true,
checkpoint,
event: checkpointEvent,
keepRunAlive: false,
};
}
break;
}
case "WAIT_FOR_TASK": {
checkpointEvent = await eventService.checkpoint({
checkpointId: checkpoint.id,
dependencyFriendlyRunId: reason.friendlyId,
});
if (checkpointEvent) {
//heartbeats will start again when the run resumes
logger.log("CreateCheckpointService: Canceling heartbeat", {
attemptId: attempt.id,
taskRunId: attempt.taskRunId,
type: "WAIT_FOR_TASK",
reason,
params,
});
await marqs?.cancelHeartbeat(attempt.taskRunId);
const childRun = await this._prisma.taskRun.findFirst({
where: {
friendlyId: reason.friendlyId,
},
});
if (!childRun) {
logger.error("CreateCheckpointService: WAIT_FOR_TASK child run not found", {
friendlyId: reason.friendlyId,
params,
});
return {
success: true,
checkpoint,
event: checkpointEvent,
keepRunAlive: false,
};
}
const resumeService = new ResumeDependentParentsService(this._prisma);
const result = await resumeService.call({ id: childRun.id });
if (result.success) {
logger.log("CreateCheckpointService: Resumed dependent parents", {
result,
childRun,
attempt,
checkpointEvent,
params,
});
} else {
logger.error("CreateCheckpointService: Failed to resume dependent parents", {
result,
childRun,
attempt,
checkpointEvent,
params,
});
}
return {
success: true,
checkpoint,
event: checkpointEvent,
keepRunAlive: false,
};
}
break;
}
case "WAIT_FOR_BATCH": {
checkpointEvent = await eventService.checkpoint({
checkpointId: checkpoint.id,
batchDependencyFriendlyId: reason.batchFriendlyId,
});
if (checkpointEvent) {
//heartbeats will start again when the run resumes
logger.log("CreateCheckpointService: Canceling heartbeat", {
attemptId: attempt.id,
taskRunId: attempt.taskRunId,
type: "WAIT_FOR_BATCH",
params,
});
await marqs?.cancelHeartbeat(attempt.taskRunId);
// Routed by friendlyId; read the primary (this._prisma) so a just-resumed batch that still
// lags the replica doesn't leave a stale resumedAt and suspend an already-resumed run.
const batchRun = await this.runStore.findBatchTaskRunByFriendlyId(
reason.batchFriendlyId,
attempt.taskRun.runtimeEnvironmentId,
undefined,
this._prisma
);
if (!batchRun) {
logger.error("CreateCheckpointService: Batch not found", {
friendlyId: reason.batchFriendlyId,
params,
});
return {
success: true,
checkpoint,
event: checkpointEvent,
keepRunAlive: false,
};
}
//if there's a message in the queue, we make sure the checkpoint event is on it
await marqs.replaceMessage(attempt.taskRun.id, {
checkpointEventId: checkpointEvent.id,
});
await ResumeBatchRunService.enqueue(batchRun.id, batchRun.batchVersion === "v3");
return {
success: true,
checkpoint,
event: checkpointEvent,
keepRunAlive: false,
};
}
break;
}
case "RETRYING_AFTER_FAILURE": {
checkpointEvent = await eventService.checkpoint({
checkpointId: checkpoint.id,
});
// ACK is already handled by attempt completion
break;
}
default: {
break;
}
}
if (!checkpointEvent) {
logger.error("No checkpoint event", {
attemptId: attempt.id,
checkpointId: checkpoint.id,
params,
});
await marqs?.acknowledgeMessage(
attempt.taskRunId,
"No checkpoint event in CreateCheckpointService"
);
return {
success: false,
};
}
return {
success: true,
checkpoint,
event: checkpointEvent,
keepRunAlive: false,
};
}
}