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:
nicktrn
2024-04-18 15:46:14 +01:00
committed by GitHub
parent c9e1a3e9c5
commit 584c7da5df
10 changed files with 150 additions and 20 deletions
+7
View File
@@ -0,0 +1,7 @@
---
"trigger.dev": patch
"@trigger.dev/core": patch
---
- Add graceful exit for prod workers
- Prevent overflow in long waits
+1
View File
@@ -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;
+2
View File
@@ -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(),
}); });
+21
View File
@@ -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;
}
+15 -1
View File
@@ -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);
});
}
},
});