Files
2026-07-13 13:32:57 +08:00

690 lines
21 KiB
TypeScript

import type { EventBusEventArgs } from "@internal/run-engine";
import { createAdapter } from "@socket.io/redis-adapter";
import {
ClientToSharedQueueMessages,
CoordinatorSocketData,
CoordinatorToPlatformMessages,
PlatformToCoordinatorMessages,
PlatformToProviderMessages,
ProviderToPlatformMessages,
SharedQueueToClientMessages,
} from "@trigger.dev/core/v3";
import { RunId } from "@trigger.dev/core/v3/isomorphic";
import type {
WorkerClientToServerEvents,
WorkerServerToClientEvents,
} from "@trigger.dev/core/v3/workers";
import { ZodNamespace } from "@trigger.dev/core/v3/zodNamespace";
import { defaultReconnectOnError } from "@internal/redis";
import { Redis } from "ioredis";
import type { Namespace, Socket } from "socket.io";
import { Server } from "socket.io";
import { env } from "~/env.server";
import { findEnvironmentById } from "~/models/runtimeEnvironment.server";
import { authenticateApiRequestWithFailure } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { singleton } from "~/utils/singleton";
import { recordRunDebugLog } from "./eventRepository/index.server";
import { sharedQueueTasks } from "./marqs/sharedQueueConsumer.server";
import { engine } from "./runEngine.server";
import { CompleteAttemptService } from "./services/completeAttempt.server";
import { CrashTaskRunService } from "./services/crashTaskRun.server";
import { CreateCheckpointService } from "./services/createCheckpoint.server";
import { CreateDeploymentBackgroundWorkerServiceV3 } from "./services/createDeploymentBackgroundWorkerV3.server";
import { CreateTaskRunAttemptService } from "./services/createTaskRunAttempt.server";
import { DeploymentIndexFailed } from "./services/deploymentIndexFailed.server";
import { ResumeAttemptService } from "./services/resumeAttempt.server";
import { UpdateFatalRunErrorService } from "./services/updateFatalRunError.server";
import { WorkerGroupTokenService } from "./services/worker/workerGroupTokenService.server";
import { SharedSocketConnection } from "./sharedSocketConnection";
import { isV3Disabled } from "./engineDeprecation.server";
export const socketIo = singleton("socketIo", initalizeIoServer);
function initalizeIoServer() {
const io = initializeSocketIOServerInstance();
io.on("connection", (socket) => {
logger.log(`[socket.io][${socket.id}] connection at url: ${socket.request.url}`);
});
const coordinatorNamespace = createCoordinatorNamespace(io);
const providerNamespace = createProviderNamespace(io);
const sharedQueueConsumerNamespace = createSharedQueueConsumerNamespace(io);
const workerNamespace = createWorkerNamespace({
io,
namespace: "/worker",
authenticate: async (request) => {
const tokenService = new WorkerGroupTokenService();
const authenticatedInstance = await tokenService.authenticate(request);
if (!authenticatedInstance) {
return false;
}
return true;
},
});
const devWorkerNamespace = createWorkerNamespace({
io,
namespace: "/dev-worker",
authenticate: async (request) => {
const authentication = await authenticateApiRequestWithFailure(request);
if (!authentication.ok) {
return false;
}
if (authentication.environment.type !== "DEVELOPMENT") {
return false;
}
return true;
},
});
return {
io,
coordinatorNamespace,
providerNamespace,
sharedQueueConsumerNamespace,
workerNamespace,
devWorkerNamespace,
};
}
function initializeSocketIOServerInstance() {
if (env.REDIS_HOST && env.REDIS_PORT) {
const pubClient = new Redis({
port: env.REDIS_PORT,
host: env.REDIS_HOST,
username: env.REDIS_USERNAME,
password: env.REDIS_PASSWORD,
enableAutoPipelining: true,
reconnectOnError: defaultReconnectOnError,
...(env.REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
});
const subClient = pubClient.duplicate();
const io = new Server({
adapter: createAdapter(pubClient, subClient, {
key: "tr:socket.io:",
publishOnSpecificResponseChannel: true,
}),
});
return io;
}
return new Server();
}
function createCoordinatorNamespace(io: Server) {
const coordinator = new ZodNamespace({
// @ts-ignore - for some reason the built ZodNamespace Server type is not compatible with the Server type here, but only when doing typechecking
io,
name: "coordinator",
authToken: env.COORDINATOR_SECRET,
clientMessages: CoordinatorToPlatformMessages,
serverMessages: PlatformToCoordinatorMessages,
socketData: CoordinatorSocketData,
handlers: {
READY_FOR_EXECUTION: async (message) => {
const payload = await sharedQueueTasks.getLatestExecutionPayloadFromRun(
message.runId,
true,
!!message.totalCompletions
);
if (!payload) {
logger.error("Failed to retrieve execution payload", message);
return { success: false };
} else {
return { success: true, payload };
}
},
READY_FOR_LAZY_ATTEMPT: async (message) => {
try {
const payload = await sharedQueueTasks.getLazyAttemptPayload(
message.envId,
message.runId
);
if (!payload) {
logger.error(
"READY_FOR_LAZY_ATTEMPT: Failed to retrieve lazy attempt payload",
message
);
return { success: false, reason: "READY_FOR_LAZY_ATTEMPT: Failed to retrieve payload" };
}
return { success: true, lazyPayload: payload };
} catch (error) {
logger.error("READY_FOR_LAZY_ATTEMPT: Error while creating lazy attempt", {
runId: message.runId,
envId: message.envId,
totalCompletions: message.totalCompletions,
error,
});
return { success: false };
}
},
READY_FOR_RESUME: async (message) => {
const resumeAttempt = new ResumeAttemptService();
await resumeAttempt.call(message);
},
TASK_RUN_COMPLETED: async (message) => {
const completeAttempt = new CompleteAttemptService({
supportsRetryCheckpoints: message.version === "v1",
});
await completeAttempt.call({
completion: message.completion,
execution: message.execution,
checkpoint: message.checkpoint,
});
},
TASK_RUN_COMPLETED_WITH_ACK: async (message) => {
try {
const completeAttempt = new CompleteAttemptService({
supportsRetryCheckpoints: message.version === "v1",
});
await completeAttempt.call({
completion: message.completion,
execution: message.execution,
checkpoint: message.checkpoint,
});
return {
success: true,
};
} catch (error) {
const friendlyError =
error instanceof Error
? {
name: error.name,
message: error.message,
stack: error.stack,
}
: {
name: "UnknownError",
message: String(error),
};
logger.error("Error while completing attempt with ack", {
error: friendlyError,
message,
});
return {
success: false,
error: friendlyError,
};
}
},
TASK_RUN_FAILED_TO_RUN: async (message) => {
await sharedQueueTasks.taskRunFailed(message.completion);
},
TASK_HEARTBEAT: async (message) => {
await sharedQueueTasks.taskHeartbeat(message.attemptFriendlyId);
},
TASK_RUN_HEARTBEAT: async (message) => {
await sharedQueueTasks.taskRunHeartbeat(message.runId);
},
CHECKPOINT_CREATED: async (message) => {
try {
const createCheckpoint = new CreateCheckpointService();
const result = await createCheckpoint.call(message);
return { keepRunAlive: result?.keepRunAlive ?? false };
} catch (error) {
logger.error("Error while creating checkpoint", {
rawMessage: message,
error: error instanceof Error ? error.message : error,
});
return { keepRunAlive: false };
}
},
CREATE_WORKER: async (message) => {
try {
const environment = await findEnvironmentById(message.envId);
if (!environment) {
logger.error("Environment not found", { id: message.envId });
return { success: false };
}
const service = new CreateDeploymentBackgroundWorkerServiceV3();
const worker = await service.call(message.projectRef, environment, message.deploymentId, {
localOnly: false,
metadata: message.metadata,
supportsLazyAttempts: message.version !== "v1" && message.supportsLazyAttempts,
});
return { success: !!worker };
} catch (error) {
logger.error("Error while creating worker", {
error,
envId: message.envId,
projectRef: message.projectRef,
deploymentId: message.deploymentId,
version: message.version,
});
return { success: false };
}
},
CREATE_TASK_RUN_ATTEMPT: async (message) => {
try {
const environment = await findEnvironmentById(message.envId);
if (!environment) {
logger.error("CREATE_TASK_RUN_ATTEMPT: Environment not found", message);
return { success: false, reason: "Environment not found" };
}
const service = new CreateTaskRunAttemptService();
const { attempt } = await service.call({
runId: message.runId,
authenticatedEnv: environment,
setToExecuting: false,
});
const payload = await sharedQueueTasks.getExecutionPayloadFromAttempt({
id: attempt.id,
setToExecuting: true,
skipStatusChecks: true,
});
if (!payload) {
logger.error(
"CREATE_TASK_RUN_ATTEMPT: Failed to retrieve payload after attempt creation",
message
);
return {
success: false,
reason: "CREATE_TASK_RUN_ATTEMPT: Failed to retrieve payload",
};
}
return { success: true, executionPayload: payload };
} catch (error) {
logger.error("CREATE_TASK_RUN_ATTEMPT: Error while creating attempt", {
...message,
error,
});
return { success: false };
}
},
INDEXING_FAILED: async (message) => {
try {
const service = new DeploymentIndexFailed();
await service.call(message.deploymentId, message.error);
} catch (error) {
logger.error("Error while processing index failure", {
deploymentId: message.deploymentId,
error,
});
}
},
RUN_CRASHED: async (message) => {
try {
const service = new CrashTaskRunService();
await service.call(message.runId, {
reason: `${message.error.name}: ${message.error.message}`,
logs: message.error.stack,
});
} catch (error) {
logger.error("Error while processing run failure", {
runId: message.runId,
error,
});
}
},
},
onConnection: async (socket, handler, sender, logger) => {
if (socket.data.supportsDynamicConfig) {
socket.emit("DYNAMIC_CONFIG", {
version: "v1",
checkpointThresholdInMs: env.CHECKPOINT_THRESHOLD_IN_MS,
});
}
},
postAuth: async (socket, next, logger) => {
function setSocketDataFromHeader(
dataKey: keyof typeof socket.data,
headerName: string,
required: boolean = true
) {
const value = socket.handshake.headers[headerName];
if (value) {
socket.data[dataKey] = Array.isArray(value) ? value[0] : value;
return;
}
if (required) {
logger.error("missing required header", { headerName });
throw new Error("missing header");
}
}
try {
setSocketDataFromHeader("supportsDynamicConfig", "x-supports-dynamic-config", false);
} catch (error) {
logger.error("setSocketDataFromHeader error", { error });
socket.disconnect(true);
return;
}
logger.debug("success", socket.data);
next();
},
});
return coordinator.namespace;
}
function createProviderNamespace(io: Server) {
const provider = new ZodNamespace({
// @ts-ignore - for some reason the built ZodNamespace Server type is not compatible with the Server type here, but only when doing typechecking
io,
name: "provider",
authToken: env.PROVIDER_SECRET,
clientMessages: ProviderToPlatformMessages,
serverMessages: PlatformToProviderMessages,
handlers: {
WORKER_CRASHED: async (message) => {
try {
if (message.overrideCompletion) {
const updateErrorService = new UpdateFatalRunErrorService();
await updateErrorService.call(message.runId, { ...message });
} else {
const crashRunService = new CrashTaskRunService();
await crashRunService.call(message.runId, { ...message });
}
} catch (error) {
logger.error("Error while handling crashed worker", { error });
}
},
INDEXING_FAILED: async (message) => {
try {
const service = new DeploymentIndexFailed();
await service.call(message.deploymentId, message.error, message.overrideCompletion);
} catch (e) {
logger.error("Error while indexing", { error: e });
}
},
},
});
return provider.namespace;
}
function createSharedQueueConsumerNamespace(io: Server) {
const sharedQueue = new ZodNamespace({
// @ts-ignore - for some reason the built ZodNamespace Server type is not compatible with the Server type here, but only when doing typechecking
io,
name: "shared-queue",
authToken: env.PROVIDER_SECRET,
clientMessages: ClientToSharedQueueMessages,
serverMessages: SharedQueueToClientMessages,
onConnection: async (socket, handler, sender, logger) => {
// v3 (engine V1) shutdown: don't start the MarQS shared-queue consumer, so no
// deployed V1 runs are dequeued. This namespace is V1-only; v4 dequeues through
// the run-engine worker path. This is the code-level equivalent of taking the
// v3 coordinator offline.
if (isV3Disabled()) {
logger.warn("Refusing /shared-queue connection: v3 engine is shut down");
socket.disconnect(true);
return;
}
const sharedSocketConnection = new SharedSocketConnection({
// @ts-ignore - for some reason the built ZodNamespace Server type is not compatible with the Server type here, but only when doing typechecking
namespace: sharedQueue.namespace,
// @ts-ignore - for some reason the built ZodNamespace Server type is not compatible with the Server type here, but only when doing typechecking
socket,
logger,
poolSize: env.SHARED_QUEUE_CONSUMER_POOL_SIZE,
});
sharedSocketConnection.onClose.attach((closeEvent) => {
logger.info("Socket closed", { closeEvent });
});
await sharedSocketConnection.initialize();
},
});
return sharedQueue.namespace;
}
function headersFromHandshake(handshake: Socket["handshake"]) {
const headers = new Headers();
for (const [key, value] of Object.entries(handshake.headers)) {
if (typeof value !== "string") continue;
headers.append(key, value);
}
return headers;
}
function createWorkerNamespace({
io,
namespace,
authenticate,
}: {
io: Server;
namespace: string;
authenticate: (request: Request) => Promise<boolean>;
}) {
const worker: Namespace<WorkerClientToServerEvents, WorkerServerToClientEvents> =
io.of(namespace);
worker.use(async (socket, next) => {
try {
const headers = headersFromHandshake(socket.handshake);
logger.debug("Worker authentication", {
namespace,
socketId: socket.id,
headers: Object.fromEntries(headers),
});
const request = new Request("https://example.com", {
headers,
});
const success = await authenticate(request);
if (!success) {
throw new Error("unauthorized");
}
next();
} catch (error) {
// System handles auth failure by disconnecting the socket — not an
// error. Most volume is V1 /dev-worker reconnect churn from outdated
// CLIs anyway.
logger.warn("Worker authentication failed", {
namespace,
error: error instanceof Error ? error.message : error,
});
socket.disconnect(true);
}
});
worker.on("connection", async (socket) => {
logger.debug("worker connected", { namespace, socketId: socket.id });
const rooms = new Set<string>();
async function onNotification({
time,
run,
snapshot,
}: EventBusEventArgs<"workerNotification">[0]) {
if (!env.RUN_ENGINE_DEBUG_WORKER_NOTIFICATIONS) {
return;
}
logger.debug("[handleSocketIo] Received worker notification", {
namespace,
time,
runId: run.id,
snapshot,
});
// Record notification event
await recordRunDebugLog(run.id, `run:notify workerNotification event`, {
attributes: {
properties: {
snapshotId: snapshot.id,
snapshotStatus: snapshot.executionStatus,
rooms: Array.from(rooms),
},
},
startTime: time,
});
}
engine.eventBus.on("workerNotification", onNotification);
const interval = setInterval(() => {
logger.debug("Rooms for socket", {
namespace,
socketId: socket.id,
rooms: Array.from(rooms),
});
}, 5000);
socket.on("disconnect", (reason, description) => {
logger.debug("worker disconnected", {
namespace,
socketId: socket.id,
reason,
description,
});
clearInterval(interval);
engine.eventBus.off("workerNotification", onNotification);
});
socket.on("disconnecting", (reason, description) => {
logger.debug("worker disconnecting", {
namespace,
socketId: socket.id,
reason,
description,
});
clearInterval(interval);
});
socket.on("error", (error) => {
logger.error("worker error", {
namespace,
socketId: socket.id,
error: JSON.parse(JSON.stringify(error)),
});
clearInterval(interval);
});
socket.on("run:subscribe", async ({ version, runFriendlyIds }) => {
logger.debug("run:subscribe", { namespace, version, runFriendlyIds });
const settledResult = await Promise.allSettled(
runFriendlyIds.map(async (friendlyId) => {
const room = roomFromFriendlyRunId(friendlyId);
logger.debug("Joining room", { namespace, room });
socket.join(room);
rooms.add(room);
await recordRunDebugLog(
RunId.fromFriendlyId(friendlyId),
"run:subscribe received by platform",
{
attributes: {
properties: {
friendlyId,
runFriendlyIds,
room,
},
},
}
);
})
);
for (const result of settledResult) {
if (result.status === "rejected") {
logger.error("Error joining room", {
namespace,
runFriendlyIds,
error: result.reason instanceof Error ? result.reason.message : result.reason,
});
}
}
logger.debug("Rooms for socket after subscribe", {
namespace,
socketId: socket.id,
rooms: Array.from(rooms),
});
});
socket.on("run:unsubscribe", async ({ version, runFriendlyIds }) => {
logger.debug("run:unsubscribe", { namespace, version, runFriendlyIds });
const settledResult = await Promise.allSettled(
runFriendlyIds.map(async (friendlyId) => {
const room = roomFromFriendlyRunId(friendlyId);
logger.debug("Leaving room", { namespace, room });
socket.leave(room);
rooms.delete(room);
await recordRunDebugLog(
RunId.fromFriendlyId(friendlyId),
"run:unsubscribe received by platform",
{
attributes: {
properties: {
friendlyId,
runFriendlyIds,
room,
},
},
}
);
})
);
for (const result of settledResult) {
if (result.status === "rejected") {
logger.error("Error leaving room", {
namespace,
runFriendlyIds,
error: result.reason instanceof Error ? result.reason.message : result.reason,
});
}
}
logger.debug("Rooms for socket after unsubscribe", {
namespace,
socketId: socket.id,
rooms: Array.from(rooms),
});
});
});
return worker;
}
export function roomFromFriendlyRunId(id: string) {
return `room:${id}`;
}