Managed run controller revamp (#1927)

* update nypm to support text-based bun lockfiles

* fix retry spans

* only download debug logs if admin

* add nypm changeset

* pull out env override logic

* use runner env gather helper

* handle dev flushing failures gracefully

* fix path normalization for init.ts

* add logger

* add execution heartbeat service

* add snapshot poller service

* fix poller

* add changesets

* create socket in constructor

* enable strictPropertyInitialization

* deprecate dequeue from version

* start is not async

* dependency injection in prep for tests

* add warm start count to all controller logs

* add restore count

* pull out run execution logic

* temp disable pre

* add a controller log when starting an execution

* refactor execution and squash some bugs

* cleanup completed docker containers by default

* execution fixes and logging improvements

* don't throw afet abort cleanup

* poller should use private interval

* rename heartbeat service file

* rename HeartbeatService to IntervalService

* restore old heartbeat service but deprecate it

* use the new interval service everywhere

* Revert "temp disable pre"

This reverts commit e03f4179de6a731c17253b68a6e00bcb7ac1736b.

* add changeset

* replace all run engine find uniques with find first
This commit is contained in:
nicktrn
2025-04-15 16:08:51 +01:00
committed by GitHub
parent 0b2eb34ea6
commit 3f97cf4a6b
35 changed files with 2213 additions and 1768 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"trigger.dev": patch
"@trigger.dev/core": patch
---
Managed run controller performance and reliability improvements
+1 -1
View File
@@ -10,7 +10,7 @@
"strict": true,
"alwaysStrict": true,
"strictPropertyInitialization": false,
"strictPropertyInitialization": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noUnusedLocals": false,
+1
View File
@@ -27,6 +27,7 @@ const Env = z.object({
RUNNER_HEARTBEAT_INTERVAL_SECONDS: z.coerce.number().optional(),
RUNNER_SNAPSHOT_POLL_INTERVAL_SECONDS: z.coerce.number().optional(),
RUNNER_ADDITIONAL_ENV_VARS: AdditionalEnvVars, // optional (csv)
RUNNER_DOCKER_AUTOREMOVE: BoolEnv.default(true),
// Dequeue settings (provider mode)
TRIGGER_DEQUEUE_ENABLED: BoolEnv.default("true"),
+1
View File
@@ -66,6 +66,7 @@ class ManagedSupervisor {
heartbeatIntervalSeconds: env.RUNNER_HEARTBEAT_INTERVAL_SECONDS,
snapshotPollIntervalSeconds: env.RUNNER_SNAPSHOT_POLL_INTERVAL_SECONDS,
additionalEnvVars: env.RUNNER_ADDITIONAL_ENV_VARS,
dockerAutoremove: env.RUNNER_DOCKER_AUTOREMOVE,
} satisfies WorkloadManagerOptions;
if (this.isKubernetes) {
+6 -6
View File
@@ -1,7 +1,7 @@
import { SimpleStructuredLogger } from "@trigger.dev/core/v3/utils/structuredLogger";
import { K8sApi } from "../clients/kubernetes.js";
import { createK8sApi } from "../clients/kubernetes.js";
import { HeartbeatService } from "@trigger.dev/core/v3";
import { IntervalService } from "@trigger.dev/core/v3";
import { Counter, Gauge, Registry } from "prom-client";
import { register } from "../metrics.js";
@@ -19,7 +19,7 @@ export class PodCleaner {
private readonly namespace: string;
private readonly batchSize: number;
private readonly deletionHeartbeat: HeartbeatService;
private readonly deletionInterval: IntervalService;
// Metrics
private readonly register: Registry;
@@ -32,10 +32,10 @@ export class PodCleaner {
this.namespace = opts.namespace;
this.batchSize = opts.batchSize ?? 500;
this.deletionHeartbeat = new HeartbeatService({
this.deletionInterval = new IntervalService({
intervalMs: opts.intervalMs ?? 10000,
leadingEdge: true,
heartbeat: this.deleteCompletedPods.bind(this),
onInterval: this.deleteCompletedPods.bind(this),
});
// Initialize metrics
@@ -57,11 +57,11 @@ export class PodCleaner {
}
async start() {
this.deletionHeartbeat.start();
this.deletionInterval.start();
}
async stop() {
this.deletionHeartbeat.stop();
this.deletionInterval.stop();
}
private async deleteCompletedPods() {
@@ -43,6 +43,10 @@ export class DockerWorkloadManager implements WorkloadManager {
`--name=${runnerId}`,
];
if (this.opts.dockerAutoremove) {
runArgs.push("--rm");
}
if (this.opts.warmStartUrl) {
runArgs.push(`--env=TRIGGER_WARM_START_URL=${this.opts.warmStartUrl}`);
}
@@ -10,6 +10,7 @@ export interface WorkloadManagerOptions {
heartbeatIntervalSeconds?: number;
snapshotPollIntervalSeconds?: number;
additionalEnvVars?: Record<string, string>;
dockerAutoremove?: boolean;
}
export interface WorkloadManager {
+1 -1
View File
@@ -452,7 +452,7 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
logger.debug("runConnected", { ...getSocketMetadata() });
// If there's already a run ID set, we should "disconnect" it from this socket
if (socket.data.runFriendlyId) {
if (socket.data.runFriendlyId && socket.data.runFriendlyId !== friendlyId) {
logger.debug("runConnected: disconnecting existing run", {
...getSocketMetadata(),
newRunId: friendlyId,
@@ -1,6 +1,6 @@
import {
clientWebsocketMessages,
HeartbeatService,
IntervalService,
serverWebsocketMessages,
} from "@trigger.dev/core/v3";
import { ZodMessageHandler, ZodMessageSender } from "@trigger.dev/core/v3/zodMessageHandler";
@@ -19,7 +19,7 @@ export class AuthenticatedSocketConnection {
private _sender: ZodMessageSender<typeof serverWebsocketMessages>;
private _consumer: DevQueueConsumer;
private _messageHandler: ZodMessageHandler<typeof clientWebsocketMessages>;
private _pingService: HeartbeatService;
private _pingService: IntervalService;
constructor(
public ws: WebSocket,
@@ -75,8 +75,8 @@ export class AuthenticatedSocketConnection {
// });
});
this._pingService = new HeartbeatService({
heartbeat: async () => {
this._pingService = new IntervalService({
onInterval: async () => {
if (ws.readyState !== WebSocket.OPEN) {
logger.debug("[AuthenticatedSocketConnection] Websocket not open, skipping ping");
return;
@@ -193,7 +193,7 @@ export async function getWorkerDeploymentFromWorker(
prisma: PrismaClientOrTransaction,
workerId: string
): Promise<WorkerDeploymentWithWorkerTasks | null> {
const worker = await prisma.backgroundWorker.findUnique({
const worker = await prisma.backgroundWorker.findFirst({
where: {
id: workerId,
},
@@ -264,12 +264,10 @@ export async function getManagedWorkerFromCurrentlyPromotedDeployment(
prisma: PrismaClientOrTransaction,
environmentId: string
): Promise<WorkerDeploymentWithWorkerTasks | null> {
const promotion = await prisma.workerDeploymentPromotion.findUnique({
const promotion = await prisma.workerDeploymentPromotion.findFirst({
where: {
environmentId_label: {
environmentId,
label: CURRENT_DEPLOYMENT_LABEL,
},
environmentId,
label: CURRENT_DEPLOYMENT_LABEL,
},
include: {
deployment: {
@@ -34,7 +34,7 @@ export class BatchSystem {
*/
async #tryCompleteBatch({ batchId }: { batchId: string }) {
return startSpan(this.$.tracer, "#tryCompleteBatch", async (span) => {
const batch = await this.$.prisma.batchTaskRun.findUnique({
const batch = await this.$.prisma.batchTaskRun.findFirst({
select: {
status: true,
runtimeEnvironmentId: true,
@@ -139,12 +139,10 @@ export class RunAttemptSystem {
throw new ServiceValidationError("Task run is not locked", 400);
}
const queue = await prisma.taskQueue.findUnique({
const queue = await prisma.taskQueue.findFirst({
where: {
runtimeEnvironmentId_name: {
runtimeEnvironmentId: environment.id,
name: taskRun.queue,
},
runtimeEnvironmentId: environment.id,
name: taskRun.queue,
},
});
@@ -1199,7 +1197,7 @@ export class RunAttemptSystem {
async #getAuthenticatedEnvironmentFromRun(runId: string, tx?: PrismaClientOrTransaction) {
const prisma = tx ?? this.$.prisma;
const taskRun = await prisma.taskRun.findUnique({
const taskRun = await prisma.taskRun.findFirst({
where: {
id: runId,
},
@@ -33,7 +33,7 @@ export class TtlSystem {
}
//only expire "PENDING" runs
const run = await prisma.taskRun.findUnique({ where: { id: runId } });
const run = await prisma.taskRun.findFirst({ where: { id: runId } });
if (!run) {
this.$.logger.debug("Could not find enqueued run to expire", {
@@ -159,12 +159,10 @@ export class WaitpointSystem {
const prisma = tx ?? this.$.prisma;
const existingWaitpoint = idempotencyKey
? await prisma.waitpoint.findUnique({
? await prisma.waitpoint.findFirst({
where: {
environmentId_idempotencyKey: {
environmentId,
idempotencyKey,
},
environmentId,
idempotencyKey,
},
})
: undefined;
@@ -241,12 +239,10 @@ export class WaitpointSystem {
tags?: string[];
}): Promise<{ waitpoint: Waitpoint; isCached: boolean }> {
const existingWaitpoint = idempotencyKey
? await this.$.prisma.waitpoint.findUnique({
? await this.$.prisma.waitpoint.findFirst({
where: {
environmentId_idempotencyKey: {
environmentId,
idempotencyKey,
},
environmentId,
idempotencyKey,
},
})
: undefined;
+10 -12
View File
@@ -8,6 +8,7 @@ import { TaskRunProcess } from "../src/executions/taskRunProcess.js";
import { createTestHttpServer } from "@epic-web/test-server/http";
import { TestCase, TestCaseRun } from "./fixtures.js";
import { access } from "node:fs/promises";
import { MachinePreset } from "@trigger.dev/core/v3";
export type PackageManager = "npm" | "pnpm" | "yarn";
@@ -295,6 +296,13 @@ export async function executeTestCaseRun({
},
});
const machine = {
name: "small-1x",
cpu: 1,
memory: 256,
centsPerMs: 0.0000001,
} satisfies MachinePreset;
try {
const taskRunProcess = new TaskRunProcess({
workerManifest: workerManifest!,
@@ -314,12 +322,7 @@ export async function executeTestCaseRun({
version: "1.0.0",
contentHash,
},
machine: {
name: "small-1x",
cpu: 1,
memory: 256,
centsPerMs: 0.0000001,
},
machineResources: machine,
}).initialize();
const result = await taskRunProcess.execute({
@@ -372,12 +375,7 @@ export async function executeTestCaseRun({
ref: "main",
name: "test",
},
machine: {
name: "small-1x",
cpu: 1,
memory: 256,
centsPerMs: 0.0000001,
},
machine,
},
},
messageId: "run_1234",
+22 -12
View File
@@ -49,13 +49,13 @@ export async function startWorkerRuntime(options: WorkerRuntimeOptions): Promise
* - Receiving snapshot update pings (via socket)
*/
class DevSupervisor implements WorkerRuntime {
private config: DevConfigResponseBody;
private config?: DevConfigResponseBody;
private disconnectPresence: (() => void) | undefined;
private lastManifest?: BuildManifest;
private latestWorkerId?: string;
/** Receive notifications when runs change state */
private socket: Socket<WorkerServerToClientEvents, WorkerClientToServerEvents>;
private socket?: Socket<WorkerServerToClientEvents, WorkerClientToServerEvents>;
private socketIsReconnecting = false;
/** Workers are versions of the code */
@@ -93,7 +93,7 @@ class DevSupervisor implements WorkerRuntime {
this.runLimiter = pLimit(maxConcurrentRuns);
this.#createSocket();
this.socket = this.#createSocket();
//start an SSE connection for presence
this.disconnectPresence = await this.#startPresenceConnection();
@@ -105,7 +105,7 @@ class DevSupervisor implements WorkerRuntime {
async shutdown(): Promise<void> {
this.disconnectPresence?.();
try {
this.socket.close();
this.socket?.close();
} catch (error) {
logger.debug("[DevSupervisor] shutdown, socket failed to close", { error });
}
@@ -187,6 +187,10 @@ class DevSupervisor implements WorkerRuntime {
* For the latest version we will pull from the main queue, so we don't specify that.
*/
async #dequeueRuns() {
if (!this.config) {
throw new Error("No config, can't dequeue runs");
}
if (!this.latestWorkerId) {
//try again later
logger.debug(`[DevSupervisor] dequeueRuns. No latest worker ID, trying again later`);
@@ -409,13 +413,14 @@ class DevSupervisor implements WorkerRuntime {
const wsUrl = new URL(this.options.client.apiURL);
wsUrl.pathname = "/dev-worker";
this.socket = io(wsUrl.href, {
const socket = io(wsUrl.href, {
transports: ["websocket"],
extraHeaders: {
Authorization: `Bearer ${this.options.client.accessToken}`,
},
});
this.socket.on("run:notify", async ({ version, run }) => {
socket.on("run:notify", async ({ version, run }) => {
logger.debug("[DevSupervisor] Received run notification", { version, run });
this.options.client.dev.sendDebugLog(run.friendlyId, {
@@ -434,10 +439,11 @@ class DevSupervisor implements WorkerRuntime {
await controller.getLatestSnapshot();
});
this.socket.on("connect", () => {
socket.on("connect", () => {
logger.debug("[DevSupervisor] Connected to supervisor");
if (this.socket.recovered || this.socketIsReconnecting) {
if (socket.recovered || this.socketIsReconnecting) {
logger.debug("[DevSupervisor] Socket recovered");
eventBus.emit("socketConnectionReconnected", `Connection was recovered`);
}
@@ -448,19 +454,21 @@ class DevSupervisor implements WorkerRuntime {
controller.resubscribeToRunNotifications();
}
});
this.socket.on("connect_error", (error) => {
socket.on("connect_error", (error) => {
logger.debug("[DevSupervisor] Connection error", { error });
});
this.socket.on("disconnect", (reason, description) => {
socket.on("disconnect", (reason, description) => {
logger.debug("[DevSupervisor] socket was disconnected", {
reason,
description,
active: this.socket.active,
active: socket.active,
});
if (reason === "io server disconnect") {
// the disconnection was initiated by the server, you need to manually reconnect
this.socket.connect();
socket.connect();
} else {
this.socketIsReconnecting = true;
eventBus.emit("socketConnectionDisconnected", reason);
@@ -472,6 +480,8 @@ class DevSupervisor implements WorkerRuntime {
connections: Array.from(this.socketConnections),
});
}, 5000);
return socket;
}
#subscribeToRunNotifications() {
@@ -1,7 +1,7 @@
import {
CompleteRunAttemptResult,
DequeuedMessage,
HeartbeatService,
IntervalService,
LogLevel,
RunExecutionData,
TaskRunExecution,
@@ -44,9 +44,9 @@ export class DevRunController {
private taskRunProcess?: TaskRunProcess;
private readonly worker: BackgroundWorker;
private readonly httpClient: CliApiClient;
private readonly runHeartbeat: HeartbeatService;
private readonly runHeartbeat: IntervalService;
private readonly heartbeatIntervalSeconds: number;
private readonly snapshotPoller: HeartbeatService;
private readonly snapshotPoller: IntervalService;
private readonly snapshotPollIntervalSeconds: number;
private state:
@@ -78,8 +78,8 @@ export class DevRunController {
this.httpClient = opts.httpClient;
this.snapshotPoller = new HeartbeatService({
heartbeat: async () => {
this.snapshotPoller = new IntervalService({
onInterval: async () => {
if (!this.runFriendlyId) {
logger.debug("[DevRunController] Skipping snapshot poll, no run ID");
return;
@@ -121,8 +121,8 @@ export class DevRunController {
},
});
this.runHeartbeat = new HeartbeatService({
heartbeat: async () => {
this.runHeartbeat = new IntervalService({
onInterval: async () => {
if (!this.runFriendlyId || !this.snapshotFriendlyId) {
logger.debug("[DevRunController] Skipping heartbeat, no run ID or snapshot ID");
return;
@@ -619,7 +619,7 @@ export class DevRunController {
version: this.opts.worker.serverWorker?.version,
engine: "V2",
},
machine: execution.machine,
machineResources: execution.machine,
}).initialize();
logger.debug("executing task run process", {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,552 @@
import { WorkerManifest } from "@trigger.dev/core/v3";
import {
WarmStartClient,
WORKLOAD_HEADERS,
type WorkloadClientToServerEvents,
WorkloadHttpClient,
type WorkloadServerToClientEvents,
} from "@trigger.dev/core/v3/workers";
import { io, type Socket } from "socket.io-client";
import { RunnerEnv } from "./env.js";
import { RunLogger, SendDebugLogOptions } from "./logger.js";
import { EnvObject } from "std-env";
import { RunExecution } from "./execution.js";
import { tryCatch } from "@trigger.dev/core/utils";
type ManagedRunControllerOptions = {
workerManifest: WorkerManifest;
env: EnvObject;
};
type SupervisorSocket = Socket<WorkloadServerToClientEvents, WorkloadClientToServerEvents>;
export class ManagedRunController {
private readonly env: RunnerEnv;
private readonly workerManifest: WorkerManifest;
private readonly httpClient: WorkloadHttpClient;
private readonly warmStartClient: WarmStartClient | undefined;
private socket: SupervisorSocket;
private readonly logger: RunLogger;
private warmStartCount = 0;
private restoreCount = 0;
private currentExecution: RunExecution | null = null;
constructor(opts: ManagedRunControllerOptions) {
const env = new RunnerEnv(opts.env);
this.env = env;
this.workerManifest = opts.workerManifest;
this.httpClient = new WorkloadHttpClient({
workerApiUrl: this.workerApiUrl,
runnerId: this.runnerId,
deploymentId: env.TRIGGER_DEPLOYMENT_ID,
deploymentVersion: env.TRIGGER_DEPLOYMENT_VERSION,
projectRef: env.TRIGGER_PROJECT_REF,
});
this.logger = new RunLogger({
httpClient: this.httpClient,
env,
});
const properties = {
...env.raw,
TRIGGER_POD_SCHEDULED_AT_MS: env.TRIGGER_POD_SCHEDULED_AT_MS.toISOString(),
TRIGGER_DEQUEUED_AT_MS: env.TRIGGER_DEQUEUED_AT_MS.toISOString(),
};
this.sendDebugLog({
runId: env.TRIGGER_RUN_ID,
message: "Creating run controller",
properties,
});
if (env.TRIGGER_WARM_START_URL) {
this.warmStartClient = new WarmStartClient({
apiUrl: new URL(env.TRIGGER_WARM_START_URL),
controllerId: env.TRIGGER_WORKLOAD_CONTROLLER_ID,
deploymentId: env.TRIGGER_DEPLOYMENT_ID,
deploymentVersion: env.TRIGGER_DEPLOYMENT_VERSION,
machineCpu: env.TRIGGER_MACHINE_CPU,
machineMemory: env.TRIGGER_MACHINE_MEMORY,
});
}
// Websocket notifications are only an optimisation so we don't need to wait for a successful connection
this.socket = this.createSupervisorSocket();
process.on("SIGTERM", async () => {
this.sendDebugLog({
runId: this.runFriendlyId,
message: "Received SIGTERM, stopping worker",
});
await this.stop();
});
}
get metrics() {
return {
warmStartCount: this.warmStartCount,
restoreCount: this.restoreCount,
};
}
get runnerId() {
return this.env.TRIGGER_RUNNER_ID;
}
get successExitCode() {
return this.env.TRIGGER_SUCCESS_EXIT_CODE;
}
get failureExitCode() {
return this.env.TRIGGER_FAILURE_EXIT_CODE;
}
get workerApiUrl() {
return this.env.TRIGGER_SUPERVISOR_API_URL;
}
get workerInstanceName() {
return this.env.TRIGGER_WORKER_INSTANCE_NAME;
}
private subscribeToRunNotifications(runFriendlyId: string, snapshotFriendlyId: string) {
this.socket.emit("run:start", {
version: "1",
run: {
friendlyId: runFriendlyId,
},
snapshot: {
friendlyId: snapshotFriendlyId,
},
});
}
private unsubscribeFromRunNotifications(runFriendlyId: string, snapshotFriendlyId: string) {
this.socket.emit("run:stop", {
version: "1",
run: {
friendlyId: runFriendlyId,
},
snapshot: {
friendlyId: snapshotFriendlyId,
},
});
}
private get runFriendlyId() {
return this.currentExecution?.runFriendlyId;
}
private get snapshotFriendlyId() {
return this.currentExecution?.currentSnapshotFriendlyId;
}
private lockedRunExecution: Promise<void> | null = null;
private async startRunExecution({
runFriendlyId,
snapshotFriendlyId,
dequeuedAt,
podScheduledAt,
isWarmStart,
previousRunId,
}: {
runFriendlyId: string;
snapshotFriendlyId: string;
dequeuedAt?: Date;
podScheduledAt?: Date;
isWarmStart?: boolean;
previousRunId?: string;
}) {
this.sendDebugLog({
runId: runFriendlyId,
message: "startAndExecuteRunAttempt()",
properties: { previousRunId },
});
if (this.lockedRunExecution) {
this.sendDebugLog({
runId: runFriendlyId,
message: "startAndExecuteRunAttempt: execution already locked",
});
return;
}
const execution = async () => {
if (!this.currentExecution || !this.currentExecution.isPreparedForNextRun) {
this.currentExecution = new RunExecution({
workerManifest: this.workerManifest,
env: this.env,
httpClient: this.httpClient,
logger: this.logger,
});
}
// Subscribe to run notifications
this.subscribeToRunNotifications(runFriendlyId, snapshotFriendlyId);
// We're prepared for the next run so we can start executing
await this.currentExecution.execute({
runFriendlyId,
snapshotFriendlyId,
dequeuedAt,
podScheduledAt,
isWarmStart,
});
};
this.lockedRunExecution = execution();
const [error] = await tryCatch(this.lockedRunExecution);
if (error) {
this.sendDebugLog({
runId: runFriendlyId,
message: "Error during execution",
properties: { error: error.message },
});
}
const metrics = this.currentExecution?.metrics;
if (metrics?.restoreCount) {
this.restoreCount += metrics.restoreCount;
}
this.lockedRunExecution = null;
this.unsubscribeFromRunNotifications(runFriendlyId, snapshotFriendlyId);
this.waitForNextRun();
}
private waitForNextRunLock = false;
/**
* This will eagerly create a new run execution. It will never throw, but may exit
* the process on any errors or when no runs are available after the configured duration.
*/
private async waitForNextRun() {
this.sendDebugLog({
runId: this.runFriendlyId,
message: "waitForNextRun()",
});
if (this.waitForNextRunLock) {
this.sendDebugLog({
runId: this.runFriendlyId,
message: "waitForNextRun: already in progress, skipping",
});
return;
}
if (this.lockedRunExecution) {
this.sendDebugLog({
runId: this.runFriendlyId,
message: "waitForNextRun: execution locked, skipping",
});
return;
}
this.waitForNextRunLock = true;
try {
if (!this.warmStartClient) {
this.sendDebugLog({
runId: this.runFriendlyId,
message: "waitForNextRun: warm starts disabled, shutting down",
});
this.exitProcess(this.successExitCode);
}
const previousRunId = this.runFriendlyId;
if (this.currentExecution?.taskRunEnv) {
this.sendDebugLog({
runId: this.runFriendlyId,
message: "waitForNextRun: eagerly recreating task run process",
});
const previousTaskRunEnv = this.currentExecution.taskRunEnv;
this.currentExecution = new RunExecution({
workerManifest: this.workerManifest,
env: this.env,
httpClient: this.httpClient,
logger: this.logger,
}).prepareForExecution({
taskRunEnv: previousTaskRunEnv,
});
}
// Check the service is up and get additional warm start config
const connect = await this.warmStartClient.connect();
if (!connect.success) {
this.sendDebugLog({
runId: this.runFriendlyId,
message: "waitForNextRun: failed to connect to warm start service",
properties: {
warmStartUrl: this.env.TRIGGER_WARM_START_URL,
error: connect.error,
},
});
this.exitProcess(this.successExitCode);
}
const connectionTimeoutMs =
connect.data.connectionTimeoutMs ?? this.env.TRIGGER_WARM_START_CONNECTION_TIMEOUT_MS;
const keepaliveMs = connect.data.keepaliveMs ?? this.env.TRIGGER_WARM_START_KEEPALIVE_MS;
const warmStartConfig = {
connectionTimeoutMs,
keepaliveMs,
};
this.sendDebugLog({
runId: this.runFriendlyId,
message: "waitForNextRun: connected to warm start service",
properties: warmStartConfig,
});
if (!connectionTimeoutMs || !keepaliveMs) {
this.sendDebugLog({
runId: this.runFriendlyId,
message: "waitForNextRun: warm starts disabled after connect",
properties: warmStartConfig,
});
this.exitProcess(this.successExitCode);
}
const nextRun = await this.warmStartClient.warmStart({
workerInstanceName: this.workerInstanceName,
connectionTimeoutMs,
keepaliveMs,
});
if (!nextRun) {
this.sendDebugLog({
runId: this.runFriendlyId,
message: "waitForNextRun: warm start failed, shutting down",
properties: warmStartConfig,
});
this.exitProcess(this.successExitCode);
}
this.warmStartCount++;
this.sendDebugLog({
runId: this.runFriendlyId,
message: "waitForNextRun: got next run",
properties: {
...warmStartConfig,
nextRunId: nextRun.run.friendlyId,
},
});
this.startRunExecution({
runFriendlyId: nextRun.run.friendlyId,
snapshotFriendlyId: nextRun.snapshot.friendlyId,
dequeuedAt: nextRun.dequeuedAt,
isWarmStart: true,
previousRunId,
}).finally(() => {});
} catch (error) {
this.sendDebugLog({
runId: this.runFriendlyId,
message: "waitForNextRun: unexpected error",
properties: { error: error instanceof Error ? error.message : String(error) },
});
this.exitProcess(this.failureExitCode);
} finally {
this.waitForNextRunLock = false;
}
}
private exitProcess(code?: number): never {
this.sendDebugLog({
runId: this.runFriendlyId,
message: "Exiting process",
properties: { code },
});
this.currentExecution?.exit();
process.exit(code);
}
createSupervisorSocket(): SupervisorSocket {
const wsUrl = new URL("/workload", this.workerApiUrl);
const socket = io(wsUrl.href, {
transports: ["websocket"],
extraHeaders: {
[WORKLOAD_HEADERS.DEPLOYMENT_ID]: this.env.TRIGGER_DEPLOYMENT_ID,
[WORKLOAD_HEADERS.RUNNER_ID]: this.env.TRIGGER_RUNNER_ID,
},
}) satisfies SupervisorSocket;
socket.on("run:notify", async ({ version, run }) => {
this.sendDebugLog({
runId: run.friendlyId,
message: "run:notify received by runner",
properties: { version, runId: run.friendlyId },
});
if (!this.runFriendlyId) {
this.sendDebugLog({
runId: run.friendlyId,
message: "run:notify: ignoring notification, no local run ID",
properties: {
currentRunId: this.runFriendlyId,
currentSnapshotId: this.snapshotFriendlyId,
},
});
return;
}
if (run.friendlyId !== this.runFriendlyId) {
this.sendDebugLog({
runId: run.friendlyId,
message: "run:notify: ignoring notification for different run",
properties: {
currentRunId: this.runFriendlyId,
currentSnapshotId: this.snapshotFriendlyId,
notificationRunId: run.friendlyId,
},
});
return;
}
const latestSnapshot = await this.httpClient.getRunExecutionData(this.runFriendlyId);
if (!latestSnapshot.success) {
this.sendDebugLog({
runId: this.runFriendlyId,
message: "run:notify: failed to get latest snapshot data",
properties: {
currentRunId: this.runFriendlyId,
currentSnapshotId: this.snapshotFriendlyId,
error: latestSnapshot.error,
},
});
return;
}
const runExecutionData = latestSnapshot.data.execution;
if (!this.currentExecution) {
this.sendDebugLog({
runId: runExecutionData.run.friendlyId,
message: "handleSnapshotChange: no current execution",
});
return;
}
const [error] = await tryCatch(this.currentExecution.handleSnapshotChange(runExecutionData));
if (error) {
this.sendDebugLog({
runId: runExecutionData.run.friendlyId,
message: "handleSnapshotChange: unexpected error",
properties: { error: error.message },
});
}
});
socket.on("connect", () => {
this.sendDebugLog({
runId: this.runFriendlyId,
message: "Socket connected to supervisor",
});
// This should handle the case where we reconnect after being restored
if (
this.runFriendlyId &&
this.snapshotFriendlyId &&
this.runFriendlyId !== this.env.TRIGGER_RUN_ID
) {
this.sendDebugLog({
runId: this.runFriendlyId,
message: "Subscribing to notifications for in-progress run",
});
this.subscribeToRunNotifications(this.runFriendlyId, this.snapshotFriendlyId);
}
});
socket.on("connect_error", (error) => {
this.sendDebugLog({
runId: this.runFriendlyId,
message: "Socket connection error",
properties: { error: error instanceof Error ? error.message : String(error) },
});
});
socket.on("disconnect", (reason, description) => {
this.sendDebugLog({
runId: this.runFriendlyId,
message: "Socket disconnected from supervisor",
properties: { reason, description: description?.toString() },
});
});
return socket;
}
async cancelAttempt(runId: string) {
this.sendDebugLog({
runId,
message: "cancelling attempt",
properties: { runId },
});
await this.currentExecution?.cancel();
}
start() {
this.sendDebugLog({
runId: this.runFriendlyId,
message: "Starting up",
});
// If we have run and snapshot IDs, we can start an attempt immediately
if (this.env.TRIGGER_RUN_ID && this.env.TRIGGER_SNAPSHOT_ID) {
this.startRunExecution({
runFriendlyId: this.env.TRIGGER_RUN_ID,
snapshotFriendlyId: this.env.TRIGGER_SNAPSHOT_ID,
dequeuedAt: this.env.TRIGGER_DEQUEUED_AT_MS,
podScheduledAt: this.env.TRIGGER_POD_SCHEDULED_AT_MS,
}).finally(() => {});
return;
}
// ..otherwise we need to wait for a run
this.waitForNextRun();
return;
}
async stop() {
this.sendDebugLog({
runId: this.runFriendlyId,
message: "Shutting down",
});
await this.currentExecution?.cancel();
this.socket.close();
}
sendDebugLog(opts: SendDebugLogOptions) {
this.logger.sendDebugLog({
...opts,
message: `[controller] ${opts.message}`,
properties: {
...opts.properties,
runnerWarmStartCount: this.warmStartCount,
runnerRestoreCount: this.restoreCount,
},
});
}
}
@@ -0,0 +1,223 @@
import { randomUUID } from "node:crypto";
import { Metadata } from "./overrides.js";
import { z } from "zod";
import { EnvObject } from "std-env";
const DateEnv = z
.string()
.transform((val) => new Date(parseInt(val, 10)))
.pipe(z.date());
// All IDs are friendly IDs
const Env = z.object({
// Set at build time
TRIGGER_CONTENT_HASH: z.string(),
TRIGGER_DEPLOYMENT_ID: z.string(),
TRIGGER_DEPLOYMENT_VERSION: z.string(),
TRIGGER_PROJECT_ID: z.string(),
TRIGGER_PROJECT_REF: z.string(),
NODE_ENV: z.string().default("production"),
NODE_EXTRA_CA_CERTS: z.string().optional(),
// Set at runtime
TRIGGER_WORKLOAD_CONTROLLER_ID: z.string().default(`controller_${randomUUID()}`),
TRIGGER_ENV_ID: z.string(),
TRIGGER_RUN_ID: z.string().optional(), // This is only useful for cold starts
TRIGGER_SNAPSHOT_ID: z.string().optional(), // This is only useful for cold starts
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url(),
TRIGGER_WARM_START_URL: z.string().optional(),
TRIGGER_WARM_START_CONNECTION_TIMEOUT_MS: z.coerce.number().default(30_000),
TRIGGER_WARM_START_KEEPALIVE_MS: z.coerce.number().default(300_000),
TRIGGER_MACHINE_CPU: z.string().default("0"),
TRIGGER_MACHINE_MEMORY: z.string().default("0"),
TRIGGER_RUNNER_ID: z.string(),
TRIGGER_METADATA_URL: z.string().optional(),
TRIGGER_PRE_SUSPEND_WAIT_MS: z.coerce.number().default(200),
// Timeline metrics
TRIGGER_POD_SCHEDULED_AT_MS: DateEnv,
TRIGGER_DEQUEUED_AT_MS: DateEnv,
// May be overridden
TRIGGER_SUPERVISOR_API_PROTOCOL: z.enum(["http", "https"]),
TRIGGER_SUPERVISOR_API_DOMAIN: z.string(),
TRIGGER_SUPERVISOR_API_PORT: z.coerce.number(),
TRIGGER_WORKER_INSTANCE_NAME: z.string(),
TRIGGER_HEARTBEAT_INTERVAL_SECONDS: z.coerce.number().default(30),
TRIGGER_SNAPSHOT_POLL_INTERVAL_SECONDS: z.coerce.number().default(5),
TRIGGER_SUCCESS_EXIT_CODE: z.coerce.number().default(0),
TRIGGER_FAILURE_EXIT_CODE: z.coerce.number().default(1),
});
type Env = z.infer<typeof Env>;
export class RunnerEnv {
private env: Env;
public readonly initial: Env;
constructor(env: EnvObject) {
this.env = Env.parse(env);
this.initial = { ...this.env };
}
get raw() {
return this.env;
}
// Base environment variables
get NODE_ENV() {
return this.env.NODE_ENV;
}
get NODE_EXTRA_CA_CERTS() {
return this.env.NODE_EXTRA_CA_CERTS;
}
get OTEL_EXPORTER_OTLP_ENDPOINT() {
return this.env.OTEL_EXPORTER_OTLP_ENDPOINT;
}
get TRIGGER_CONTENT_HASH() {
return this.env.TRIGGER_CONTENT_HASH;
}
get TRIGGER_DEPLOYMENT_ID() {
return this.env.TRIGGER_DEPLOYMENT_ID;
}
get TRIGGER_DEPLOYMENT_VERSION() {
return this.env.TRIGGER_DEPLOYMENT_VERSION;
}
get TRIGGER_PROJECT_ID() {
return this.env.TRIGGER_PROJECT_ID;
}
get TRIGGER_PROJECT_REF() {
return this.env.TRIGGER_PROJECT_REF;
}
get TRIGGER_WORKLOAD_CONTROLLER_ID() {
return this.env.TRIGGER_WORKLOAD_CONTROLLER_ID;
}
get TRIGGER_ENV_ID() {
return this.env.TRIGGER_ENV_ID;
}
get TRIGGER_RUN_ID() {
return this.env.TRIGGER_RUN_ID;
}
get TRIGGER_SNAPSHOT_ID() {
return this.env.TRIGGER_SNAPSHOT_ID;
}
get TRIGGER_WARM_START_URL() {
return this.env.TRIGGER_WARM_START_URL;
}
get TRIGGER_WARM_START_CONNECTION_TIMEOUT_MS() {
return this.env.TRIGGER_WARM_START_CONNECTION_TIMEOUT_MS;
}
get TRIGGER_WARM_START_KEEPALIVE_MS() {
return this.env.TRIGGER_WARM_START_KEEPALIVE_MS;
}
get TRIGGER_MACHINE_CPU() {
return this.env.TRIGGER_MACHINE_CPU;
}
get TRIGGER_MACHINE_MEMORY() {
return this.env.TRIGGER_MACHINE_MEMORY;
}
get TRIGGER_METADATA_URL() {
return this.env.TRIGGER_METADATA_URL;
}
get TRIGGER_PRE_SUSPEND_WAIT_MS() {
return this.env.TRIGGER_PRE_SUSPEND_WAIT_MS;
}
get TRIGGER_POD_SCHEDULED_AT_MS() {
return this.env.TRIGGER_POD_SCHEDULED_AT_MS;
}
get TRIGGER_DEQUEUED_AT_MS() {
return this.env.TRIGGER_DEQUEUED_AT_MS;
}
// Overridable values
get TRIGGER_SUCCESS_EXIT_CODE() {
return this.env.TRIGGER_SUCCESS_EXIT_CODE;
}
get TRIGGER_FAILURE_EXIT_CODE() {
return this.env.TRIGGER_FAILURE_EXIT_CODE;
}
get TRIGGER_HEARTBEAT_INTERVAL_SECONDS() {
return this.env.TRIGGER_HEARTBEAT_INTERVAL_SECONDS;
}
get TRIGGER_SNAPSHOT_POLL_INTERVAL_SECONDS() {
return this.env.TRIGGER_SNAPSHOT_POLL_INTERVAL_SECONDS;
}
get TRIGGER_WORKER_INSTANCE_NAME() {
return this.env.TRIGGER_WORKER_INSTANCE_NAME;
}
get TRIGGER_RUNNER_ID() {
return this.env.TRIGGER_RUNNER_ID;
}
get TRIGGER_SUPERVISOR_API_PROTOCOL() {
return this.env.TRIGGER_SUPERVISOR_API_PROTOCOL;
}
get TRIGGER_SUPERVISOR_API_DOMAIN() {
return this.env.TRIGGER_SUPERVISOR_API_DOMAIN;
}
get TRIGGER_SUPERVISOR_API_PORT() {
return this.env.TRIGGER_SUPERVISOR_API_PORT;
}
get TRIGGER_SUPERVISOR_API_URL() {
return `${this.TRIGGER_SUPERVISOR_API_PROTOCOL}://${this.TRIGGER_SUPERVISOR_API_DOMAIN}:${this.TRIGGER_SUPERVISOR_API_PORT}`;
}
/** Overrides existing env vars with new values */
override(overrides: Metadata) {
if (overrides.TRIGGER_SUCCESS_EXIT_CODE) {
this.env.TRIGGER_SUCCESS_EXIT_CODE = overrides.TRIGGER_SUCCESS_EXIT_CODE;
}
if (overrides.TRIGGER_FAILURE_EXIT_CODE) {
this.env.TRIGGER_FAILURE_EXIT_CODE = overrides.TRIGGER_FAILURE_EXIT_CODE;
}
if (overrides.TRIGGER_HEARTBEAT_INTERVAL_SECONDS) {
this.env.TRIGGER_HEARTBEAT_INTERVAL_SECONDS = overrides.TRIGGER_HEARTBEAT_INTERVAL_SECONDS;
}
if (overrides.TRIGGER_SNAPSHOT_POLL_INTERVAL_SECONDS) {
this.env.TRIGGER_SNAPSHOT_POLL_INTERVAL_SECONDS =
overrides.TRIGGER_SNAPSHOT_POLL_INTERVAL_SECONDS;
}
if (overrides.TRIGGER_WORKER_INSTANCE_NAME) {
this.env.TRIGGER_WORKER_INSTANCE_NAME = overrides.TRIGGER_WORKER_INSTANCE_NAME;
}
if (overrides.TRIGGER_SUPERVISOR_API_PROTOCOL) {
this.env.TRIGGER_SUPERVISOR_API_PROTOCOL = overrides.TRIGGER_SUPERVISOR_API_PROTOCOL as
| "http"
| "https";
}
if (overrides.TRIGGER_SUPERVISOR_API_DOMAIN) {
this.env.TRIGGER_SUPERVISOR_API_DOMAIN = overrides.TRIGGER_SUPERVISOR_API_DOMAIN;
}
if (overrides.TRIGGER_SUPERVISOR_API_PORT) {
this.env.TRIGGER_SUPERVISOR_API_PORT = overrides.TRIGGER_SUPERVISOR_API_PORT;
}
if (overrides.TRIGGER_RUNNER_ID) {
this.env.TRIGGER_RUNNER_ID = overrides.TRIGGER_RUNNER_ID;
}
}
// Helper method to get process env for task runs
gatherProcessEnv(): Record<string, string> {
const $env = {
NODE_ENV: this.NODE_ENV,
NODE_EXTRA_CA_CERTS: this.NODE_EXTRA_CA_CERTS,
OTEL_EXPORTER_OTLP_ENDPOINT: this.OTEL_EXPORTER_OTLP_ENDPOINT,
};
// Filter out undefined values
return Object.fromEntries(
Object.entries($env).filter(([key, value]) => value !== undefined)
) as Record<string, string>;
}
}
@@ -0,0 +1,923 @@
import {
type CompleteRunAttemptResult,
type RunExecutionData,
SuspendedProcessError,
type TaskRunExecutionMetrics,
type TaskRunExecutionResult,
TaskRunExecutionRetry,
type TaskRunFailedExecutionResult,
WorkerManifest,
} from "@trigger.dev/core/v3";
import { type WorkloadRunAttemptStartResponseBody } from "@trigger.dev/core/v3/workers";
import { TaskRunProcess } from "../../executions/taskRunProcess.js";
import { RunLogger, SendDebugLogOptions } from "./logger.js";
import { RunnerEnv } from "./env.js";
import { WorkloadHttpClient } from "@trigger.dev/core/v3/workers";
import { setTimeout as sleep } from "timers/promises";
import { RunExecutionHeartbeat } from "./heartbeat.js";
import { RunExecutionSnapshotPoller } from "./poller.js";
import { assertExhaustive, tryCatch } from "@trigger.dev/core/utils";
import { MetadataClient } from "./overrides.js";
import { randomBytes } from "node:crypto";
class ExecutionAbortError extends Error {
constructor(message: string) {
super(message);
this.name = "ExecutionAbortError";
}
}
type RunExecutionOptions = {
workerManifest: WorkerManifest;
env: RunnerEnv;
httpClient: WorkloadHttpClient;
logger: RunLogger;
};
type RunExecutionPrepareOptions = {
taskRunEnv: Record<string, string>;
};
type RunExecutionRunOptions = {
runFriendlyId: string;
snapshotFriendlyId: string;
dequeuedAt?: Date;
podScheduledAt?: Date;
isWarmStart?: boolean;
};
export class RunExecution {
private id: string;
private executionAbortController: AbortController;
private _runFriendlyId?: string;
private currentSnapshotId?: string;
private currentTaskRunEnv?: Record<string, string>;
private dequeuedAt?: Date;
private podScheduledAt?: Date;
private readonly workerManifest: WorkerManifest;
private readonly env: RunnerEnv;
private readonly httpClient: WorkloadHttpClient;
private readonly logger: RunLogger;
private restoreCount: number;
private taskRunProcess?: TaskRunProcess;
private runHeartbeat?: RunExecutionHeartbeat;
private snapshotPoller?: RunExecutionSnapshotPoller;
constructor(opts: RunExecutionOptions) {
this.id = randomBytes(4).toString("hex");
this.workerManifest = opts.workerManifest;
this.env = opts.env;
this.httpClient = opts.httpClient;
this.logger = opts.logger;
this.restoreCount = 0;
this.executionAbortController = new AbortController();
}
/**
* Prepares the execution with task run environment variables.
* This should be called before executing, typically after a successful run to prepare for the next one.
*/
public prepareForExecution(opts: RunExecutionPrepareOptions): this {
if (this.taskRunProcess) {
throw new Error("prepareForExecution called after process was already created");
}
if (this.isPreparedForNextRun) {
throw new Error("prepareForExecution called after execution was already prepared");
}
this.taskRunProcess = this.createTaskRunProcess({
envVars: opts.taskRunEnv,
isWarmStart: true,
});
return this;
}
private createTaskRunProcess({
envVars,
isWarmStart,
}: {
envVars: Record<string, string>;
isWarmStart?: boolean;
}) {
return new TaskRunProcess({
workerManifest: this.workerManifest,
env: {
...envVars,
...this.env.gatherProcessEnv(),
},
serverWorker: {
id: "managed",
contentHash: this.env.TRIGGER_CONTENT_HASH,
version: this.env.TRIGGER_DEPLOYMENT_VERSION,
engine: "V2",
},
machineResources: {
cpu: Number(this.env.TRIGGER_MACHINE_CPU),
memory: Number(this.env.TRIGGER_MACHINE_MEMORY),
},
isWarmStart,
}).initialize();
}
/**
* Returns true if the execution has been prepared with task run env.
*/
get isPreparedForNextRun(): boolean {
return !!this.taskRunProcess?.isPreparedForNextRun;
}
/**
* Called by the RunController when it receives a websocket notification
* or when the snapshot poller detects a change
*/
public async handleSnapshotChange(runData: RunExecutionData): Promise<void> {
const { run, snapshot, completedWaitpoints } = runData;
const snapshotMetadata = {
incomingRunId: run.friendlyId,
incomingSnapshotId: snapshot.friendlyId,
completedWaitpoints: completedWaitpoints.length,
};
// Ensure we have run details
if (!this.runFriendlyId || !this.currentSnapshotId) {
this.sendDebugLog(
"handleSnapshotChange: missing run or snapshot ID",
snapshotMetadata,
run.friendlyId
);
return;
}
// Ensure the run ID matches
if (run.friendlyId !== this.runFriendlyId) {
// Send debug log to both runs
this.sendDebugLog("handleSnapshotChange: mismatched run IDs", snapshotMetadata);
this.sendDebugLog(
"handleSnapshotChange: mismatched run IDs",
snapshotMetadata,
run.friendlyId
);
return;
}
this.sendDebugLog(`enqueued snapshot change: ${snapshot.executionStatus}`, snapshotMetadata);
this.snapshotChangeQueue.push(runData);
await this.processSnapshotChangeQueue();
}
private snapshotChangeQueue: RunExecutionData[] = [];
private snapshotChangeQueueLock = false;
private async processSnapshotChangeQueue() {
if (this.snapshotChangeQueueLock) {
return;
}
this.snapshotChangeQueueLock = true;
while (this.snapshotChangeQueue.length > 0) {
const runData = this.snapshotChangeQueue.shift();
if (!runData) {
continue;
}
const [error] = await tryCatch(this.processSnapshotChange(runData));
if (error) {
this.sendDebugLog("Failed to process snapshot change", { error: error.message });
}
}
this.snapshotChangeQueueLock = false;
}
private async processSnapshotChange(runData: RunExecutionData): Promise<void> {
const { run, snapshot, completedWaitpoints } = runData;
const snapshotMetadata = {
incomingSnapshotId: snapshot.friendlyId,
completedWaitpoints: completedWaitpoints.length,
};
// Check if the incoming snapshot is newer than the current one
if (!this.currentSnapshotId || snapshot.friendlyId < this.currentSnapshotId) {
this.sendDebugLog(
"handleSnapshotChange: received older snapshot, skipping",
snapshotMetadata
);
return;
}
if (snapshot.friendlyId === this.currentSnapshotId) {
this.sendDebugLog("handleSnapshotChange: snapshot not changed", snapshotMetadata);
return;
}
this.sendDebugLog(`snapshot change: ${snapshot.executionStatus}`, snapshotMetadata);
// Reset the snapshot poll interval so we don't do unnecessary work
this.snapshotPoller?.resetCurrentInterval();
// Update internal state
this.currentSnapshotId = snapshot.friendlyId;
// Update services
this.runHeartbeat?.updateSnapshotId(snapshot.friendlyId);
this.snapshotPoller?.updateSnapshotId(snapshot.friendlyId);
switch (snapshot.executionStatus) {
case "PENDING_CANCEL": {
const [error] = await tryCatch(this.cancel());
if (error) {
this.sendDebugLog("snapshot change: failed to cancel attempt", {
...snapshotMetadata,
error: error.message,
});
}
this.abortExecution();
return;
}
case "FINISHED": {
this.sendDebugLog("Run is finished", snapshotMetadata);
// Pretend we've just suspended the run. This will kill the process without failing the run.
await this.taskRunProcess?.suspend();
return;
}
case "QUEUED_EXECUTING":
case "EXECUTING_WITH_WAITPOINTS": {
this.sendDebugLog("Run is executing with waitpoints", snapshotMetadata);
const [error] = await tryCatch(this.taskRunProcess?.cleanup(false));
if (error) {
this.sendDebugLog("Failed to cleanup task run process, carrying on", {
...snapshotMetadata,
error: error.message,
});
}
if (snapshot.friendlyId !== this.currentSnapshotId) {
this.sendDebugLog("Snapshot changed after cleanup, abort", snapshotMetadata);
this.abortExecution();
return;
}
await sleep(this.env.TRIGGER_PRE_SUSPEND_WAIT_MS);
if (snapshot.friendlyId !== this.currentSnapshotId) {
this.sendDebugLog("Snapshot changed after suspend threshold, abort", snapshotMetadata);
this.abortExecution();
return;
}
if (!this.runFriendlyId || !this.currentSnapshotId) {
this.sendDebugLog(
"handleSnapshotChange: Missing run ID or snapshot ID after suspension, abort",
snapshotMetadata
);
this.abortExecution();
return;
}
const suspendResult = await this.httpClient.suspendRun(
this.runFriendlyId,
this.currentSnapshotId
);
if (!suspendResult.success) {
this.sendDebugLog("Failed to suspend run, staying alive 🎶", {
...snapshotMetadata,
error: suspendResult.error,
});
this.sendDebugLog("checkpoint: suspend request failed", {
...snapshotMetadata,
error: suspendResult.error,
});
// This is fine, we'll wait for the next status change
return;
}
if (!suspendResult.data.ok) {
this.sendDebugLog("checkpoint: failed to suspend run", {
snapshotId: this.currentSnapshotId,
error: suspendResult.data.error,
});
// This is fine, we'll wait for the next status change
return;
}
this.sendDebugLog("Suspending, any day now 🚬", snapshotMetadata);
// Wait for next status change
return;
}
case "SUSPENDED": {
this.sendDebugLog("Run was suspended, kill the process", snapshotMetadata);
// This will kill the process and fail the execution with a SuspendedProcessError
await this.taskRunProcess?.suspend();
return;
}
case "PENDING_EXECUTING": {
this.sendDebugLog("Run is pending execution", snapshotMetadata);
if (completedWaitpoints.length === 0) {
this.sendDebugLog("No waitpoints to complete, nothing to do", snapshotMetadata);
return;
}
const [error] = await tryCatch(this.restore());
if (error) {
this.sendDebugLog("Failed to restore execution", {
...snapshotMetadata,
error: error.message,
});
this.abortExecution();
return;
}
return;
}
case "EXECUTING": {
this.sendDebugLog("Run is now executing", snapshotMetadata);
if (completedWaitpoints.length === 0) {
return;
}
this.sendDebugLog("Processing completed waitpoints", snapshotMetadata);
if (!this.taskRunProcess) {
this.sendDebugLog("No task run process, ignoring completed waitpoints", snapshotMetadata);
this.abortExecution();
return;
}
for (const waitpoint of completedWaitpoints) {
this.taskRunProcess.waitpointCompleted(waitpoint);
}
return;
}
case "RUN_CREATED":
case "QUEUED": {
this.sendDebugLog("Invalid status change", snapshotMetadata);
this.abortExecution();
return;
}
default: {
assertExhaustive(snapshot.executionStatus);
}
}
}
private async startAttempt({
isWarmStart,
}: {
isWarmStart?: boolean;
}): Promise<WorkloadRunAttemptStartResponseBody & { metrics: TaskRunExecutionMetrics }> {
if (!this.runFriendlyId || !this.currentSnapshotId) {
throw new Error("Cannot start attempt: missing run or snapshot ID");
}
this.sendDebugLog("Starting attempt");
const attemptStartedAt = Date.now();
// Check for abort before each major async operation
if (this.executionAbortController.signal.aborted) {
throw new ExecutionAbortError("Execution aborted before start");
}
const start = await this.httpClient.startRunAttempt(
this.runFriendlyId,
this.currentSnapshotId,
{ isWarmStart }
);
if (this.executionAbortController.signal.aborted) {
throw new ExecutionAbortError("Execution aborted after start");
}
if (!start.success) {
throw new Error(`Start API call failed: ${start.error}`);
}
// A snapshot was just created, so update the snapshot ID
this.currentSnapshotId = start.data.snapshot.friendlyId;
const metrics = this.measureExecutionMetrics({
attemptCreatedAt: attemptStartedAt,
dequeuedAt: this.dequeuedAt?.getTime(),
podScheduledAt: this.podScheduledAt?.getTime(),
});
this.sendDebugLog("Started attempt");
return { ...start.data, metrics };
}
/**
* Executes the run. This will return when the execution is complete and we should warm start.
* When this returns, the child process will have been cleaned up.
*/
public async execute(runOpts: RunExecutionRunOptions): Promise<void> {
// Setup initial state
this.runFriendlyId = runOpts.runFriendlyId;
this.currentSnapshotId = runOpts.snapshotFriendlyId;
this.dequeuedAt = runOpts.dequeuedAt;
this.podScheduledAt = runOpts.podScheduledAt;
// Create and start services
this.runHeartbeat = new RunExecutionHeartbeat({
runFriendlyId: this.runFriendlyId,
snapshotFriendlyId: this.currentSnapshotId,
httpClient: this.httpClient,
logger: this.logger,
heartbeatIntervalSeconds: this.env.TRIGGER_HEARTBEAT_INTERVAL_SECONDS,
});
this.snapshotPoller = new RunExecutionSnapshotPoller({
runFriendlyId: this.runFriendlyId,
snapshotFriendlyId: this.currentSnapshotId,
httpClient: this.httpClient,
logger: this.logger,
snapshotPollIntervalSeconds: this.env.TRIGGER_SNAPSHOT_POLL_INTERVAL_SECONDS,
handleSnapshotChange: this.handleSnapshotChange.bind(this),
});
this.runHeartbeat.start();
this.snapshotPoller.start();
const [startError, start] = await tryCatch(
this.startAttempt({ isWarmStart: runOpts.isWarmStart })
);
if (startError) {
this.sendDebugLog("Failed to start attempt", { error: startError.message });
this.stopServices();
return;
}
const [executeError] = await tryCatch(this.executeRunWrapper(start));
if (executeError) {
this.sendDebugLog("Failed to execute run", { error: executeError.message });
this.stopServices();
return;
}
this.stopServices();
}
private async executeRunWrapper({
run,
snapshot,
envVars,
execution,
metrics,
isWarmStart,
}: WorkloadRunAttemptStartResponseBody & {
metrics: TaskRunExecutionMetrics;
isWarmStart?: boolean;
}) {
this.currentTaskRunEnv = envVars;
const [executeError] = await tryCatch(
this.executeRun({
run,
snapshot,
envVars,
execution,
metrics,
isWarmStart,
})
);
this.sendDebugLog("Run execution completed", { error: executeError?.message });
if (!executeError) {
this.stopServices();
return;
}
if (executeError instanceof SuspendedProcessError) {
this.sendDebugLog("Run was suspended", {
run: run.friendlyId,
snapshot: snapshot.friendlyId,
error: executeError.message,
});
return;
}
if (executeError instanceof ExecutionAbortError) {
this.sendDebugLog("Run was interrupted", {
run: run.friendlyId,
snapshot: snapshot.friendlyId,
error: executeError.message,
});
return;
}
this.sendDebugLog("Error while executing attempt", {
error: executeError.message,
runId: run.friendlyId,
snapshotId: snapshot.friendlyId,
});
const completion = {
id: execution.run.id,
ok: false,
retry: undefined,
error: TaskRunProcess.parseExecuteError(executeError),
} satisfies TaskRunFailedExecutionResult;
const [completeError] = await tryCatch(this.complete({ completion }));
if (completeError) {
this.sendDebugLog("Failed to complete run", { error: completeError.message });
}
this.stopServices();
}
private async executeRun({
run,
snapshot,
envVars,
execution,
metrics,
isWarmStart,
}: WorkloadRunAttemptStartResponseBody & {
metrics: TaskRunExecutionMetrics;
isWarmStart?: boolean;
}) {
// To skip this step and eagerly create the task run process, run prepareForExecution first
if (!this.taskRunProcess || !this.isPreparedForNextRun) {
this.taskRunProcess = this.createTaskRunProcess({ envVars, isWarmStart });
}
this.sendDebugLog("executing task run process", { runId: execution.run.id });
// Set up an abort handler that will cleanup the task run process
this.executionAbortController.signal.addEventListener("abort", async () => {
this.sendDebugLog("Execution aborted during task run, cleaning up process", {
runId: execution.run.id,
});
await this.taskRunProcess?.cleanup(true);
});
const completion = await this.taskRunProcess.execute(
{
payload: {
execution,
traceContext: execution.run.traceContext ?? {},
metrics,
},
messageId: run.friendlyId,
env: envVars,
},
isWarmStart
);
// If we get here, the task completed normally
this.sendDebugLog("Completed run attempt", { attemptSuccess: completion.ok });
// The execution has finished, so we can cleanup the task run process. Killing it should be safe.
const [error] = await tryCatch(this.taskRunProcess.cleanup(true));
if (error) {
this.sendDebugLog("Failed to cleanup task run process, submitting completion anyway", {
error: error.message,
});
}
const [completionError] = await tryCatch(this.complete({ completion }));
if (completionError) {
this.sendDebugLog("Failed to complete run", { error: completionError.message });
}
}
/**
* Cancels the current execution.
*/
public async cancel(): Promise<void> {
this.sendDebugLog("cancelling attempt", { runId: this.runFriendlyId });
await this.taskRunProcess?.cancel();
}
public exit() {
if (this.isPreparedForNextRun) {
this.taskRunProcess?.forceExit();
}
}
private async complete({ completion }: { completion: TaskRunExecutionResult }): Promise<void> {
if (!this.runFriendlyId || !this.currentSnapshotId) {
throw new Error("Cannot complete run: missing run or snapshot ID");
}
const completionResult = await this.httpClient.completeRunAttempt(
this.runFriendlyId,
this.currentSnapshotId,
{ completion }
);
if (!completionResult.success) {
throw new Error(`failed to submit completion: ${completionResult.error}`);
}
await this.handleCompletionResult({
completion,
result: completionResult.data.result,
});
}
private async handleCompletionResult({
completion,
result,
}: {
completion: TaskRunExecutionResult;
result: CompleteRunAttemptResult;
}) {
this.sendDebugLog("Handling completion result", {
attemptSuccess: completion.ok,
attemptStatus: result.attemptStatus,
snapshotId: result.snapshot.friendlyId,
runId: result.run.friendlyId,
});
// Update our snapshot ID to match the completion result
// This ensures any subsequent API calls use the correct snapshot
this.currentSnapshotId = result.snapshot.friendlyId;
const { attemptStatus } = result;
if (attemptStatus === "RUN_FINISHED") {
this.sendDebugLog("Run finished");
return;
}
if (attemptStatus === "RUN_PENDING_CANCEL") {
this.sendDebugLog("Run pending cancel");
return;
}
if (attemptStatus === "RETRY_QUEUED") {
this.sendDebugLog("Retry queued");
return;
}
if (attemptStatus === "RETRY_IMMEDIATELY") {
if (completion.ok) {
throw new Error("Should retry but completion OK.");
}
if (!completion.retry) {
throw new Error("Should retry but missing retry params.");
}
await this.retryImmediately({ retryOpts: completion.retry });
return;
}
assertExhaustive(attemptStatus);
}
private measureExecutionMetrics({
attemptCreatedAt,
dequeuedAt,
podScheduledAt,
}: {
attemptCreatedAt: number;
dequeuedAt?: number;
podScheduledAt?: number;
}): TaskRunExecutionMetrics {
const metrics: TaskRunExecutionMetrics = [
{
name: "start",
event: "create_attempt",
timestamp: attemptCreatedAt,
duration: Date.now() - attemptCreatedAt,
},
];
if (dequeuedAt) {
metrics.push({
name: "start",
event: "dequeue",
timestamp: dequeuedAt,
duration: 0,
});
}
if (podScheduledAt) {
metrics.push({
name: "start",
event: "pod_scheduled",
timestamp: podScheduledAt,
duration: 0,
});
}
return metrics;
}
private async retryImmediately({ retryOpts }: { retryOpts: TaskRunExecutionRetry }) {
this.sendDebugLog("Retrying run immediately", {
timestamp: retryOpts.timestamp,
delay: retryOpts.delay,
});
const delay = retryOpts.timestamp - Date.now();
if (delay > 0) {
// Wait for retry delay to pass
await sleep(delay);
}
// Start and execute next attempt
const [startError, start] = await tryCatch(this.startAttempt({ isWarmStart: true }));
if (startError) {
this.sendDebugLog("Failed to start attempt for retry", { error: startError.message });
this.stopServices();
return;
}
const [executeError] = await tryCatch(this.executeRunWrapper({ ...start, isWarmStart: true }));
if (executeError) {
this.sendDebugLog("Failed to execute run for retry", { error: executeError.message });
this.stopServices();
return;
}
this.stopServices();
}
/**
* Restores a suspended execution from PENDING_EXECUTING
*/
private async restore(): Promise<void> {
this.sendDebugLog("Restoring execution");
if (!this.runFriendlyId || !this.currentSnapshotId) {
throw new Error("Cannot restore: missing run or snapshot ID");
}
// Short delay to give websocket time to reconnect
await sleep(100);
// Process any env overrides
await this.processEnvOverrides();
const continuationResult = await this.httpClient.continueRunExecution(
this.runFriendlyId,
this.currentSnapshotId
);
if (!continuationResult.success) {
throw new Error(continuationResult.error);
}
// Track restore count
this.restoreCount++;
}
/**
* Processes env overrides from the metadata service. Generally called when we're resuming from a suspended state.
*/
private async processEnvOverrides() {
if (!this.env.TRIGGER_METADATA_URL) {
this.sendDebugLog("No metadata URL, skipping env overrides");
return;
}
const metadataClient = new MetadataClient(this.env.TRIGGER_METADATA_URL);
const overrides = await metadataClient.getEnvOverrides();
if (!overrides) {
this.sendDebugLog("No env overrides, skipping");
return;
}
this.sendDebugLog("Processing env overrides", overrides);
// Override the env with the new values
this.env.override(overrides);
// Update services with new values
if (overrides.TRIGGER_HEARTBEAT_INTERVAL_SECONDS) {
this.runHeartbeat?.updateInterval(this.env.TRIGGER_HEARTBEAT_INTERVAL_SECONDS * 1000);
}
if (overrides.TRIGGER_SNAPSHOT_POLL_INTERVAL_SECONDS) {
this.snapshotPoller?.updateInterval(this.env.TRIGGER_SNAPSHOT_POLL_INTERVAL_SECONDS * 1000);
}
if (
overrides.TRIGGER_SUPERVISOR_API_PROTOCOL ||
overrides.TRIGGER_SUPERVISOR_API_DOMAIN ||
overrides.TRIGGER_SUPERVISOR_API_PORT
) {
this.httpClient.updateApiUrl(this.env.TRIGGER_SUPERVISOR_API_URL);
}
if (overrides.TRIGGER_RUNNER_ID) {
this.httpClient.updateRunnerId(this.env.TRIGGER_RUNNER_ID);
}
}
sendDebugLog(
message: string,
properties?: SendDebugLogOptions["properties"],
runIdOverride?: string
) {
this.logger.sendDebugLog({
runId: runIdOverride ?? this.runFriendlyId,
message: `[execution] ${message}`,
properties: {
...properties,
runId: this.runFriendlyId,
snapshotId: this.currentSnapshotId,
executionId: this.id,
executionRestoreCount: this.restoreCount,
},
});
}
// Ensure we can only set this once
private set runFriendlyId(id: string) {
if (this._runFriendlyId) {
throw new Error("Run ID already set");
}
this._runFriendlyId = id;
}
public get runFriendlyId(): string | undefined {
return this._runFriendlyId;
}
public get currentSnapshotFriendlyId(): string | undefined {
return this.currentSnapshotId;
}
public get taskRunEnv(): Record<string, string> | undefined {
return this.currentTaskRunEnv;
}
public get metrics() {
return {
restoreCount: this.restoreCount,
};
}
get isAborted() {
return this.executionAbortController.signal.aborted;
}
private abortExecution() {
if (this.isAborted) {
this.sendDebugLog("Execution already aborted");
return;
}
this.executionAbortController.abort();
this.stopServices();
}
private stopServices() {
this.runHeartbeat?.stop();
this.snapshotPoller?.stop();
}
}
@@ -0,0 +1,92 @@
import { IntervalService } from "@trigger.dev/core/v3";
import { WorkloadHttpClient } from "@trigger.dev/core/v3/runEngineWorker";
import { RunLogger } from "./logger.js";
export type RunExecutionHeartbeatOptions = {
runFriendlyId: string;
snapshotFriendlyId: string;
httpClient: WorkloadHttpClient;
logger: RunLogger;
heartbeatIntervalSeconds: number;
};
export class RunExecutionHeartbeat {
private readonly runFriendlyId: string;
private snapshotFriendlyId: string;
private readonly httpClient: WorkloadHttpClient;
private readonly logger: RunLogger;
private readonly heartbeatIntervalMs: number;
private readonly heartbeat: IntervalService;
constructor(opts: RunExecutionHeartbeatOptions) {
this.runFriendlyId = opts.runFriendlyId;
this.snapshotFriendlyId = opts.snapshotFriendlyId;
this.httpClient = opts.httpClient;
this.logger = opts.logger;
this.heartbeatIntervalMs = opts.heartbeatIntervalSeconds * 1000;
this.logger.sendDebugLog({
runId: this.runFriendlyId,
message: "RunExecutionHeartbeat",
properties: {
runFriendlyId: this.runFriendlyId,
snapshotFriendlyId: this.snapshotFriendlyId,
heartbeatIntervalSeconds: opts.heartbeatIntervalSeconds,
},
});
this.heartbeat = new IntervalService({
onInterval: async () => {
this.logger.sendDebugLog({
runId: this.runFriendlyId,
message: "heartbeat: started",
});
const response = await this.httpClient.heartbeatRun(
this.runFriendlyId,
this.snapshotFriendlyId
);
if (!response.success) {
this.logger.sendDebugLog({
runId: this.runFriendlyId,
message: "heartbeat: failed",
properties: {
error: response.error,
},
});
}
},
intervalMs: this.heartbeatIntervalMs,
leadingEdge: false,
onError: async (error) => {
this.logger.sendDebugLog({
runId: this.runFriendlyId,
message: "Failed to send heartbeat",
properties: { error: error instanceof Error ? error.message : String(error) },
});
},
});
}
resetCurrentInterval() {
this.heartbeat.resetCurrentInterval();
}
updateSnapshotId(snapshotFriendlyId: string) {
this.snapshotFriendlyId = snapshotFriendlyId;
}
updateInterval(intervalMs: number) {
this.heartbeat.updateInterval(intervalMs);
}
start() {
this.heartbeat.start();
}
stop() {
this.heartbeat.stop();
}
}
@@ -0,0 +1,52 @@
import {
WorkloadDebugLogRequestBody,
WorkloadHttpClient,
} from "@trigger.dev/core/v3/runEngineWorker";
import { RunnerEnv } from "./env.js";
export type SendDebugLogOptions = {
runId?: string;
message: string;
date?: Date;
properties?: WorkloadDebugLogRequestBody["properties"];
};
export type RunLoggerOptions = {
httpClient: WorkloadHttpClient;
env: RunnerEnv;
};
export class RunLogger {
private readonly httpClient: WorkloadHttpClient;
private readonly env: RunnerEnv;
constructor(private readonly opts: RunLoggerOptions) {
this.httpClient = opts.httpClient;
this.env = opts.env;
}
sendDebugLog({ runId, message, date, properties }: SendDebugLogOptions) {
if (!runId) {
runId = this.env.TRIGGER_RUN_ID;
}
if (!runId) {
return;
}
const mergedProperties = {
...properties,
runId,
runnerId: this.env.TRIGGER_RUNNER_ID,
workerName: this.env.TRIGGER_WORKER_INSTANCE_NAME,
};
console.log(message, mergedProperties);
this.httpClient.sendDebugLog(runId, {
message,
time: date ?? new Date(),
properties: mergedProperties,
});
}
}
@@ -0,0 +1,29 @@
export type Metadata = {
TRIGGER_SUPERVISOR_API_PROTOCOL: string | undefined;
TRIGGER_SUPERVISOR_API_DOMAIN: string | undefined;
TRIGGER_SUPERVISOR_API_PORT: number | undefined;
TRIGGER_WORKER_INSTANCE_NAME: string | undefined;
TRIGGER_HEARTBEAT_INTERVAL_SECONDS: number | undefined;
TRIGGER_SNAPSHOT_POLL_INTERVAL_SECONDS: number | undefined;
TRIGGER_SUCCESS_EXIT_CODE: number | undefined;
TRIGGER_FAILURE_EXIT_CODE: number | undefined;
TRIGGER_RUNNER_ID: string | undefined;
};
export class MetadataClient {
private readonly url: URL;
constructor(url: string) {
this.url = new URL(url);
}
async getEnvOverrides(): Promise<Metadata | null> {
try {
const response = await fetch(new URL("/env", this.url));
return response.json();
} catch (error) {
console.error("Failed to fetch metadata", { error });
return null;
}
}
}
@@ -0,0 +1,121 @@
import { WorkloadHttpClient } from "@trigger.dev/core/v3/runEngineWorker";
import { RunLogger } from "./logger.js";
import { IntervalService, RunExecutionData } from "@trigger.dev/core/v3";
export type RunExecutionSnapshotPollerOptions = {
runFriendlyId: string;
snapshotFriendlyId: string;
httpClient: WorkloadHttpClient;
logger: RunLogger;
snapshotPollIntervalSeconds: number;
handleSnapshotChange: (execution: RunExecutionData) => Promise<void>;
};
export class RunExecutionSnapshotPoller {
private runFriendlyId: string;
private snapshotFriendlyId: string;
private readonly httpClient: WorkloadHttpClient;
private readonly logger: RunLogger;
private readonly snapshotPollIntervalMs: number;
private readonly handleSnapshotChange: (runData: RunExecutionData) => Promise<void>;
private readonly poller: IntervalService;
constructor(opts: RunExecutionSnapshotPollerOptions) {
this.runFriendlyId = opts.runFriendlyId;
this.snapshotFriendlyId = opts.snapshotFriendlyId;
this.httpClient = opts.httpClient;
this.logger = opts.logger;
this.snapshotPollIntervalMs = opts.snapshotPollIntervalSeconds * 1000;
this.handleSnapshotChange = opts.handleSnapshotChange;
this.logger.sendDebugLog({
runId: this.runFriendlyId,
message: "RunExecutionSnapshotPoller",
properties: {
runFriendlyId: this.runFriendlyId,
snapshotFriendlyId: this.snapshotFriendlyId,
snapshotPollIntervalSeconds: opts.snapshotPollIntervalSeconds,
},
});
this.poller = new IntervalService({
onInterval: async () => {
if (!this.runFriendlyId) {
this.logger.sendDebugLog({
runId: this.runFriendlyId,
message: "Skipping snapshot poll, no run ID",
});
return;
}
this.logger.sendDebugLog({
runId: this.runFriendlyId,
message: "Polling for latest snapshot",
});
this.logger.sendDebugLog({
runId: this.runFriendlyId,
message: `snapshot poll: started`,
properties: {
snapshotId: this.snapshotFriendlyId,
},
});
const response = await this.httpClient.getRunExecutionData(this.runFriendlyId);
if (!response.success) {
this.logger.sendDebugLog({
runId: this.runFriendlyId,
message: "Snapshot poll failed",
properties: {
error: response.error,
},
});
this.logger.sendDebugLog({
runId: this.runFriendlyId,
message: `snapshot poll: failed`,
properties: {
snapshotId: this.snapshotFriendlyId,
error: response.error,
},
});
return;
}
await this.handleSnapshotChange(response.data.execution);
},
intervalMs: this.snapshotPollIntervalMs,
leadingEdge: false,
onError: async (error) => {
this.logger.sendDebugLog({
runId: this.runFriendlyId,
message: "Failed to poll for snapshot",
properties: { error: error instanceof Error ? error.message : String(error) },
});
},
});
}
resetCurrentInterval() {
this.poller.resetCurrentInterval();
}
updateSnapshotId(snapshotFriendlyId: string) {
this.snapshotFriendlyId = snapshotFriendlyId;
}
updateInterval(intervalMs: number) {
this.poller.updateInterval(intervalMs);
}
start() {
this.poller.start();
}
stop() {
this.poller.stop();
}
}
@@ -1,7 +1,7 @@
import {
CompletedWaitpoint,
ExecutorToWorkerMessageCatalog,
MachinePreset,
MachinePresetResources,
ServerBackgroundWorker,
TaskRunErrorCodes,
TaskRunExecution,
@@ -50,7 +50,7 @@ export type TaskRunProcessOptions = {
workerManifest: WorkerManifest;
serverWorker: ServerBackgroundWorker;
env: Record<string, string>;
machine: MachinePreset;
machineResources: MachinePresetResources;
isWarmStart?: boolean;
cwd?: string;
};
@@ -125,7 +125,7 @@ export class TaskRunProcess {
}
initialize() {
const { env: $env, workerManifest, cwd, machine } = this.options;
const { env: $env, workerManifest, cwd, machineResources: machine } = this.options;
const maxOldSpaceSize = nodeOptionsWithMaxOldSpaceSize(undefined, machine);
+7 -1
View File
@@ -2,7 +2,13 @@ export function assertExhaustive(x: never): never {
throw new Error("Unexpected object: " + x);
}
export async function tryCatch<T, E = Error>(promise: Promise<T>): Promise<[null, T] | [E, null]> {
export async function tryCatch<T, E = Error>(
promise: Promise<T> | undefined
): Promise<[null, T] | [E, null]> {
if (!promise) {
return [null, undefined as T];
}
try {
const data = await promise;
return [null, data];
+1
View File
@@ -67,6 +67,7 @@ export {
} from "./utils/ioSerialization.js";
export * from "./utils/imageRef.js";
export * from "./utils/interval.js";
export * from "./utils/heartbeat.js";
export * from "./config.js";
+13 -7
View File
@@ -1,14 +1,17 @@
import { MachinePreset } from "../schemas/common.js";
import { MachinePresetResources } from "../schemas/common.js";
/**
* Returns a value to be used for `--max-old-space-size`. It is in MiB.
* Setting this correctly means V8 spends more times running Garbage Collection (GC).
* It won't eliminate crashes but it will help avoid them.
* @param {MachinePreset} machine - The machine preset configuration containing memory specifications
* @param {MachinePresetResources} machine - The machine preset configuration containing memory specifications
* @param {number} [overhead=0.2] - The memory overhead factor (0.2 = 20% reserved for system operations)
* @returns {number} The calculated max old space size in MiB
*/
export function maxOldSpaceSizeForMachine(machine: MachinePreset, overhead: number = 0.2): number {
export function maxOldSpaceSizeForMachine(
machine: MachinePresetResources,
overhead: number = 0.2
): number {
return Math.round(machine.memory * 1_024 * (1 - overhead));
}
@@ -16,24 +19,27 @@ export function maxOldSpaceSizeForMachine(machine: MachinePreset, overhead: numb
* Returns a flag to be used for `--max-old-space-size`. It is in MiB.
* Setting this correctly means V8 spends more times running Garbage Collection (GC).
* It won't eliminate crashes but it will help avoid them.
* @param {MachinePreset} machine - The machine preset configuration containing memory specifications
* @param {MachinePresetResources} machine - The machine preset configuration containing memory specifications
* @param {number} [overhead=0.2] - The memory overhead factor (0.2 = 20% reserved for system operations)
* @returns {string} The calculated max old space size flag
*/
export function maxOldSpaceSizeFlag(machine: MachinePreset, overhead: number = 0.2): string {
export function maxOldSpaceSizeFlag(
machine: MachinePresetResources,
overhead: number = 0.2
): string {
return `--max-old-space-size=${maxOldSpaceSizeForMachine(machine, overhead)}`;
}
/**
* Takes the existing NODE_OPTIONS value, removes any existing max-old-space-size flag, and adds a new one.
* @param {string | undefined} existingOptions - The existing NODE_OPTIONS value
* @param {MachinePreset} machine - The machine preset configuration containing memory specifications
* @param {MachinePresetResources} machine - The machine preset configuration containing memory specifications
* @param {number} [overhead=0.2] - The memory overhead factor (0.2 = 20% reserved for system operations)
* @returns {string} The updated NODE_OPTIONS value with the new max-old-space-size flag
*/
export function nodeOptionsWithMaxOldSpaceSize(
existingOptions: string | undefined,
machine: MachinePreset,
machine: MachinePresetResources,
overhead: number = 0.2
): string {
let options = existingOptions ?? "";
@@ -81,6 +81,7 @@ export class SupervisorHttpClient {
);
}
/** @deprecated Not currently used */
async dequeueFromVersion(deploymentId: string, maxRunCount = 1, runnerId?: string) {
return wrapZodFetch(
WorkerApiDequeueResponseBody,
@@ -8,7 +8,7 @@ import { VERSION } from "../../../version.js";
import { io, Socket } from "socket.io-client";
import { WorkerClientToServerEvents, WorkerServerToClientEvents } from "../types.js";
import { getDefaultWorkerHeaders } from "./util.js";
import { HeartbeatService } from "../../utils/heartbeat.js";
import { IntervalService } from "../../utils/interval.js";
type SupervisorSessionOptions = SupervisorClientCommonOptions & {
queueConsumerEnabled?: boolean;
@@ -29,7 +29,7 @@ export class SupervisorSession extends EventEmitter<WorkerEvents> {
private readonly queueConsumerEnabled: boolean;
private readonly queueConsumer: RunQueueConsumer;
private readonly heartbeatService: HeartbeatService;
private readonly heartbeat: IntervalService;
private readonly heartbeatIntervalSeconds: number;
constructor(private opts: SupervisorSessionOptions) {
@@ -50,8 +50,8 @@ export class SupervisorSession extends EventEmitter<WorkerEvents> {
// TODO: This should be dynamic and set by (or at least overridden by) the platform
this.heartbeatIntervalSeconds = opts.heartbeatIntervalSeconds || 30;
this.heartbeatService = new HeartbeatService({
heartbeat: async () => {
this.heartbeat = new IntervalService({
onInterval: async () => {
console.debug("[SupervisorSession] Sending heartbeat");
const body = this.getHeartbeatBody();
@@ -182,7 +182,7 @@ export class SupervisorSession extends EventEmitter<WorkerEvents> {
if (this.queueConsumerEnabled) {
console.log("[SupervisorSession] Queue consumer enabled");
this.queueConsumer.start();
this.heartbeatService.start();
this.heartbeat.start();
} else {
console.warn("[SupervisorSession] Queue consumer disabled");
}
@@ -196,7 +196,7 @@ export class SupervisorSession extends EventEmitter<WorkerEvents> {
}
async stop() {
this.heartbeatService.stop();
this.heartbeat.stop();
this.runNotificationsSocket?.disconnect();
}
@@ -165,6 +165,7 @@ export class WorkloadHttpClient {
}
}
/** @deprecated Not currently used */
async dequeue() {
return wrapZodFetch(
WorkloadDequeueFromVersionResponseBody,
+2
View File
@@ -123,6 +123,8 @@ export const MachinePreset = z.object({
export type MachinePreset = z.infer<typeof MachinePreset>;
export type MachinePresetResources = Pick<MachinePreset, "memory" | "cpu">;
export const TaskRunBuiltInError = z.object({
type: z.literal("BUILT_IN_ERROR"),
name: z.string(),
+3
View File
@@ -5,6 +5,9 @@ type HeartbeatServiceOptions = {
onError?: (error: unknown) => Promise<void>;
};
/**
* @deprecated Use IntervalService instead
*/
export class HeartbeatService {
private _heartbeat: () => Promise<void>;
private _intervalMs: number;
+95
View File
@@ -0,0 +1,95 @@
type IntervalServiceOptions = {
onInterval: () => Promise<void>;
onError?: (error: unknown) => Promise<void>;
intervalMs?: number;
leadingEdge?: boolean;
};
export class IntervalService {
private _onInterval: () => Promise<void>;
private _onError?: (error: unknown) => Promise<void>;
private _intervalMs: number;
private _nextInterval: NodeJS.Timeout | undefined;
private _leadingEdge: boolean;
private _isEnabled: boolean;
constructor(opts: IntervalServiceOptions) {
this._onInterval = opts.onInterval;
this._onError = opts.onError;
this._intervalMs = opts.intervalMs ?? 45_000;
this._nextInterval = undefined;
this._leadingEdge = opts.leadingEdge ?? false;
this._isEnabled = false;
}
start() {
if (this._isEnabled) {
return;
}
this._isEnabled = true;
if (this._leadingEdge) {
this.#doInterval();
} else {
this.#scheduleNextInterval();
}
}
stop() {
if (!this._isEnabled) {
return;
}
this._isEnabled = false;
this.#clearNextInterval();
}
resetCurrentInterval() {
if (!this._isEnabled) {
return;
}
this.#clearNextInterval();
this.#scheduleNextInterval();
}
updateInterval(intervalMs: number) {
this._intervalMs = intervalMs;
this.resetCurrentInterval();
}
#doInterval = async () => {
this.#clearNextInterval();
if (!this._isEnabled) {
return;
}
try {
await this._onInterval();
} catch (error) {
if (this._onError) {
try {
await this._onError(error);
} catch (error) {
console.error("Error during interval error handler", error);
}
}
}
this.#scheduleNextInterval();
};
#clearNextInterval() {
if (this._nextInterval) {
clearTimeout(this._nextInterval);
}
}
#scheduleNextInterval() {
this._nextInterval = setTimeout(this.#doInterval, this._intervalMs);
}
}