v3: prod worker graceful shutdown (#1034)
* graceful exit with timeout * handle and display graceful timeout errors * fix for very long waits * changeset * increase termination grace period to an hour
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"trigger.dev": patch
|
||||||
|
"@trigger.dev/core": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
- Add graceful exit for prod workers
|
||||||
|
- Prevent overflow in long waits
|
||||||
@@ -133,6 +133,7 @@ class KubernetesTaskOperations implements TaskOperations {
|
|||||||
},
|
},
|
||||||
spec: {
|
spec: {
|
||||||
...this.#defaultPodSpec,
|
...this.#defaultPodSpec,
|
||||||
|
terminationGracePeriodSeconds: 60 * 60,
|
||||||
containers: [
|
containers: [
|
||||||
{
|
{
|
||||||
name: this.#getRunContainerName(opts.runId),
|
name: this.#getRunContainerName(opts.runId),
|
||||||
|
|||||||
@@ -248,14 +248,52 @@ export class CompleteAttemptService extends BaseService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await this._prisma.taskRun.update({
|
if (
|
||||||
where: {
|
completion.error.type === "INTERNAL_ERROR" &&
|
||||||
id: taskRunAttempt.taskRunId,
|
completion.error.code === "GRACEFUL_EXIT_TIMEOUT"
|
||||||
},
|
) {
|
||||||
data: {
|
// We need to fail all incomplete spans
|
||||||
status: "COMPLETED_WITH_ERRORS",
|
const inProgressEvents = await eventRepository.queryIncompleteEvents({
|
||||||
},
|
attemptId: execution.attempt.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
logger.debug("Failing in-progress events", {
|
||||||
|
inProgressEvents: inProgressEvents.map((event) => event.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
const exception = {
|
||||||
|
type: "Graceful exit timeout",
|
||||||
|
message: completion.error.message,
|
||||||
|
};
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
inProgressEvents.map((event) => {
|
||||||
|
return eventRepository.crashEvent({
|
||||||
|
event: event,
|
||||||
|
crashedAt: new Date(),
|
||||||
|
exception,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await this._prisma.taskRun.update({
|
||||||
|
where: {
|
||||||
|
id: taskRunAttempt.taskRunId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: "SYSTEM_FAILURE",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await this._prisma.taskRun.update({
|
||||||
|
where: {
|
||||||
|
id: taskRunAttempt.taskRunId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: "COMPLETED_WITH_ERRORS",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!env || env.type !== "DEVELOPMENT") {
|
if (!env || env.type !== "DEVELOPMENT") {
|
||||||
await ResumeTaskRunDependenciesService.enqueue(taskRunAttempt.id, this._prisma);
|
await ResumeTaskRunDependenciesService.enqueue(taskRunAttempt.id, this._prisma);
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
import { HttpReply, SimpleLogger, getRandomPortNumber } from "@trigger.dev/core-apps";
|
import { HttpReply, SimpleLogger, getRandomPortNumber } from "@trigger.dev/core-apps";
|
||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
import { z } from "zod";
|
|
||||||
import { ProdBackgroundWorker } from "./backgroundWorker";
|
import { ProdBackgroundWorker } from "./backgroundWorker";
|
||||||
import { TaskMetadataParseError, UncaughtExceptionError } from "../common/errors";
|
import { TaskMetadataParseError, UncaughtExceptionError } from "../common/errors";
|
||||||
import { setTimeout } from "node:timers/promises";
|
import { setTimeout } from "node:timers/promises";
|
||||||
@@ -58,6 +57,8 @@ class ProdWorker {
|
|||||||
port: number,
|
port: number,
|
||||||
private host = "0.0.0.0"
|
private host = "0.0.0.0"
|
||||||
) {
|
) {
|
||||||
|
process.on("SIGTERM", this.#handleSignal.bind(this, "SIGTERM"));
|
||||||
|
|
||||||
this.#coordinatorSocket = this.#createCoordinatorSocket(COORDINATOR_HOST);
|
this.#coordinatorSocket = this.#createCoordinatorSocket(COORDINATOR_HOST);
|
||||||
|
|
||||||
this.#backgroundWorker = new ProdBackgroundWorker("worker.js", {
|
this.#backgroundWorker = new ProdBackgroundWorker("worker.js", {
|
||||||
@@ -150,6 +151,36 @@ class ProdWorker {
|
|||||||
this.#httpServer = this.#createHttpServer();
|
this.#httpServer = this.#createHttpServer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async #handleSignal(signal: NodeJS.Signals) {
|
||||||
|
logger.log("Received signal", { signal });
|
||||||
|
|
||||||
|
if (signal === "SIGTERM") {
|
||||||
|
if (this.executing) {
|
||||||
|
const terminationGracePeriodSeconds = 60 * 60;
|
||||||
|
|
||||||
|
logger.log("Waiting for attempt to complete before exiting", {
|
||||||
|
terminationGracePeriodSeconds,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for termination grace period minus 5s to give cleanup a chance to complete
|
||||||
|
await setTimeout(terminationGracePeriodSeconds * 1000 - 5000);
|
||||||
|
|
||||||
|
logger.log("Termination timeout reached, exiting gracefully.");
|
||||||
|
} else {
|
||||||
|
logger.log("Not executing, exiting immediately.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.#exitGracefully();
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log("Unhandled signal", { signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
async #exitGracefully() {
|
||||||
|
await this.#backgroundWorker.close();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
async #reconnect(isPostStart = false, reconnectImmediately = false) {
|
async #reconnect(isPostStart = false, reconnectImmediately = false) {
|
||||||
if (isPostStart) {
|
if (isPostStart) {
|
||||||
this.waitForPostStart = false;
|
this.waitForPostStart = false;
|
||||||
@@ -206,8 +237,7 @@ class ProdWorker {
|
|||||||
logger.log("WARNING: Will checkpoint but also requested exit. This won't end well.");
|
logger.log("WARNING: Will checkpoint but also requested exit. This won't end well.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.#backgroundWorker.close();
|
await this.#exitGracefully();
|
||||||
process.exit(0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.executing = false;
|
this.executing = false;
|
||||||
@@ -605,7 +635,6 @@ class ProdWorker {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logger.log("preStop", { url: req.url });
|
|
||||||
|
|
||||||
return reply.text("preStop ok");
|
return reply.text("preStop ok");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,6 +175,23 @@ const zodIpc = new ZodIpcConnection({
|
|||||||
CLEANUP: async ({ flush, kill }, sender) => {
|
CLEANUP: async ({ flush, kill }, sender) => {
|
||||||
if (kill) {
|
if (kill) {
|
||||||
await tracingSDK.flush();
|
await tracingSDK.flush();
|
||||||
|
|
||||||
|
if (_execution) {
|
||||||
|
// Fail currently executing attempt
|
||||||
|
await sender.send("TASK_RUN_COMPLETED", {
|
||||||
|
execution: _execution,
|
||||||
|
result: {
|
||||||
|
ok: false,
|
||||||
|
id: _execution.attempt.id,
|
||||||
|
error: {
|
||||||
|
type: "INTERNAL_ERROR",
|
||||||
|
code: TaskRunErrorCodes.GRACEFUL_EXIT_TIMEOUT,
|
||||||
|
message: "Worker process killed while attempt in progress.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Now we need to exit the process
|
// Now we need to exit the process
|
||||||
await sender.send("READY_TO_DISPOSE", undefined);
|
await sender.send("READY_TO_DISPOSE", undefined);
|
||||||
} else {
|
} else {
|
||||||
@@ -186,6 +203,9 @@ const zodIpc = new ZodIpcConnection({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Ignore SIGTERM, handled by entry point
|
||||||
|
process.on("SIGTERM", async () => {});
|
||||||
|
|
||||||
const prodRuntimeManager = new ProdRuntimeManager(zodIpc, {
|
const prodRuntimeManager = new ProdRuntimeManager(zodIpc, {
|
||||||
waitThresholdInMs: parseInt(process.env.TRIGGER_RUNTIME_WAIT_THRESHOLD_IN_MS ?? "30000", 10),
|
waitThresholdInMs: parseInt(process.env.TRIGGER_RUNTIME_WAIT_THRESHOLD_IN_MS ?? "30000", 10),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
TaskRunExecutionResult,
|
TaskRunExecutionResult,
|
||||||
} from "../schemas";
|
} from "../schemas";
|
||||||
import { RuntimeManager } from "./manager";
|
import { RuntimeManager } from "./manager";
|
||||||
|
import { unboundedTimeout } from "../utils/timers";
|
||||||
|
|
||||||
export class DevRuntimeManager implements RuntimeManager {
|
export class DevRuntimeManager implements RuntimeManager {
|
||||||
_taskWaits: Map<
|
_taskWaits: Map<
|
||||||
@@ -24,15 +25,11 @@ export class DevRuntimeManager implements RuntimeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async waitForDuration(ms: number): Promise<void> {
|
async waitForDuration(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => {
|
await unboundedTimeout(ms);
|
||||||
setTimeout(resolve, ms);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async waitUntil(date: Date): Promise<void> {
|
async waitUntil(date: Date): Promise<void> {
|
||||||
return new Promise((resolve) => {
|
return this.waitForDuration(date.getTime() - Date.now());
|
||||||
setTimeout(resolve, date.getTime() - Date.now());
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async waitForTask(params: { id: string; ctx: TaskRunContext }): Promise<TaskRunExecutionResult> {
|
async waitForTask(params: { id: string; ctx: TaskRunContext }): Promise<TaskRunExecutionResult> {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from "../schemas";
|
} from "../schemas";
|
||||||
import { ZodIpcConnection } from "../zodIpc";
|
import { ZodIpcConnection } from "../zodIpc";
|
||||||
import { RuntimeManager } from "./manager";
|
import { RuntimeManager } from "./manager";
|
||||||
|
import { unboundedTimeout } from "../utils/timers";
|
||||||
|
|
||||||
export type ProdRuntimeManagerOptions = {
|
export type ProdRuntimeManagerOptions = {
|
||||||
waitThresholdInMs?: number;
|
waitThresholdInMs?: number;
|
||||||
@@ -43,7 +44,7 @@ export class ProdRuntimeManager implements RuntimeManager {
|
|||||||
async waitForDuration(ms: number): Promise<void> {
|
async waitForDuration(ms: number): Promise<void> {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
const resolveAfterDuration = setTimeout(ms, "duration" as const);
|
const resolveAfterDuration = unboundedTimeout(ms, "duration" as const);
|
||||||
|
|
||||||
if (ms <= this.waitThresholdInMs) {
|
if (ms <= this.waitThresholdInMs) {
|
||||||
await resolveAfterDuration;
|
await resolveAfterDuration;
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export const TaskRunErrorCodes = {
|
|||||||
TASK_RUN_CANCELLED: "TASK_RUN_CANCELLED",
|
TASK_RUN_CANCELLED: "TASK_RUN_CANCELLED",
|
||||||
TASK_OUTPUT_ERROR: "TASK_OUTPUT_ERROR",
|
TASK_OUTPUT_ERROR: "TASK_OUTPUT_ERROR",
|
||||||
HANDLE_ERROR_ERROR: "HANDLE_ERROR_ERROR",
|
HANDLE_ERROR_ERROR: "HANDLE_ERROR_ERROR",
|
||||||
|
GRACEFUL_EXIT_TIMEOUT: "GRACEFUL_EXIT_TIMEOUT",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const TaskRunInternalError = z.object({
|
export const TaskRunInternalError = z.object({
|
||||||
@@ -49,6 +50,7 @@ export const TaskRunInternalError = z.object({
|
|||||||
"TASK_RUN_CANCELLED",
|
"TASK_RUN_CANCELLED",
|
||||||
"TASK_OUTPUT_ERROR",
|
"TASK_OUTPUT_ERROR",
|
||||||
"HANDLE_ERROR_ERROR",
|
"HANDLE_ERROR_ERROR",
|
||||||
|
"GRACEFUL_EXIT_TIMEOUT"
|
||||||
]),
|
]),
|
||||||
message: z.string().optional(),
|
message: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { TimerOptions } from "node:timers";
|
||||||
|
import { setTimeout } from "node:timers/promises";
|
||||||
|
|
||||||
|
export async function unboundedTimeout<T = void>(
|
||||||
|
delay: number = 0,
|
||||||
|
value?: T,
|
||||||
|
options?: TimerOptions
|
||||||
|
): Promise<T> {
|
||||||
|
const maxDelay = 2147483647; // Highest value that will fit in a 32-bit signed integer
|
||||||
|
|
||||||
|
const fullTimeouts = Math.floor(delay / maxDelay);
|
||||||
|
const remainingDelay = delay % maxDelay;
|
||||||
|
|
||||||
|
let lastTimeoutResult = await setTimeout(remainingDelay, value, options);
|
||||||
|
|
||||||
|
for (let i = 0; i < fullTimeouts; i++) {
|
||||||
|
lastTimeoutResult = await setTimeout(maxDelay, value, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastTimeoutResult;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { task } from "@trigger.dev/sdk/v3";
|
import { logger, task, wait } from "@trigger.dev/sdk/v3";
|
||||||
|
import { setTimeout } from "node:timers/promises";
|
||||||
|
|
||||||
export const loggingTask = task({
|
export const loggingTask = task({
|
||||||
id: "logging-task-2",
|
id: "logging-task-2",
|
||||||
@@ -6,3 +7,16 @@ export const loggingTask = task({
|
|||||||
console.log("Hello world");
|
console.log("Hello world");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const waitForever = task({
|
||||||
|
id: "wait-forever",
|
||||||
|
run: async (payload: { freeze?: boolean }) => {
|
||||||
|
if (payload.freeze) {
|
||||||
|
await wait.for({ years: 9999 });
|
||||||
|
} else {
|
||||||
|
await logger.trace("Waiting..", async () => {
|
||||||
|
await setTimeout(2147483647);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user