v3: cli build command, prod runs, checkpoints (#919)

* fix trailing slash in api url

* add maybe platform down error

* shorten config path

* extend protobuf compiler install instructions

* build command and image model

* new image triggers task indexing

* index support for prod builds

* shorten example provider secret

* multi-stage task prod build

* lock prod tasks to node 18 image

* extract shared build and dev command libs

* pull out more shared deps

* add coordinator and providers

* fix core-apps build

* add dev builds for new apps

* fix cwd

* enable corepack

* some build fixes

* enable buildkit for old docker versions

* coordinator image fixes

* update provider containerfile

* build dev images in parallel

* upgrade pgadmin

* fix prod facade build

* prod runs

* fix merge

* don't knock out platform on invalid attempt id

* fix prod facade

* rename to build.ts

* fix prod builds

* set to executing after fetching payload

* make prod worker listen on random port if in use

* remove experimental warnings in dev

* prod resume

* prevent execution after completion

* exit prod worker after completion

* always restart otel collector

* docker checkpoints and prod runtime messaging

* don't retry indexing without chance of success

* make platform checkpoint aware

* deploy with existing hash sets latest worker

* log restore requests

* only try to checkpoint long waits

* tidying up

* lockfile

* fix build

* prod worker merge fixes

* fix prod complete and cancel

* fix lua nil checks

* socket namespace abstraction

* cleanup

* make all build args optional

* add build script

* don't require env vars for dev

* fix schema

* prod merge

* small fix

* bind correct logger

* fix v3 ref catalog entry

* resume prod batch

* pass socket to error and disconnect handlers

* fix non-batch resume

* fix batch resume

* send connection env vars when not in dev

* create worker via socket

* move api client back into v3 cli

* fix lockfile

* fix resume with failures

* marqs replace message

* typecheck prior to build

* don't define api url in prod builds

* support prod retries after resume

* skip typecheck option
This commit is contained in:
nicktrn
2024-03-04 13:10:07 +00:00
committed by GitHub
parent ca47e6bdcd
commit 52c9d485f8
95 changed files with 7262 additions and 455 deletions
+5 -1
View File
@@ -45,4 +45,8 @@ CLOUD_LINEAR_CLIENT_ID=
CLOUD_LINEAR_CLIENT_SECRET=
CLOUD_SLACK_APP_HOST=
CLOUD_SLACK_CLIENT_ID=
CLOUD_SLACK_CLIENT_SECRET=
CLOUD_SLACK_CLIENT_SECRET=
# v3 variables
PROVIDER_SECRET=provider-secret # generate the actual secret with `openssl rand -hex 32`
COORDINATOR_SECRET=coordinator-secret # generate the actual secret with `openssl rand -hex 32`
+83
View File
@@ -0,0 +1,83 @@
name: "🚢 Publish Container Images (dev)"
on:
push:
tags:
- "dev-*"
paths:
- ".github/workflows/publish.yml"
- "packages/**"
- "!packages/**/*.md"
- "!packages/**/*.eslintrc"
- "apps/**"
- "!apps/**/*.md"
- "!apps/**/*.eslintrc"
- "integrations/**"
- "!integrations/**/*.md"
- "!integrations/**/*.eslintrc"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "turbo.json"
- "docker/Dockerfile"
- "docker/scripts/**"
- "tests/**"
permissions:
id-token: write
packages: write
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
env:
AWS_REGION: us-east-1
jobs:
build:
strategy:
matrix:
package: [coordinator, kubernetes-provider]
runs-on: buildjet-4vcpu-ubuntu-2204
env:
DOCKER_BUILDKIT: "1"
steps:
- uses: actions/checkout@v4
- name: Generate build ID
id: prep
run: |
sha=${GITHUB_SHA::7}
ts=$(date +%s)
echo "BUILD_ID=${{ matrix.package }}-${sha}-${ts}" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
# ..to avoid rate limits when pulling images
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: 🚢 Build Container Image
run: |
docker build -t dev_image -f ./apps/${{ matrix.package }}/Containerfile .
# ..to push image
- name: 🐙 Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: 🐙 Push to GitHub Container Registry
run: |
docker tag dev_image $REGISTRY/$REPOSITORY:$IMAGE_TAG
docker push $REGISTRY/$REPOSITORY:$IMAGE_TAG
env:
REGISTRY: ghcr.io/triggerdotdev
REPOSITORY: dev
IMAGE_TAG: ${{ steps.prep.outputs.BUILD_ID }}
+4
View File
@@ -0,0 +1,4 @@
HTTP_SERVER_PORT=8020
PLATFORM_ENABLED=true
PLATFORM_WS_PORT=3030
+3
View File
@@ -0,0 +1,3 @@
dist/
node_modules/
.env
+63
View File
@@ -0,0 +1,63 @@
# syntax=docker/dockerfile:labs
FROM node:18.18.2-bullseye-slim@sha256:21479df46c3173ee0cefc6b264928e10239152c4f74df872ca9369be01a245b7 AS node-18
WORKDIR /app
FROM node-18 AS pruner
COPY --chown=node:node . .
RUN npx -q turbo@1.10.9 prune --scope=coordinator --docker
RUN find . -name "node_modules" -type d -prune -exec rm -rf '{}' +
FROM node-18 AS base
RUN apt-get update \
&& apt-get install -y buildah ca-certificates dumb-init \
&& rm -rf /var/lib/apt/lists/*
COPY --chown=node:node .gitignore .gitignore
COPY --from=pruner --chown=node:node /app/out/json/ .
COPY --from=pruner --chown=node:node /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
COPY --from=pruner --chown=node:node /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
FROM base AS dev-deps
RUN corepack enable
ENV NODE_ENV development
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm fetch --no-frozen-lockfile
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --ignore-scripts --no-frozen-lockfile
FROM base AS builder
RUN corepack enable
COPY --from=pruner --chown=node:node /app/out/full/ .
COPY --from=dev-deps --chown=node:node /app/ .
COPY --chown=node:node turbo.json turbo.json
RUN pnpm run -r --filter '@trigger.dev/core*' build
RUN pnpm run -r --filter coordinator build:bundle
FROM alpine AS cri-tools
WORKDIR /cri-tools
ARG CRICTL_VERSION=v1.29.0
ARG CRICTL_CHECKSUM=sha256:d16a1ffb3938f5a19d5c8f45d363bd091ef89c0bc4d44ad16b933eede32fdcbb
ADD --checksum=${CRICTL_CHECKSUM} \
https://github.com/kubernetes-sigs/cri-tools/releases/download/${CRICTL_VERSION}/crictl-${CRICTL_VERSION}-linux-amd64.tar.gz .
RUN tar zxvf crictl-${CRICTL_VERSION}-linux-amd64.tar.gz
FROM base AS runner
RUN corepack enable
ENV NODE_ENV production
COPY --from=cri-tools --chown=node:node /cri-tools/crictl /usr/local/bin
COPY --from=builder --chown=node:node /app/apps/coordinator/dist/index.cjs ./index.cjs
EXPOSE 8000
USER node
CMD [ "/usr/bin/dumb-init", "--", "/usr/local/bin/node", "./index.cjs" ]
+3
View File
@@ -0,0 +1,3 @@
# Coordinator
Sits between the platform and tasks. Facilitates communication and checkpointing, amongst other things.
+34
View File
@@ -0,0 +1,34 @@
{
"name": "coordinator",
"private": true,
"version": "0.0.1",
"description": "",
"main": "dist/index.cjs",
"type": "module",
"scripts": {
"build": "npm run build:bundle",
"build:bundle": "esbuild src/index.ts --bundle --outfile=dist/index.cjs --platform=node",
"build:image": "docker build -f Containerfile . -t coordinator",
"dev": "tsx --no-warnings=ExperimentalWarning --require dotenv/config --watch src/index.ts",
"start": "tsx src/index.ts",
"typecheck": "tsc --noEmit"
},
"keywords": [],
"author": "",
"license": "MIT",
"dependencies": {
"@trigger.dev/core": "workspace:*",
"@trigger.dev/core-apps": "workspace:*",
"execa": "^8.0.1",
"prom-client": "^15.1.0",
"socket.io": "^4.7.4",
"socket.io-client": "^4.7.4"
},
"devDependencies": {
"@types/node": "^18",
"dotenv": "^16.4.2",
"esbuild": "^0.19.11",
"tsx": "^4.7.0",
"typescript": "^5.3.3"
}
}
+506
View File
@@ -0,0 +1,506 @@
import { randomUUID } from "node:crypto";
import { createServer } from "node:http";
import { $ } from "execa";
import { Namespace } from "socket.io";
import { Server } from "socket.io";
import { Socket, io } from "socket.io-client";
import { DefaultEventsMap } from "socket.io/dist/typed-events";
import {
CoordinatorToPlatformEvents,
CoordinatorToProdWorkerEvents,
PlatformToCoordinatorEvents,
ProdWorkerSocketData,
ProdWorkerToCoordinatorEvents,
} from "@trigger.dev/core/v3";
import { HttpReply, getTextBody, SimpleLogger } from "@trigger.dev/core-apps";
import { collectDefaultMetrics, register, Gauge } from "prom-client";
collectDefaultMetrics();
const HTTP_SERVER_PORT = Number(process.env.HTTP_SERVER_PORT || 8020);
const NODE_NAME = process.env.NODE_NAME || "coordinator";
const REGISTRY_FQDN = process.env.REGISTRY_FQDN || "localhost:5000";
const REPO_NAME = process.env.REPO_NAME || "checkpoints";
const CHECKPOINT_PATH = process.env.CHECKPOINT_PATH || "/checkpoints";
const REGISTRY_TLS_VERIFY = process.env.REGISTRY_TLS_VERIFY === "false" ? "false" : "true";
const PLATFORM_ENABLED = ["1", "true"].includes(process.env.PLATFORM_ENABLED ?? "true");
const PLATFORM_HOST = process.env.PLATFORM_HOST || "127.0.0.1";
const PLATFORM_WS_PORT = process.env.PLATFORM_WS_PORT || 3030;
const PLATFORM_SECRET = process.env.PLATFORM_SECRET || "coordinator-secret";
const logger = new SimpleLogger(`[${NODE_NAME}]`);
class Checkpointer {
#initialized = false;
#canCheckpoint = false;
#dockerMode = true;
#logger = new SimpleLogger("[checkptr]");
async initialize() {
if (this.#initialized) {
return;
}
try {
await $`criu --version`;
} catch (error) {
this.#logger.error("No checkpoint support: Missing CRIU binary");
this.#canCheckpoint = false;
this.#initialized = true;
return;
}
if (this.#dockerMode) {
try {
await $`docker checkpoint`;
} catch (error) {
this.#logger.error(
"No checkpoint support: Docker needs to have experimental features enabled"
);
this.#canCheckpoint = false;
this.#initialized = true;
return;
}
}
this.#logger.log(
`Full checkpoint support with docker ${this.#dockerMode ? "enabled" : "disabled"}`
);
this.#initialized = true;
this.#canCheckpoint = true;
}
async checkpointAndPush(podName: string) {
await this.initialize();
if (!this.#canCheckpoint) {
return;
}
try {
const { path } = await this.#checkpointContainer(podName);
const { tag } = await this.#buildImage(path, podName);
const { destination } = await this.#pushImage(tag);
if (this.#dockerMode) {
this.#logger.log("checkpoint created:", { podName, path });
} else {
this.#logger.log("checkpointed and pushed image to:", destination);
}
return {
path,
tag,
destination: this.#dockerMode ? path : destination,
docker: this.#dockerMode,
};
} catch (error) {
this.#logger.error("checkpoint failed", error);
return;
}
}
async #checkpointContainer(podName: string) {
await this.initialize();
if (!this.#canCheckpoint) {
throw new Error("No checkpoint support");
}
if (this.#dockerMode) {
this.#logger.log("Checkpointing:", podName);
const path = randomUUID();
try {
this.#logger.debug(await $`docker checkpoint create --leave-running ${podName} ${path}`);
} catch (error: any) {
this.#logger.error(error.stderr);
}
return { path };
}
const containerId = this.#logger.debug(
// @ts-expect-error
await $`crictl ps`
.pipeStdout($({ stdin: "pipe" })`grep ${podName}`)
.pipeStdout($({ stdin: "pipe" })`cut -f1 ${"-d "}`)
);
if (!containerId.stdout) {
throw new Error("could not find container id");
}
const exportPath = `${CHECKPOINT_PATH}/${podName}.tar`;
this.#logger.debug(await $`crictl checkpoint --export=${exportPath} ${containerId}`);
return {
path: exportPath,
};
}
async #buildImage(checkpointPath: string, tag: string) {
await this.initialize();
if (!this.#canCheckpoint) {
throw new Error("No checkpoint support");
}
if (this.#dockerMode) {
// Nothing to do here
return { tag };
}
const container = this.#logger.debug(await $`buildah from scratch`);
this.#logger.debug(await $`buildah add ${container} ${checkpointPath} /`);
this.#logger.debug(
await $`buildah config --annotation=io.kubernetes.cri-o.annotations.checkpoint.name=counter ${container}`
);
this.#logger.debug(await $`buildah commit ${container} ${REGISTRY_FQDN}/${REPO_NAME}:${tag}`);
this.#logger.debug(await $`buildah rm ${container}`);
return {
tag,
};
}
async #pushImage(tag: string) {
await this.initialize();
if (!this.#canCheckpoint) {
throw new Error("No checkpoint support");
}
if (this.#dockerMode) {
// Nothing to do here
return { destination: "" };
}
const destination = `${REGISTRY_FQDN}/${REPO_NAME}:${tag}`;
this.#logger.debug(await $`buildah push --tls-verify=${REGISTRY_TLS_VERIFY} ${destination}`);
return {
destination,
};
}
}
class TaskCoordinator {
#httpServer: ReturnType<typeof createServer>;
#checkpointer = new Checkpointer();
#prodWorkerNamespace: Namespace<
ProdWorkerToCoordinatorEvents,
CoordinatorToProdWorkerEvents,
DefaultEventsMap,
ProdWorkerSocketData
>;
#platformSocket?: Socket<PlatformToCoordinatorEvents, CoordinatorToPlatformEvents>;
constructor(
private port: number,
private host = "0.0.0.0"
) {
this.#httpServer = this.#createHttpServer();
this.#checkpointer.initialize();
const io = new Server(this.#httpServer);
this.#prodWorkerNamespace = this.#createProdWorkerNamespace(io);
this.#platformSocket = this.#createPlatformSocket();
const connectedTasksTotal = new Gauge({
name: "daemon_connected_tasks_total", // don't change this without updating dashboard config
help: "The number of tasks currently connected.",
collect: () => {
connectedTasksTotal.set(this.#prodWorkerNamespace.sockets.size);
},
});
register.registerMetric(connectedTasksTotal);
}
#createPlatformSocket() {
if (!PLATFORM_ENABLED) {
console.log("INFO: platform connection disabled");
return;
}
const socket: Socket<PlatformToCoordinatorEvents, CoordinatorToPlatformEvents> = io(
`ws://${PLATFORM_HOST}:${PLATFORM_WS_PORT}/coordinator`,
{
transports: ["websocket"],
auth: {
token: PLATFORM_SECRET,
},
}
);
const logger = new SimpleLogger(`[platform][${socket.id ?? "NO_ID"}]`);
socket.on("connect", () => {
logger.log("connect");
});
socket.on("connect_error", (err) => {
logger.error(`connect_error: ${err.message}`);
});
socket.on("disconnect", () => {
logger.log("disconnect");
});
socket.on("RESUME", async (message) => {
logger.log("[RESUME]", message);
const taskSocket = await this.#getAttemptSocket(message.attemptId);
if (!taskSocket) {
logger.log("Socket for attempt not found", { attemptId: message.attemptId });
return;
}
taskSocket.emit("RESUME", message);
});
return socket;
}
async #getAttemptSocket(attemptId: string) {
const sockets = await this.#prodWorkerNamespace.fetchSockets();
for (const socket of sockets) {
if (socket.data.attemptId === attemptId) {
return socket;
}
}
}
#createProdWorkerNamespace(io: Server) {
const namespace: Namespace<
ProdWorkerToCoordinatorEvents,
CoordinatorToProdWorkerEvents,
DefaultEventsMap,
ProdWorkerSocketData
> = io.of("/prod-worker");
namespace.on("connection", async (socket) => {
const logger = new SimpleLogger(`[task][${socket.id}]`);
this.#platformSocket?.emit("LOG", {
version: "v1",
metadata: {
projectRef: socket.data.projectRef,
attemptId: socket.data.attemptId,
},
text: "connected",
});
logger.log("connected");
socket.on("disconnect", (reason, description) => {
logger.log("disconnect", { reason, description });
this.#platformSocket?.emit("LOG", {
version: "v1",
metadata: {
projectRef: socket.data.projectRef,
attemptId: socket.data.attemptId,
},
text: "disconnect",
});
});
socket.on("error", (error) => {
logger.error({ error });
});
socket.on("LOG", (message, callback) => {
logger.log("[LOG]", message.text);
callback();
this.#platformSocket?.emit("LOG", {
version: "v1",
metadata: { attemptId: socket.data.attemptId },
text: message.text,
});
});
socket.on("READY_FOR_EXECUTION", async (message) => {
logger.log("[READY_FOR_EXECUTION]", message);
const executionAck = await this.#platformSocket?.emitWithAck("READY_FOR_EXECUTION", {
version: "v1",
attemptId: message.attemptId,
});
if (!executionAck) {
logger.error("no execution ack", { attemptId: socket.data.attemptId });
return;
}
if (!executionAck.success) {
logger.error("execution unsuccessful", { attemptId: socket.data.attemptId });
return;
}
// FIXME: shouldn't wait for completion here
const completionAck = await socket.emitWithAck("EXECUTE_TASK_RUN", {
version: "v1",
payload: executionAck.payload,
});
logger.log("completed task", { completionId: completionAck.completion.id });
this.#platformSocket?.emit("TASK_RUN_COMPLETED", {
version: "v1",
execution: executionAck.payload.execution,
completion: completionAck.completion,
});
});
socket.on("TASK_HEARTBEAT", (message) => {
logger.log("[TASK_HEARTBEAT]", message);
this.#platformSocket?.emit("TASK_HEARTBEAT", message);
});
socket.on("WAIT_FOR_BATCH", (message) => {
logger.log("[WAIT_FOR_BATCH]", message);
// this.#checkpointer.checkpointAndPush(socket.data.podName);
});
socket.on("WAIT_FOR_DURATION", async (message, callback) => {
logger.log("[WAIT_FOR_DURATION]", message);
const checkpoint = await this.#checkpointer.checkpointAndPush(socket.data.podName);
if (!checkpoint) {
logger.error("Failed to checkpoint", { podName: socket.data.podName });
callback({ success: false });
return;
}
this.#platformSocket?.emit("CHECKPOINT_CREATED", {
version: "v1",
attemptId: socket.data.attemptId,
docker: checkpoint.docker,
location: checkpoint.destination,
reason: "WAIT_FOR_DURATION",
});
callback({ success: true });
});
socket.on("WAIT_FOR_TASK", (message) => {
logger.log("[WAIT_FOR_TASK]", message);
// this.#checkpointer.checkpointAndPush(socket.data.podName);
});
socket.on("INDEX_TASKS", async (message, callback) => {
logger.log("[INDEX_TASKS]", message);
const workerAck = await this.#platformSocket?.emitWithAck("CREATE_WORKER", {
version: "v1",
projectRef: socket.data.projectRef,
envId: socket.data.envId,
metadata: {
cliPackageVersion: socket.data.cliPackageVersion,
contentHash: socket.data.contentHash,
packageVersion: message.packageVersion,
tasks: message.tasks,
},
});
if (!workerAck) {
logger.debug("no worker ack while indexing", message);
}
callback({ success: !!workerAck?.success });
});
});
// auth middleware
namespace.use(async (socket, next) => {
const logger = new SimpleLogger(`[task][${socket.id}][auth]`);
function setSocketDataFromHeader(dataKey: keyof typeof socket.data, headerName: string) {
const value = socket.handshake.headers[headerName];
if (!value) {
logger.error(`missing required header: ${headerName}`);
throw new Error("missing header");
}
socket.data[dataKey] = Array.isArray(value) ? value[0] : value;
}
try {
setSocketDataFromHeader("podName", "x-pod-name");
setSocketDataFromHeader("contentHash", "x-trigger-content-hash");
setSocketDataFromHeader("cliPackageVersion", "x-trigger-cli-package-version");
setSocketDataFromHeader("projectRef", "x-trigger-project-ref");
setSocketDataFromHeader("attemptId", "x-trigger-attempt-id");
setSocketDataFromHeader("envId", "x-trigger-env-id");
} catch (error) {
return socket.disconnect(true);
}
logger.log("success", socket.data);
next();
});
return namespace;
}
#createHttpServer() {
const httpServer = createServer(async (req, res) => {
logger.log(`[${req.method}]`, req.url);
const reply = new HttpReply(res);
switch (req.url) {
case "/health": {
return reply.text("ok");
}
case "/metrics": {
return reply.text(await register.metrics(), 200, register.contentType);
}
case "/whoami": {
return reply.text(NODE_NAME);
}
case "/checkpoint": {
const body = await getTextBody(req);
await this.#checkpointer.checkpointAndPush(body);
return reply.text(`sent restore request: ${body}`);
}
default: {
return reply.empty(404);
}
}
});
httpServer.on("clientError", (err, socket) => {
socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
});
httpServer.on("listening", () => {
logger.log("server listening on port", HTTP_SERVER_PORT);
});
return httpServer;
}
listen() {
this.#httpServer.listen(this.port, this.host);
}
}
const coordinator = new TaskCoordinator(HTTP_SERVER_PORT);
coordinator.listen();
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"paths": {
"@trigger.dev/core/v3": ["../core/src/v3"],
"@trigger.dev/core/v3/*": ["../core/src/v3/*"],
"@trigger.dev/core-apps": ["../core-apps/src"],
"@trigger.dev/core-apps/*": ["../core-apps/src/*"]
}
}
}
+4
View File
@@ -0,0 +1,4 @@
HTTP_SERVER_PORT=8050
PLATFORM_WS_PORT=3030
PLATFORM_SECRET=provider-secret
+3
View File
@@ -0,0 +1,3 @@
dist/
node_modules/
.env
+16
View File
@@ -0,0 +1,16 @@
# syntax=docker/dockerfile:labs
FROM node:18-slim AS base
RUN apt-get update \
&& apt-get install -y dumb-init
FROM base
WORKDIR /app
COPY --chown=node dist/index.cjs /app/
EXPOSE 8000
ENTRYPOINT [ "/usr/bin/dumb-init", "--", "/usr/local/bin/node", "/app/index.cjs" ]
+3
View File
@@ -0,0 +1,3 @@
# Docker provider
The `docker-provider` allows the platform to be orchestrator-agnostic. The platform can perform actions such as `INDEX_TASKS` or `INVOKE_TASK` which the provider translates into Docker actions.
+32
View File
@@ -0,0 +1,32 @@
{
"name": "docker-provider",
"private": true,
"version": "0.0.1",
"description": "",
"main": "dist/index.cjs",
"type": "module",
"scripts": {
"build": "npm run build:bundle",
"build:bundle": "esbuild src/index.ts --bundle --outfile=dist/index.cjs --platform=node",
"build:image": "docker build -f Containerfile . -t docker-provider",
"dev": "tsx --no-warnings=ExperimentalWarning --require dotenv/config --watch src/index.ts",
"start": "tsx src/index.ts",
"typecheck": "tsc --noEmit"
},
"keywords": [],
"author": "",
"license": "MIT",
"dependencies": {
"@trigger.dev/core": "workspace:*",
"@trigger.dev/core-apps": "workspace:*",
"execa": "^8.0.1",
"socket.io-client": "^4.7.4"
},
"devDependencies": {
"@types/node": "^18.19.8",
"dotenv": "^16.4.2",
"esbuild": "^0.19.11",
"tsx": "^4.7.0",
"typescript": "^5.3.3"
}
}
+335
View File
@@ -0,0 +1,335 @@
import { createServer } from "node:http";
import { $ } from "execa";
import { io, Socket } from "socket.io-client";
import {
clientWebsocketMessages,
Machine,
MessageCatalogToSocketIoEvents,
ProviderClientToServerEvents,
ProviderServerToClientEvents,
serverWebsocketMessages,
ZodMessageHandler,
ZodMessageSender,
} from "@trigger.dev/core/v3";
import { HttpReply, SimpleLogger, getTextBody, getRandomPortNumber } from "@trigger.dev/core-apps";
const HTTP_SERVER_PORT = Number(process.env.HTTP_SERVER_PORT || getRandomPortNumber());
const MACHINE_NAME = process.env.MACHINE_NAME || "local";
const PLATFORM_HOST = process.env.PLATFORM_HOST || "127.0.0.1";
const PLATFORM_WS_PORT = process.env.PLATFORM_WS_PORT || 3030;
const PLATFORM_SECRET = process.env.PLATFORM_SECRET || "provider-secret";
const COORDINATOR_PORT = process.env.COORDINATOR_PORT || 8020;
const logger = new SimpleLogger(`[${MACHINE_NAME}]`);
interface TaskOperations {
create: (...args: any[]) => Promise<any>;
restore: (...args: any[]) => Promise<any>;
delete: (...args: any[]) => Promise<any>;
get: (...args: any[]) => Promise<any>;
index: (...args: any[]) => Promise<any>;
}
class DockerTaskOperations implements TaskOperations {
async index(opts: { contentHash: string; imageTag: string; envId: string }) {
const containerName = this.#getIndexContainerName(opts.contentHash);
const { exitCode } = logger.debug(
await $`docker run --rm -e COORDINATOR_PORT=${COORDINATOR_PORT} -e POD_NAME=${containerName} -e TRIGGER_ENV_ID=${opts.envId} -e INDEX_TASKS=true --network=host --pull=never --name=${containerName} ${opts.imageTag}`
);
if (exitCode !== 0) {
throw new Error("docker run command failed");
}
}
async create(opts: { attemptId: string; image: string; machine: Machine; envId: string }) {
const containerName = this.#getRunContainerName(opts.attemptId);
const { exitCode } = logger.debug(
await $`docker run -d -e COORDINATOR_PORT=${COORDINATOR_PORT} -e POD_NAME=${containerName} -e TRIGGER_ENV_ID=${opts.envId} -e TRIGGER_ATTEMPT_ID=${opts.attemptId} --network=host --pull=never --name=${containerName} ${opts.image}`
);
if (exitCode !== 0) {
throw new Error("docker run command failed");
}
}
async restore(opts: {
attemptId: string;
runId: string;
image: string;
name: string;
checkpointId: string;
machine: Machine;
}) {
const containerName = this.#getRunContainerName(opts.attemptId);
const { exitCode } = logger.debug(
await $`docker start --checkpoint=${opts.checkpointId} ${containerName}`
);
if (exitCode !== 0) {
throw new Error("docker start command failed");
}
}
async delete(opts: { runId: string }) {
logger.log("noop: delete");
}
async get(opts: { runId: string }) {
logger.log("noop: get");
}
#getIndexContainerName(contentHash: string) {
return `task-index-${contentHash}`;
}
#getRunContainerName(attemptId: string) {
return `task-run-${attemptId}`;
}
}
interface Provider {
tasks: TaskOperations;
}
type DockerProviderOptions = {
tasks: DockerTaskOperations;
host?: string;
port: number;
};
class DockerProvider implements Provider {
tasks: DockerTaskOperations;
#httpServer: ReturnType<typeof createServer>;
#platformSocket: Socket<ProviderServerToClientEvents, ProviderClientToServerEvents>;
constructor(private options: DockerProviderOptions) {
this.tasks = options.tasks;
this.#httpServer = this.#createHttpServer();
this.#platformSocket = this.#createPlatformSocket();
this.#createSharedQueueSocket();
}
#createSharedQueueSocket() {
const socket: Socket<
MessageCatalogToSocketIoEvents<typeof serverWebsocketMessages>,
MessageCatalogToSocketIoEvents<typeof clientWebsocketMessages>
> = io(`ws://${PLATFORM_HOST}:${PLATFORM_WS_PORT}/shared-queue`, {
transports: ["websocket"],
auth: {
token: PLATFORM_SECRET,
},
});
const logger = new SimpleLogger(`[shared-queue][${socket.id ?? "NO_ID"}]`);
socket.on("connect_error", (err) => {
logger.error(`connect_error: ${err.message}`);
});
socket.on("connect", () => {
logger.log("connect");
});
socket.on("disconnect", () => {
logger.log("disconnect");
});
const sender = new ZodMessageSender({
schema: clientWebsocketMessages,
sender: async (message) => {
return new Promise((resolve, reject) => {
try {
const { type, ...payload } = message;
socket.emit(type, payload as any);
resolve();
} catch (err) {
reject(err);
}
});
},
});
const handler = new ZodMessageHandler({
schema: serverWebsocketMessages,
messages: {
SERVER_READY: async (payload) => {
logger.log("received SERVER_READY", payload);
// TODO: create new schema without worker requirement
await sender.send("READY_FOR_TASKS", {
backgroundWorkerId: "placeholder",
});
},
BACKGROUND_WORKER_MESSAGE: async (payload) => {
logger.log("received BACKGROUND_WORKER_MESSAGE", payload);
if (payload.data.type === "SCHEDULE_ATTEMPT") {
this.tasks.create({
envId: payload.data.envId,
attemptId: payload.data.id,
image: payload.data.image,
machine: {},
});
}
},
},
});
handler.registerHandlers(socket, logger.log.bind(logger));
return socket;
}
#createPlatformSocket() {
const socket: Socket<ProviderServerToClientEvents, ProviderClientToServerEvents> = io(
`ws://${PLATFORM_HOST}:${PLATFORM_WS_PORT}/provider`,
{
transports: ["websocket"],
auth: {
token: PLATFORM_SECRET,
},
extraHeaders: {
"x-trigger-provider-type": "docker",
},
}
);
const logger = new SimpleLogger(`[platform][${socket.id ?? "NO_ID"}]`);
socket.on("connect_error", (err) => {
logger.error(`connect_error: ${err.message}`);
});
socket.on("connect", () => {
logger.log("connect");
});
socket.on("disconnect", () => {
logger.log("disconnect");
});
socket.on("GET", async (message) => {
logger.log("[GET]", message);
this.tasks.get({ runId: message.name });
});
socket.on("DELETE", async (message, callback) => {
logger.log("[DELETE]", message);
callback({
message: "delete request received",
});
this.tasks.delete({ runId: message.name });
});
socket.on("INDEX", async (message) => {
logger.log("[INDEX]", message);
try {
await this.tasks.index({
contentHash: message.contentHash,
imageTag: message.imageTag,
envId: message.envId,
});
} catch (error) {
logger.error("task index failed", error);
}
});
socket.on("RESTORE", async (message) => {
logger.log("[RESTORE]", message);
// await this.tasks.restore({});
});
socket.on("HEALTH", async (message) => {
logger.log("[HEALTH]", message);
});
return socket;
}
#createHttpServer() {
const httpServer = createServer(async (req, res) => {
logger.log(`[${req.method}]`, req.url);
const reply = new HttpReply(res);
switch (req.url) {
case "/health": {
return reply.text("ok");
}
case "/whoami": {
return reply.text(`${MACHINE_NAME}`);
}
case "/close": {
this.#platformSocket.close();
return reply.text("platform socket closed");
}
case "/delete": {
const body = await getTextBody(req);
await this.tasks.delete({ runId: body });
return reply.text(`sent delete request: ${body}`);
}
case "/invoke": {
const body = await getTextBody(req);
await this.tasks.create({
attemptId: body,
envId: "placeholder",
image: body,
machine: {
cpu: "1",
memory: "100Mi",
},
});
return reply.text(`sent restore request: ${body}`);
}
case "/restore": {
const body = await getTextBody(req);
const items = body.split("&");
const image = items[0];
const baseImageTag = items[1] ?? image;
// await this.tasks.restore({});
return reply.text(`sent restore request: ${body}`);
}
default: {
return reply.empty(404);
}
}
});
httpServer.on("clientError", (err, socket) => {
socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
});
httpServer.on("listening", () => {
logger.log("server listening on port", this.options.port);
});
return httpServer;
}
listen() {
this.#httpServer.listen(this.options.port, this.options.host ?? "0.0.0.0");
}
}
const provider = new DockerProvider({
port: HTTP_SERVER_PORT,
tasks: new DockerTaskOperations(),
});
provider.listen();
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"paths": {
"@trigger.dev/core/v3": ["../core/src/v3"],
"@trigger.dev/core/v3/*": ["../core/src/v3/*"],
"@trigger.dev/core-apps": ["../core-apps/src"],
"@trigger.dev/core-apps/*": ["../core-apps/src/*"]
}
}
}
+7
View File
@@ -0,0 +1,7 @@
HTTP_SERVER_PORT=8060
PLATFORM_WS_PORT=8003
PLATFORM_SECRET=provider-secret
REGISTRY_FQDN=docker.io
REPO_NAME=task
+3
View File
@@ -0,0 +1,3 @@
dist/
node_modules/
.env
+48
View File
@@ -0,0 +1,48 @@
FROM node:18-alpine@sha256:ca9f6cb0466f9638e59e0c249d335a07c867cd50c429b5c7830dda1bed584649 AS node-18-alpine
WORKDIR /app
FROM node-18-alpine AS pruner
COPY --chown=node:node . .
RUN npx -q turbo@1.10.9 prune --scope=kubernetes-provider --docker
RUN find . -name "node_modules" -type d -prune -exec rm -rf '{}' +
FROM node-18-alpine AS base
RUN apk add --no-cache dumb-init
COPY --chown=node:node .gitignore .gitignore
COPY --from=pruner --chown=node:node /app/out/json/ .
COPY --from=pruner --chown=node:node /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
COPY --from=pruner --chown=node:node /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
FROM base AS dev-deps
RUN corepack enable
ENV NODE_ENV development
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm fetch --no-frozen-lockfile
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --ignore-scripts --no-frozen-lockfile
FROM base AS builder
RUN corepack enable
COPY --from=pruner --chown=node:node /app/out/full/ .
COPY --from=dev-deps --chown=node:node /app/ .
COPY --chown=node:node turbo.json turbo.json
RUN pnpm run -r --filter '@trigger.dev/core*' build
RUN pnpm run -r --filter kubernetes-provider build:bundle
FROM base AS runner
RUN corepack enable
ENV NODE_ENV production
COPY --from=builder --chown=node:node /app/apps/kubernetes-provider/dist/index.cjs ./index.cjs
EXPOSE 8000
USER node
CMD [ "/usr/bin/dumb-init", "--", "/usr/local/bin/node", "./index.cjs" ]
+3
View File
@@ -0,0 +1,3 @@
# Kubernetes provider
The `kubernetes-provider` allows the platform to be orchestrator-agnostic. The platform can perform actions such as `INDEX_TASKS` or `INVOKE_TASK` which the provider translates into Kubernetes actions.
+31
View File
@@ -0,0 +1,31 @@
{
"name": "kubernetes-provider",
"private": true,
"version": "0.0.1",
"description": "",
"main": "dist/index.cjs",
"type": "module",
"scripts": {
"build": "npm run build:bundle",
"build:bundle": "esbuild src/index.ts --bundle --outfile=dist/index.cjs --platform=node",
"build:image": "docker build -f Containerfile . -t kubernetes-provider",
"dev": "tsx --no-warnings=ExperimentalWarning --require dotenv/config --watch src/index.ts",
"start": "tsx src/index.ts",
"typecheck": "tsc --noEmit"
},
"keywords": [],
"author": "",
"license": "MIT",
"dependencies": {
"@kubernetes/client-node": "^0.20.0",
"@trigger.dev/core": "workspace:*",
"@trigger.dev/core-apps": "workspace:*",
"socket.io-client": "^4.7.4"
},
"devDependencies": {
"dotenv": "^16.4.2",
"esbuild": "^0.19.11",
"tsx": "^4.7.0",
"typescript": "^5.3.3"
}
}
+561
View File
@@ -0,0 +1,561 @@
import { randomUUID } from "node:crypto";
import { createServer } from "node:http";
import k8s, { BatchV1Api, CoreV1Api, V1Job, V1Pod } from "@kubernetes/client-node";
import { io, Socket } from "socket.io-client";
import {
Machine,
ProviderClientToServerEvents,
ProviderServerToClientEvents,
} from "@trigger.dev/core/v3";
import { HttpReply, SimpleLogger, getTextBody } from "@trigger.dev/core-apps";
const RUNTIME_ENV = process.env.KUBERNETES_PORT ? "kubernetes" : "local";
const HTTP_SERVER_PORT = Number(process.env.HTTP_SERVER_PORT || 8000);
const NODE_NAME = process.env.NODE_NAME || "some-node";
const POD_NAME = process.env.POD_NAME || "k8s-provider";
const PLATFORM_HOST = process.env.PLATFORM_HOST || "127.0.0.1";
const PLATFORM_WS_PORT = process.env.PLATFORM_WS_PORT || 5080;
const PLATFORM_SECRET = process.env.PLATFORM_SECRET || "provider-secret";
const REGISTRY_FQDN = process.env.REGISTRY_FQDN || "localhost:5000";
const REPO_NAME = process.env.REPO_NAME || "test";
const logger = new SimpleLogger(`[${NODE_NAME}]`);
type Namespace = {
metadata: {
name: string;
};
};
interface TaskOperations {
create: (...args: any[]) => Promise<any>;
restore: (...args: any[]) => Promise<any>;
delete: (...args: any[]) => Promise<any>;
get: (...args: any[]) => Promise<any>;
index: (...args: any[]) => Promise<any>;
}
class KubernetesTaskOperations implements TaskOperations {
#namespace: Namespace;
#k8sApi: {
core: CoreV1Api;
batch: BatchV1Api;
};
constructor(namespace = "default") {
this.#namespace = {
metadata: {
name: namespace,
},
};
this.#k8sApi = this.#createK8sApi();
}
async index(opts: { contentHash: string; imageTag: string }) {
await this.#createJob(
{
metadata: {
name: `task-index-${opts.contentHash}`,
namespace: this.#namespace.metadata.name,
},
spec: {
completions: 1,
template: {
metadata: {
labels: {
app: "task-index",
},
},
spec: {
restartPolicy: "Never",
imagePullSecrets: [
{
name: "registry-trigger",
},
],
containers: [
{
name: opts.contentHash,
image: opts.imageTag,
ports: [
{
containerPort: 8000,
},
],
resources: {
limits: {
cpu: "100m",
memory: "50Mi",
},
},
env: [
{
name: "DEBUG",
value: "true",
},
{
name: "INDEX_TASKS",
value: "true",
},
{
name: "HTTP_SERVER_PORT",
value: "8000",
},
{
name: "POD_NAME",
valueFrom: {
fieldRef: {
fieldPath: "metadata.name",
},
},
},
{
name: "COORDINATOR_HOST",
valueFrom: {
fieldRef: {
fieldPath: "status.hostIP",
},
},
},
{
name: "MACHINE_NAME",
valueFrom: {
fieldRef: {
fieldPath: "spec.nodeName",
},
},
},
],
},
],
},
},
},
},
this.#namespace
);
}
async create(opts: { runId: string; image: string; machine: Machine }) {
await this.#createPod(
{
metadata: {
name: `${opts.runId}-${randomUUID().slice(0, 5)}`,
namespace: this.#namespace.metadata.name,
},
spec: {
restartPolicy: "Never",
containers: [
{
name: opts.runId,
image: this.#getImageFromRunId(opts.runId),
ports: [
{
containerPort: 8000,
},
],
// resources: {
// limits: opts.machine,
// },
env: [
{
name: "DEBUG",
value: "true",
},
{
name: "POD_NAME",
valueFrom: {
fieldRef: {
fieldPath: "metadata.name",
},
},
},
{
name: "COORDINATOR_HOST",
valueFrom: {
fieldRef: {
fieldPath: "status.hostIP",
},
},
},
{
name: "NODE_NAME",
valueFrom: {
fieldRef: {
fieldPath: "spec.nodeName",
},
},
},
],
},
],
},
},
this.#namespace
);
}
async restore(opts: {
runId: string;
image: string;
name: string;
checkpointId: string;
machine: Machine;
}) {
await this.#createPod(
{
metadata: {
name: opts.name,
namespace: this.#namespace.metadata.name,
},
spec: {
imagePullSecrets: [
{
name: "regcred",
},
],
initContainers: [
{
name: "pull-base-image",
image: this.#getRestoreImage(opts.runId, opts.checkpointId),
command: ["sleep", "0"],
},
],
containers: [
{
name: opts.runId,
image: this.#getImageFromRunId(opts.runId),
ports: [
{
containerPort: 8000,
},
],
// resources: {
// limits: opts.machine,
// },
lifecycle: {
postStart: {
httpGet: {
path: "/connect",
port: 8000,
},
},
},
env: [
{
name: "DEBUG",
value: "true",
},
{
name: "POD_NAME",
valueFrom: {
fieldRef: {
fieldPath: "metadata.name",
},
},
},
{
name: "COORDINATOR_HOST",
valueFrom: {
fieldRef: {
fieldPath: "status.hostIP",
},
},
},
{
name: "NODE_NAME",
valueFrom: {
fieldRef: {
fieldPath: "spec.nodeName",
},
},
},
],
},
],
},
},
this.#namespace
);
}
async delete(opts: { runId: string }) {
await this.#deletePod({
podName: opts.runId,
namespace: this.#namespace,
});
}
async get(opts: { runId: string }) {
await this.#getPod(opts.runId, this.#namespace);
}
#getImageFromRunId(runId: string) {
return `${REGISTRY_FQDN}/${REPO_NAME}:${runId}`;
}
#getRestoreImage(runId: string, checkpointId: string) {
return `${REGISTRY_FQDN}/${REPO_NAME}:${checkpointId}`;
}
#createK8sApi() {
const kubeConfig = new k8s.KubeConfig();
if (RUNTIME_ENV === "local") {
kubeConfig.loadFromDefault();
} else if (RUNTIME_ENV === "kubernetes") {
kubeConfig.loadFromCluster();
} else {
throw new Error(`Unsupported runtime environment: ${RUNTIME_ENV}`);
}
return {
core: kubeConfig.makeApiClient(k8s.CoreV1Api),
batch: kubeConfig.makeApiClient(k8s.BatchV1Api),
};
}
async #createPod(pod: V1Pod, namespace: Namespace) {
try {
const res = await this.#k8sApi.core.createNamespacedPod(namespace.metadata.name, pod);
logger.debug(res.body);
} catch (err: any) {
if ("body" in err) {
logger.error(err.body);
} else {
logger.error(err);
}
}
}
async #deletePod(opts: { podName: string; namespace: Namespace }) {
try {
const res = await this.#k8sApi.core.deleteNamespacedPod(
opts.podName,
opts.namespace.metadata.name
);
logger.debug(res.body);
} catch (err: any) {
if ("body" in err) {
logger.error(err.body);
} else {
logger.error(err);
}
}
}
async #getPod(podName: string, namespace: Namespace) {
try {
const res = await this.#k8sApi.core.readNamespacedPod(podName, namespace.metadata.name);
logger.debug(res.body);
return res.body;
} catch (err: any) {
if ("body" in err) {
logger.error(err.body);
} else {
logger.error(err);
}
}
}
async #createJob(job: V1Job, namespace: Namespace) {
try {
const res = await this.#k8sApi.batch.createNamespacedJob(namespace.metadata.name, job);
logger.debug(res.body);
} catch (err: any) {
if ("body" in err) {
logger.error(err.body);
} else {
logger.error(err);
}
}
}
}
interface Provider {
tasks: TaskOperations;
}
type KubernetesProviderOptions = {
tasks: KubernetesTaskOperations;
host?: string;
port: number;
};
class KubernetesProvider implements Provider {
tasks: KubernetesTaskOperations;
#httpServer: ReturnType<typeof createServer>;
#platformSocket: Socket<ProviderServerToClientEvents, ProviderClientToServerEvents>;
constructor(private options: KubernetesProviderOptions) {
this.tasks = options.tasks;
this.#httpServer = this.#createHttpServer();
this.#platformSocket = this.#createPlatformSocket();
}
#createPlatformSocket() {
const socket: Socket<ProviderServerToClientEvents, ProviderClientToServerEvents> = io(
`ws://${PLATFORM_HOST}:${PLATFORM_WS_PORT}/provider`,
{
transports: ["websocket"],
auth: {
token: PLATFORM_SECRET,
},
extraHeaders: {
"x-trigger-provider-type": "kubernetes",
},
}
);
const logger = new SimpleLogger(`[platform][${socket.id ?? "NO_ID"}]`);
socket.on("connect_error", (err) => {
logger.error(`connect_error: ${err.message}`);
});
socket.on("connect", () => {
logger.log("connect");
});
socket.on("disconnect", () => {
logger.log("disconnect");
});
socket.on("GET", async (message) => {
logger.log("[GET]", message);
this.tasks.get({ runId: message.name });
});
socket.on("DELETE", async (message, callback) => {
logger.log("[DELETE]", message);
callback({
message: "delete request received",
});
this.tasks.delete({ runId: message.name });
});
socket.on("INDEX", async (message) => {
logger.log("[INDEX]", message);
await this.tasks.index({
contentHash: message.contentHash,
imageTag: message.imageTag,
});
});
socket.on("INVOKE", async (message) => {
logger.log("[INVOKE]", message);
await this.tasks.create({
runId: message.name,
image: message.name,
machine: message.machine,
});
});
socket.on("RESTORE", async (message) => {
logger.log("[RESTORE]", message);
// await this.tasks.restore({});
});
socket.on("HEALTH", async (message) => {
logger.log("[HEALTH]", message);
});
return socket;
}
#createHttpServer() {
const httpServer = createServer(async (req, res) => {
logger.log(`[${req.method}]`, req.url);
const reply = new HttpReply(res);
switch (req.url) {
case "/health": {
return reply.text("ok");
}
case "/whoami": {
return reply.text(`${POD_NAME}`);
}
case "/close": {
this.#platformSocket.close();
return reply.text("platform socket closed");
}
case "/delete": {
const body = await getTextBody(req);
await this.tasks.delete({ runId: body });
return reply.text(`sent delete request: ${body}`);
}
case "/invoke": {
const body = await getTextBody(req);
await this.tasks.create({
runId: body,
image: body,
machine: {
cpu: "1",
memory: "100Mi",
},
});
return reply.text(`sent restore request: ${body}`);
}
case "/restore": {
const body = await getTextBody(req);
const items = body.split("&");
const image = items[0];
const baseImageTag = items[1] ?? image;
await this.tasks.restore({
runId: image,
name: `${image}-restore`,
image,
checkpointId: baseImageTag,
machine: {
cpu: "1",
memory: "100Mi",
},
});
return reply.text(`sent restore request: ${body}`);
}
default: {
return reply.empty(404);
}
}
});
httpServer.on("clientError", (err, socket) => {
socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
});
httpServer.on("listening", () => {
logger.log("server listening on port", this.options.port);
});
return httpServer;
}
listen() {
this.#httpServer.listen(this.options.port, this.options.host ?? "0.0.0.0");
}
}
const provider = new KubernetesProvider({
port: HTTP_SERVER_PORT,
tasks: new KubernetesTaskOperations(),
});
provider.listen();
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"paths": {
"@trigger.dev/core/v3": ["../core/src/v3"],
"@trigger.dev/core/v3/*": ["../core/src/v3/*"],
"@trigger.dev/core-apps": ["../core-apps/src"],
"@trigger.dev/core-apps/*": ["../core-apps/src/*"]
}
}
}
+1
View File
@@ -196,3 +196,4 @@ function logError(error: unknown, request?: Request) {
const sqsEventConsumer = singleton("sqsEventConsumer", getSharedSqsEventConsumer);
export { wss } from "./v3/handleWebsockets.server";
export { socketIo } from "./v3/handleSocketIo.server";
+4
View File
@@ -72,6 +72,10 @@ const EnvironmentSchema = z.object({
V3_ENABLED: z.string().default("false"),
OTLP_EXPORTER_TRACES_URL: z.string().optional(),
LOG_TELEMETRY: z.string().default("true"),
IMAGE_REGISTRY: z.string().default("docker.io"),
IMAGE_REPO: z.string().default("task"),
PROVIDER_SECRET: z.string().default("provider-secret"),
COORDINATOR_SECRET: z.string().default("coordinator-secret"),
});
export type Environment = z.infer<typeof EnvironmentSchema>;
@@ -0,0 +1,54 @@
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { CreateImageDetailsRequestBody } from "@trigger.dev/core/v3";
import { z } from "zod";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { CreateImageDetailsService } from "~/v3/services/createImageDetails.server";
const ParamsSchema = z.object({
projectRef: z.string(),
});
export async function action({ request, params }: ActionFunctionArgs) {
// Ensure this is a POST request
if (request.method.toUpperCase() !== "POST") {
return { status: 405, body: "Method Not Allowed" };
}
const parsedParams = ParamsSchema.safeParse(params);
if (!parsedParams.success) {
return json({ error: "Invalid params" }, { status: 400 });
}
// Next authenticate the request
const authenticationResult = await authenticateApiRequest(request);
if (!authenticationResult) {
logger.info("Invalid or missing api key", { url: request.url });
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
const authenticatedEnv = authenticationResult.environment;
const { projectRef } = parsedParams.data;
const rawBody = await request.json();
const body = CreateImageDetailsRequestBody.safeParse(rawBody);
if (!body.success) {
return json({ error: "Invalid body", issues: body.error.issues }, { status: 400 });
}
const service = new CreateImageDetailsService();
const imageDetails = await service.call(projectRef, authenticatedEnv, body.data);
return json(
{
id: imageDetails.friendlyId,
contentHash: imageDetails.contentHash,
},
{ status: 200 }
);
}
@@ -0,0 +1,62 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { GetProjectDevResponse } from "@trigger.dev/core/v3";
import { z } from "zod";
import { prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
const ParamsSchema = z.object({
projectRef: z.string(),
});
export async function loader({ request, params }: LoaderFunctionArgs) {
logger.info("projects get prod env", { url: request.url });
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
}
const parsedParams = ParamsSchema.safeParse(params);
if (!parsedParams.success) {
return json({ error: "Invalid Params" }, { status: 400 });
}
const projectRef = parsedParams.data.projectRef;
const project = await prisma.project.findUnique({
where: {
externalRef: projectRef,
organization: {
members: {
some: {
userId: authenticationResult.userId,
},
},
},
},
include: {
environments: {
where: {
slug: "prod",
},
},
},
});
if (!project) {
return json({ error: "Project not found" }, { status: 404 });
}
const prodEnvironment = project.environments[0];
const result: GetProjectDevResponse = {
apiKey: prodEnvironment.apiKey,
name: project.name,
};
return json(result);
}
+15
View File
@@ -29,6 +29,7 @@ import { InvokeEphemeralDispatcherService } from "./dispatchers/invokeEphemeralE
import { ResumeRunService } from "./runs/resumeRun.server";
import { executionRateLimiter } from "./runExecutionRateLimiter.server";
import { DeliverWebhookRequestService } from "./sources/deliverWebhookRequest.server";
import { IndexTasksService } from "~/v3/services/indexTasks.server";
const workerCatalog = {
indexEndpoint: z.object({
@@ -102,6 +103,10 @@ const workerCatalog = {
resumeRun: z.object({
id: z.string(),
}),
// v3 tasks
indexTasks: z.object({
id: z.string(),
}),
};
const executionWorkerCatalog = {
@@ -425,6 +430,16 @@ function getWorkerQueue() {
handler: async (payload, job) => {
const service = new ResumeRunService();
return await service.call(payload.id);
},
},
// v3 tasks
indexTasks: {
priority: 0,
maxAttempts: 5,
handler: async (payload, job) => {
const service = new IndexTasksService();
return await service.call(payload.id);
},
},
@@ -4,6 +4,7 @@ import { $transaction, prisma } from "~/db.server";
import { getSecretStore } from "~/services/secrets/secretStore.server";
import { generateFriendlyId } from "../friendlyIdentifiers";
import { EnvironmentVariable, ProjectEnvironmentVariable, Repository, Result } from "./repository";
import { env } from "~/env.server";
function secretKeyProjectPrefix(projectId: string) {
return `environmentvariable:${projectId}:`;
@@ -402,7 +403,34 @@ export class EnvironmentVariablesRepository implements Repository {
return this.getEnvironmentVariables(projectId, environmentId);
}
async getEnvironmentVariables(
async #getTriggerEnvironmentVariables(environmentId: string): Promise<EnvironmentVariable[]> {
const environment = await this.prismaClient.runtimeEnvironment.findFirst({
where: {
id: environmentId,
},
});
if (!environment) {
return [];
}
if (environment.type === "DEVELOPMENT") {
return [];
}
return [
{
key: "TRIGGER_API_KEY",
value: environment.apiKey,
},
{
key: "TRIGGER_API_URL",
value: env.APP_ORIGIN,
},
];
}
async #getSecretEnvironmentVariables(
projectId: string,
environmentId: string
): Promise<EnvironmentVariable[]> {
@@ -424,6 +452,16 @@ export class EnvironmentVariablesRepository implements Repository {
});
}
async getEnvironmentVariables(
projectId: string,
environmentId: string
): Promise<EnvironmentVariable[]> {
const secretEnvVars = await this.#getSecretEnvironmentVariables(projectId, environmentId);
const triggerEnvVars = await this.#getTriggerEnvironmentVariables(environmentId);
return [...secretEnvVars, ...triggerEnvVars];
}
async delete(projectId: string, userId: string, options: { id: string }): Promise<Result> {
const project = await this.prismaClient.project.findUnique({
where: {
+132
View File
@@ -0,0 +1,132 @@
import {
CoordinatorToPlatformMessages,
PlatformToCoordinatorMessages,
PlatformToProviderMessages,
ProviderToPlatformMessages,
ZodNamespace,
clientWebsocketMessages,
serverWebsocketMessages,
} from "@trigger.dev/core/v3";
import { Server } from "socket.io";
import { env } from "~/env.server";
import { singleton } from "~/utils/singleton";
import { SharedSocketConnection } from "./sharedSocketConnection";
import { CreateCheckpointService } from "./services/createCheckpoint.server";
import { sharedQueueTasks } from "./marqs/sharedQueueConsumer.server";
import { CompleteAttemptService } from "./services/completeAttempt.server";
import { CreateBackgroundWorkerService } from "./services/createBackgroundWorker.server";
import { logger } from "~/services/logger.server";
import { findEnvironmentById } from "~/models/runtimeEnvironment.server";
export const socketIo = singleton("socketIo", initalizeIoServer);
function initalizeIoServer() {
const io = new Server();
io.on("connection", (socket) => {
console.log(`[socket.io][${socket.id}] connection at url: ${socket.request.url}`);
});
const coordinatorNamespace = createCoordinatorNamespace(io);
const providerNamespace = createProviderNamespace(io);
const sharedQueueConsumerNamespace = createSharedQueueConsumerNamespace(io);
return {
io,
coordinatorNamespace,
providerNamespace,
sharedQueueConsumerNamespace,
};
}
function createCoordinatorNamespace(io: Server) {
const coordinator = new ZodNamespace({
io,
name: "coordinator",
authToken: env.COORDINATOR_SECRET,
clientMessages: CoordinatorToPlatformMessages,
serverMessages: PlatformToCoordinatorMessages,
messageHandler: {
READY_FOR_EXECUTION: async (message) => {
const payload = await sharedQueueTasks.getExecutionPayloadFromAttempt(message.attemptId);
if (!payload) {
return { success: false };
} else {
return { success: true, payload };
}
},
TASK_RUN_COMPLETED: async (message) => {
const completeAttempt = new CompleteAttemptService();
await completeAttempt.call(message.completion, message.execution);
},
TASK_HEARTBEAT: async (message) => {
await sharedQueueTasks.taskHeartbeat(message.attemptFriendlyId);
},
CHECKPOINT_CREATED: async (message) => {
const createCheckpoint = new CreateCheckpointService();
await createCheckpoint.call(message);
},
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 createCheckpoint = new CreateBackgroundWorkerService();
await createCheckpoint.call(message.projectRef, environment, {
localOnly: true,
metadata: message.metadata,
});
return { success: true };
} catch (error) {
logger.error("Error while creating worker", { error });
return { success: false };
}
},
},
});
return coordinator.namespace;
}
function createProviderNamespace(io: Server) {
const provider = new ZodNamespace({
io,
name: "provider",
authToken: env.PROVIDER_SECRET,
clientMessages: ProviderToPlatformMessages,
serverMessages: PlatformToProviderMessages,
});
return provider.namespace;
}
function createSharedQueueConsumerNamespace(io: Server) {
const sharedQueue = new ZodNamespace({
io,
name: "shared-queue",
authToken: env.PROVIDER_SECRET,
clientMessages: clientWebsocketMessages,
serverMessages: serverWebsocketMessages,
onConnection: async (socket, handler, sender, logger) => {
const sharedSocketConnection = new SharedSocketConnection(
sharedQueue.namespace,
socket,
logger
);
sharedSocketConnection.onClose.attach((closeEvent) => {
logger("Socket closed", { closeEvent });
});
await sharedSocketConnection.initialize();
},
});
return sharedQueue.namespace;
}
+49 -5
View File
@@ -29,7 +29,7 @@ const constants = {
QUEUE_PART: "queue",
CONCURRENCY_KEY_PART: "ck",
MESSAGE_PART: "message",
};
} as const;
const MessagePayload = z.object({
version: z.literal("1"),
@@ -259,6 +259,50 @@ export class MarQS {
);
}
public async replaceMessage(
messageId: string,
messageData: Record<string, unknown>,
timestamp?: number
) {
return this.#trace(
"replaceMessage",
async (span) => {
const oldMessage = await this.#readMessage(messageId);
if (!oldMessage) {
return;
}
span.setAttributes({
[SemanticAttributes.QUEUE]: oldMessage.queue,
[SemanticAttributes.MESSAGE_ID]: oldMessage.messageId,
[SemanticAttributes.CONCURRENCY_KEY]: oldMessage.concurrencyKey,
[SemanticAttributes.PARENT_QUEUE]: oldMessage.parentQueue,
});
await this.#callAcknowledgeMessage({
messageKey: `${constants.MESSAGE_PART}:${messageId}`,
visibilityQueue: constants.MESSAGE_VISIBILITY_TIMEOUT_QUEUE,
concurrencyKey: `${oldMessage.queue}:${constants.CURRENT_CONCURRENCY_PART}`,
messageId,
});
const newMessage: MessagePayload = {
version: "1",
data: messageData,
queue: oldMessage.queue,
concurrencyKey: oldMessage.concurrencyKey,
timestamp: timestamp ?? Date.now(),
messageId,
parentQueue: oldMessage.parentQueue,
};
await this.#callEnqueueMessage(newMessage);
},
{ kind: SpanKind.CONSUMER }
);
}
async #trace<T>(
name: string,
fn: (span: Span, abort: () => void) => Promise<T>,
@@ -783,9 +827,9 @@ local currentTime = tonumber(ARGV[3])
local messageScore = tonumber(ARGV[4])
-- Check to see if the message is still in the visibilityQueue
local messageVisibility = redis.call('ZSCORE', visibilityQueue, messageId)
local messageVisibility = tonumber(redis.call('ZSCORE', visibilityQueue, messageId)) or 0
if messageVisibility == nil then
if messageVisibility == 0 then
return
end
@@ -820,9 +864,9 @@ local milliseconds = tonumber(ARGV[2])
local maxVisibilityTimeout = tonumber(ARGV[3])
-- Get the current visibility timeout
local currentVisibilityTimeout = redis.call('ZSCORE', visibilityQueue, messageId)
local currentVisibilityTimeout = tonumber(redis.call('ZSCORE', visibilityQueue, messageId)) or 0
if currentVisibilityTimeout == nil then
if currentVisibilityTimeout == 0 then
return
end
@@ -0,0 +1,851 @@
import { Context, ROOT_CONTEXT, Span, SpanKind, context, trace } from "@opentelemetry/api";
import {
ProdTaskRunExecution,
ProdTaskRunExecutionPayload,
TaskRunError,
TaskRunExecution,
TaskRunExecutionResult,
TaskRunFailedExecutionResult,
TaskRunSuccessfulExecutionResult,
ZodMessageSender,
serverWebsocketMessages,
} from "@trigger.dev/core/v3";
import { BackgroundWorker, BackgroundWorkerTask } from "@trigger.dev/database";
import { z } from "zod";
import { prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { generateFriendlyId } from "../friendlyIdentifiers";
import { marqs } from "../marqs.server";
import { EnvironmentVariablesRepository } from "../environmentVariables/environmentVariablesRepository.server";
import { CancelAttemptService } from "../services/cancelAttempt.server";
import { socketIo } from "../handleSocketIo.server";
import { singleton } from "~/utils/singleton";
const tracer = trace.getTracer("sharedQueueConsumer");
const MessageBody = z.discriminatedUnion("type", [
z.object({
type: z.literal("EXECUTE"),
taskIdentifier: z.string(),
}),
z.object({
type: z.literal("RESUME"),
completedAttemptIds: z.string().array(),
}),
]);
type BackgroundWorkerWithTasks = BackgroundWorker & { tasks: BackgroundWorkerTask[] };
export type SharedQueueConsumerOptions = {
maximumItemsPerTrace?: number;
traceTimeoutSeconds?: number;
};
export class SharedQueueConsumer {
private _backgroundWorkers: Map<string, BackgroundWorkerWithTasks> = new Map();
private _deprecatedWorkers: Map<string, BackgroundWorkerWithTasks> = new Map();
private _enabled = false;
private _options: Required<SharedQueueConsumerOptions>;
private _perTraceCountdown: number | undefined;
private _lastNewTrace: Date | undefined;
private _currentSpanContext: Context | undefined;
private _taskFailures: number = 0;
private _taskSuccesses: number = 0;
private _currentSpan: Span | undefined;
private _endSpanInNextIteration = false;
private _tasks = sharedQueueTasks;
private _inProgressAttempts: Map<string, string> = new Map(); // Keys are task attempt friendly IDs, values are TaskRun ids/queue message ids
constructor(
private _sender: ZodMessageSender<typeof serverWebsocketMessages>,
options: SharedQueueConsumerOptions = {}
) {
this._options = {
maximumItemsPerTrace: options.maximumItemsPerTrace ?? 1_000, // 1k items per trace
traceTimeoutSeconds: options.traceTimeoutSeconds ?? 60, // 60 seconds
};
}
// This method is called when a background worker is deprecated and will no longer be used unless a run is locked to it
public async deprecateBackgroundWorker(id: string) {
const backgroundWorker = this._backgroundWorkers.get(id);
if (!backgroundWorker) {
return;
}
this._deprecatedWorkers.set(id, backgroundWorker);
this._backgroundWorkers.delete(id);
}
public async registerBackgroundWorker(id: string, envId?: string) {
if (!envId) {
logger.error("Environment ID is required for background worker registration", {
backgroundWorkerId: id,
});
return;
}
const backgroundWorker = await prisma.backgroundWorker.findUnique({
where: {
friendlyId: id,
runtimeEnvironmentId: envId,
},
include: {
tasks: true,
},
});
if (!backgroundWorker) {
return;
}
this._backgroundWorkers.set(backgroundWorker.id, backgroundWorker);
logger.debug("Registered background worker", { backgroundWorker: backgroundWorker.id });
// Start reading from the queue if we haven't already
this.#enable();
}
public async start() {
this.#enable();
}
public async stop(reason: string = "Provider disconnected") {
if (!this._enabled) {
return;
}
logger.debug("Stopping shared queue consumer");
this._enabled = false;
// TODO: think about automatic prod cancellation
// We need to cancel all the in progress task run attempts and ack the messages so they will stop processing
// await this.#cancelInProgressAttempts(reason);
}
async #cancelInProgressAttempts(reason: string) {
const service = new CancelAttemptService();
const cancelledAt = new Date();
const inProgressAttempts = new Map(this._inProgressAttempts);
this._inProgressAttempts.clear();
for (const [attemptId, messageId] of inProgressAttempts) {
await this.#cancelInProgressAttempt(attemptId, messageId, service, cancelledAt, reason);
}
}
async #cancelInProgressAttempt(
attemptId: string,
messageId: string,
cancelAttemptService: CancelAttemptService,
cancelledAt: Date,
reason: string
) {
try {
await cancelAttemptService.call(attemptId, messageId, cancelledAt, reason);
} catch (e) {
logger.error("Failed to cancel in progress attempt", {
attemptId,
messageId,
error: e,
});
}
}
#enable() {
if (this._enabled) {
return;
}
this._enabled = true;
this._perTraceCountdown = this._options.maximumItemsPerTrace;
this._lastNewTrace = new Date();
this._taskFailures = 0;
this._taskSuccesses = 0;
this.#doWork().finally(() => {});
}
async #doWork() {
if (!this._enabled) {
return;
}
// Check if the trace has expired
if (
this._perTraceCountdown === 0 ||
Date.now() - this._lastNewTrace!.getTime() > this._options.traceTimeoutSeconds * 1000 ||
this._currentSpanContext === undefined ||
this._endSpanInNextIteration
) {
if (this._currentSpan) {
this._currentSpan.setAttribute("tasks.period.failures", this._taskFailures);
this._currentSpan.setAttribute("tasks.period.successes", this._taskSuccesses);
this._currentSpan.end();
}
// Create a new trace
this._currentSpan = tracer.startSpan(
"SharedQueueConsumer.doWork()",
{
kind: SpanKind.CONSUMER,
},
ROOT_CONTEXT
);
// Get the span trace context
this._currentSpanContext = trace.setSpan(ROOT_CONTEXT, this._currentSpan);
this._perTraceCountdown = this._options.maximumItemsPerTrace;
this._lastNewTrace = new Date();
this._taskFailures = 0;
this._taskSuccesses = 0;
this._endSpanInNextIteration = false;
}
return context.with(this._currentSpanContext ?? ROOT_CONTEXT, async () => {
await this.#doWorkInternal();
this._perTraceCountdown = this._perTraceCountdown! - 1;
});
}
async #doWorkInternal() {
// Attempt to dequeue a message from the shared queue
// If no message is available, reschedule the worker to run again in 1 second
// If a message is available, find the BackgroundWorkerTask that matches the message's taskIdentifier
// If no matching task is found, nack the message and reschedule the worker to run again in 1 second
// If the matching task is found, create the task attempt and lock the task run, then send the task run to the client
// Store the message as a processing message
// If the websocket connection disconnects before the task run is completed, nack the message
// When the task run completes, ack the message
// Using a heartbeat mechanism, if the client keeps responding with a heartbeat, we'll keep the message processing and increase the visibility timeout.
const message = await marqs?.dequeueMessageInSharedQueue();
if (!message) {
setTimeout(() => this.#doWork(), 1000);
return;
}
console.log("dequeueMessageInSharedQueue()", message);
const envId = this.#envIdFromQueue(message.queue);
const environment = await prisma.runtimeEnvironment.findUnique({
include: {
organization: true,
project: true,
},
where: {
id: envId,
},
});
if (!environment) {
logger.error("Environment not found", {
queueMessage: message.data,
envId,
});
await marqs?.acknowledgeMessage(message.messageId);
setTimeout(() => this.#doWork(), 100);
return;
}
const messageBody = MessageBody.safeParse(message.data);
if (!messageBody.success) {
logger.error("Failed to parse message", {
queueMessage: message.data,
error: messageBody.error,
env: environment,
});
await marqs?.acknowledgeMessage(message.messageId);
setTimeout(() => this.#doWork(), 100);
return;
}
switch (messageBody.data.type) {
case "EXECUTE": {
const existingTaskRun = await prisma.taskRun.findUnique({
where: {
id: message.messageId,
},
});
if (!existingTaskRun) {
logger.error("No existing task run", {
queueMessage: message.data,
messageId: message.messageId,
});
await marqs?.acknowledgeMessage(message.messageId);
setTimeout(() => this.#doWork(), 100);
return;
}
const backgroundWorker = await prisma.backgroundWorker.findFirst({
where: {
runtimeEnvironmentId: existingTaskRun.runtimeEnvironmentId,
projectId: existingTaskRun.projectId,
imageDetails: {
some: {},
},
},
orderBy: {
updatedAt: "desc",
},
include: {
tasks: true,
imageDetails: true,
},
});
if (!backgroundWorker) {
logger.error("No matching background worker found for task run", {
queueMessage: message.data,
messageId: message.messageId,
});
await marqs?.acknowledgeMessage(message.messageId);
setTimeout(() => this.#doWork(), 100);
return;
}
const backgroundTask = backgroundWorker.tasks.find(
(task) => task.slug === existingTaskRun.taskIdentifier
);
if (!backgroundTask) {
logger.warn("No matching background task found for task run", {
taskRun: existingTaskRun.id,
taskIdentifier: existingTaskRun.taskIdentifier,
backgroundWorker: backgroundWorker.id,
taskSlugs: backgroundWorker.tasks.map((task) => task.slug),
});
await marqs?.acknowledgeMessage(message.messageId);
setTimeout(() => this.#doWork(), 100);
return;
}
const lockedTaskRun = await prisma.taskRun.update({
where: {
id: message.messageId,
},
data: {
lockedAt: new Date(),
lockedById: backgroundTask.id,
},
include: {
attempts: {
take: 1,
orderBy: { number: "desc" },
},
tags: true,
},
});
if (!lockedTaskRun) {
logger.warn("Failed to lock task run", {
taskRun: existingTaskRun.id,
taskIdentifier: existingTaskRun.taskIdentifier,
backgroundWorker: backgroundWorker.id,
messageId: message.messageId,
});
await marqs?.acknowledgeMessage(message.messageId);
setTimeout(() => this.#doWork(), 100);
return;
}
const queue = await prisma.taskQueue.findUnique({
where: {
runtimeEnvironmentId_name: {
runtimeEnvironmentId: environment.id,
name: lockedTaskRun.queue,
},
},
});
if (!queue) {
await marqs?.nackMessage(message.messageId);
setTimeout(() => this.#doWork(), 1000);
return;
}
if (!this._enabled) {
await marqs?.nackMessage(message.messageId);
return;
}
const taskRunAttempt = await prisma.taskRunAttempt.create({
data: {
number: lockedTaskRun.attempts[0] ? lockedTaskRun.attempts[0].number + 1 : 1,
friendlyId: generateFriendlyId("attempt"),
taskRunId: lockedTaskRun.id,
startedAt: new Date(),
backgroundWorkerId: backgroundTask.workerId,
backgroundWorkerTaskId: backgroundTask.id,
status: "PENDING" as const,
queueId: queue.id,
},
});
try {
await this._sender.send("BACKGROUND_WORKER_MESSAGE", {
backgroundWorkerId: backgroundWorker.friendlyId,
data: {
type: "SCHEDULE_ATTEMPT",
id: taskRunAttempt.id,
image: backgroundWorker.imageDetails[0].tag,
envId: environment.id,
},
});
this._inProgressAttempts.set(taskRunAttempt.friendlyId, message.messageId);
} catch (e) {
if (e instanceof Error) {
this._currentSpan?.recordException(e);
} else {
this._currentSpan?.recordException(new Error(String(e)));
}
this._endSpanInNextIteration = true;
// We now need to unlock the task run and delete the task run attempt
await prisma.$transaction([
prisma.taskRun.update({
where: {
id: lockedTaskRun.id,
},
data: {
lockedAt: null,
lockedById: null,
},
}),
prisma.taskRunAttempt.delete({
where: {
id: taskRunAttempt.id,
},
}),
]);
// Finally we need to nack the message so it can be retried
await marqs?.nackMessage(message.messageId);
} finally {
setTimeout(() => this.#doWork(), 100);
}
break;
}
// Resume after dependency completed with no remaining retries
case "RESUME": {
if (messageBody.data.completedAttemptIds.length < 1) {
logger.error("No attempt IDs provided", {
queueMessage: message.data,
messageId: message.messageId,
});
await marqs?.acknowledgeMessage(message.messageId);
setTimeout(() => this.#doWork(), 100);
return;
}
const resumableRun = await prisma.taskRun.findFirst({
where: {
id: message.messageId,
},
include: {
attempts: {
orderBy: {
createdAt: "desc",
},
take: 1,
include: {
checkpoints: {
take: 1,
orderBy: {
createdAt: "desc",
},
},
},
},
},
});
const resumableAttempt = resumableRun?.attempts[0];
if (!resumableAttempt) {
logger.error("Task run attempt to resume not found", {
queueMessage: message.data,
messageId: message.messageId,
});
await marqs?.acknowledgeMessage(message.messageId);
setTimeout(() => this.#doWork(), 100);
return;
}
const backgroundWorker = await prisma.backgroundWorker.findFirst({
where: {
runtimeEnvironmentId: resumableRun.runtimeEnvironmentId,
projectId: resumableRun.projectId,
imageDetails: {
some: {},
},
},
orderBy: {
createdAt: "desc",
},
include: {
tasks: true,
imageDetails: {
take: 1,
orderBy: {
updatedAt: "desc",
},
},
},
});
if (!backgroundWorker) {
logger.error("No matching background worker found for task run", {
queueMessage: message.data,
messageId: message.messageId,
});
await marqs?.acknowledgeMessage(message.messageId);
setTimeout(() => this.#doWork(), 100);
return;
}
const backgroundTask = backgroundWorker.tasks.find(
(task) => task.slug === resumableRun.taskIdentifier
);
if (!backgroundTask) {
logger.warn("No matching background task found for task run", {
taskRun: resumableRun.id,
taskIdentifier: resumableRun.taskIdentifier,
backgroundWorker: backgroundWorker.id,
taskSlugs: backgroundWorker.tasks.map((task) => task.slug),
});
await marqs?.acknowledgeMessage(message.messageId);
setTimeout(() => this.#doWork(), 100);
return;
}
const queue = await prisma.taskQueue.findUnique({
where: {
runtimeEnvironmentId_name: {
runtimeEnvironmentId: environment.id,
name: resumableRun.queue,
},
},
});
if (!queue) {
await marqs?.nackMessage(message.messageId);
setTimeout(() => this.#doWork(), 1000);
return;
}
if (!this._enabled) {
await marqs?.nackMessage(message.messageId);
return;
}
const completions: TaskRunExecutionResult[] = [];
const executions: TaskRunExecution[] = [];
for (const completedAttemptId of messageBody.data.completedAttemptIds) {
const completedAttempt = await prisma.taskRunAttempt.findUnique({
where: {
id: completedAttemptId,
taskRun: {
lockedAt: {
not: null,
},
lockedById: {
not: null,
},
},
},
});
if (!completedAttempt) {
logger.error("Completed attempt not found", {
queueMessage: message.data,
messageId: message.messageId,
});
await marqs?.acknowledgeMessage(message.messageId);
setTimeout(() => this.#doWork(), 100);
return;
}
const completion = await this._tasks.getCompletionPayloadFromAttempt(completedAttempt.id);
if (!completion) {
await marqs?.acknowledgeMessage(message.messageId);
setTimeout(() => this.#doWork(), 100);
return;
}
completions.push(completion);
const executionPayload = await this._tasks.getExecutionPayloadFromAttempt(
completedAttempt.id,
false
);
if (!executionPayload) {
await marqs?.acknowledgeMessage(message.messageId);
setTimeout(() => this.#doWork(), 100);
return;
}
executions.push(executionPayload.execution);
}
try {
const latestCheckpoint = resumableAttempt.checkpoints[0];
if (!latestCheckpoint) {
// No checkpoint means the task should still be running
// We can broadcast to all coordinators to resume immediately
socketIo.coordinatorNamespace.emit("RESUME", {
version: "v1",
attemptId: resumableAttempt.id,
image: backgroundWorker.imageDetails[0].tag,
completions,
executions,
});
} else {
// There's a checkpoint we need to restore first
// TODO: Send RESUME message once the restored task has checked in
socketIo.providerNamespace.emit("RESTORE", {
version: "v1",
id: latestCheckpoint.id,
attemptId: latestCheckpoint.attemptId,
type: latestCheckpoint.type,
location: latestCheckpoint.location,
reason: latestCheckpoint.reason ?? undefined,
});
}
} catch (e) {
if (e instanceof Error) {
this._currentSpan?.recordException(e);
} else {
this._currentSpan?.recordException(new Error(String(e)));
}
this._endSpanInNextIteration = true;
// Finally we need to nack the message so it can be retried
await marqs?.nackMessage(message.messageId);
} finally {
setTimeout(() => this.#doWork(), 100);
}
break;
}
}
}
#envIdFromQueue(queueName: string) {
return queueName.split(":")[1];
}
}
class SharedQueueTasks {
async getCompletionPayloadFromAttempt(id: string): Promise<TaskRunExecutionResult | undefined> {
const attempt = await prisma.taskRunAttempt.findUnique({
where: {
id,
status: {
in: ["COMPLETED", "FAILED"],
},
},
include: {
backgroundWorker: true,
backgroundWorkerTask: true,
taskRun: {
include: {
runtimeEnvironment: {
include: {
organization: true,
project: true,
},
},
tags: true,
},
},
queue: true,
},
});
if (!attempt) {
logger.error("No completed attempt found", { id });
return;
}
const ok = attempt.status === "COMPLETED";
if (ok) {
const success: TaskRunSuccessfulExecutionResult = {
ok,
id: attempt.friendlyId,
output: attempt.output ?? "",
outputType: attempt.outputType,
};
return success;
} else {
const failure: TaskRunFailedExecutionResult = {
ok,
id: attempt.friendlyId,
error: attempt.error as TaskRunError,
};
return failure;
}
}
async getExecutionPayloadFromAttempt(
id: string,
setToExecuting = true
): Promise<ProdTaskRunExecutionPayload | undefined> {
const attempt = await prisma.taskRunAttempt.findUnique({
where: {
id,
},
include: {
backgroundWorker: true,
backgroundWorkerTask: true,
taskRun: {
include: {
runtimeEnvironment: {
include: {
organization: true,
project: true,
},
},
tags: true,
batchItem: {
include: {
batchTaskRun: true,
},
},
},
},
queue: true,
},
});
if (!attempt) {
logger.error("No attempt found", { id });
return;
}
if (setToExecuting) {
await prisma.taskRunAttempt.update({
where: {
id,
},
data: {
status: "EXECUTING",
},
});
}
const { backgroundWorkerTask, taskRun, queue } = attempt;
const execution: ProdTaskRunExecution = {
task: {
id: backgroundWorkerTask.slug,
filePath: backgroundWorkerTask.filePath,
exportName: backgroundWorkerTask.exportName,
},
attempt: {
id: attempt.friendlyId,
number: attempt.number,
startedAt: attempt.startedAt ?? attempt.createdAt,
backgroundWorkerId: attempt.backgroundWorkerId,
backgroundWorkerTaskId: attempt.backgroundWorkerTaskId,
status: "EXECUTING" as const,
},
run: {
id: taskRun.friendlyId,
payload: taskRun.payload,
payloadType: taskRun.payloadType,
context: taskRun.context,
createdAt: taskRun.createdAt,
tags: taskRun.tags.map((tag) => tag.name),
isTest: taskRun.isTest,
},
queue: {
id: queue.friendlyId,
name: queue.name,
},
environment: {
id: taskRun.runtimeEnvironment.id,
slug: taskRun.runtimeEnvironment.slug,
type: taskRun.runtimeEnvironment.type,
},
organization: {
id: taskRun.runtimeEnvironment.organization.id,
slug: taskRun.runtimeEnvironment.organization.slug,
name: taskRun.runtimeEnvironment.organization.title,
},
project: {
id: taskRun.runtimeEnvironment.project.id,
ref: taskRun.runtimeEnvironment.project.externalRef,
slug: taskRun.runtimeEnvironment.project.slug,
name: taskRun.runtimeEnvironment.project.name,
},
batch: taskRun.batchItem?.batchTaskRun
? { id: taskRun.batchItem.batchTaskRun.friendlyId }
: undefined,
worker: {
id: attempt.backgroundWorkerId,
contentHash: attempt.backgroundWorker.contentHash,
version: attempt.backgroundWorker.version,
},
};
const environmentRepository = new EnvironmentVariablesRepository();
const variables = await environmentRepository.getEnvironmentVariables(
attempt.taskRun.runtimeEnvironment.projectId,
attempt.taskRun.runtimeEnvironmentId
);
const payload: ProdTaskRunExecutionPayload = {
execution,
traceContext: taskRun.traceContext as Record<string, unknown>,
environment: variables.reduce((acc: Record<string, string>, curr) => {
acc[curr.key] = curr.value;
return acc;
}, {}),
};
return payload;
}
async taskHeartbeat(attemptFriendlyId: string, seconds: number = 60) {
const taskRunAttempt = await prisma.taskRunAttempt.findUnique({
where: { friendlyId: attemptFriendlyId },
});
if (!taskRunAttempt) {
return;
}
await marqs?.heartbeatMessage(taskRunAttempt.taskRunId, seconds);
}
}
export const sharedQueueTasks = singleton("sharedQueueTasks", () => new SharedQueueTasks());
@@ -4,14 +4,26 @@ import { marqs } from "../marqs.server";
import { BaseService } from "./baseService.server";
import { logger } from "~/services/logger.server";
import { PrismaClientOrTransaction, prisma } from "~/db.server";
export class CancelAttemptService extends BaseService {
public async call(
attemptId: string,
taskRunId: string,
cancelledAt: Date,
reason: string,
environment: AuthenticatedEnvironment
env?: AuthenticatedEnvironment
) {
let environment: AuthenticatedEnvironment | undefined = env;
if (!environment) {
environment = await getAuthenticatedEnvironmentFromAttempt(attemptId);
if (!environment) {
return;
}
}
return await this.traceWithEnv("call()", environment, async (span) => {
span.setAttribute("taskRunId", taskRunId);
span.setAttribute("attemptId", attemptId);
@@ -56,3 +68,32 @@ export class CancelAttemptService extends BaseService {
});
}
}
async function getAuthenticatedEnvironmentFromAttempt(
friendlyId: string,
prismaClient?: PrismaClientOrTransaction
) {
const taskRunAttempt = await (prismaClient ?? prisma).taskRunAttempt.findUnique({
where: {
friendlyId,
},
include: {
taskRun: {
include: {
runtimeEnvironment: {
include: {
organization: true,
project: true,
},
},
},
},
},
});
if (!taskRunAttempt) {
return;
}
return taskRunAttempt?.taskRun.runtimeEnvironment;
}
@@ -11,13 +11,14 @@ import { eventRepository } from "../eventRepository.server";
import { marqs } from "../marqs.server";
import { BaseService } from "./baseService.server";
import { Attributes } from "@opentelemetry/api";
import { logger } from "~/services/logger.server";
export class CompleteAttemptService extends BaseService {
public async call(
completion: TaskRunExecutionResult,
execution: TaskRunExecution,
env: AuthenticatedEnvironment
): Promise<"ACKNOWLEDGED" | "RETRIED"> {
env?: AuthenticatedEnvironment
): Promise<"ACKNOWLEDGED" | "RETRIED" | "FAILED"> {
const taskRunAttempt = completion.ok
? await this._prisma.taskRunAttempt.update({
where: { friendlyId: completion.id },
@@ -28,7 +29,17 @@ export class CompleteAttemptService extends BaseService {
outputType: completion.outputType,
},
include: {
taskRun: true,
taskRun: {
include: {
batchItem: true,
dependency: {
include: {
dependentAttempt: true,
dependentBatchRun: true,
},
},
},
},
backgroundWorkerTask: true,
},
})
@@ -40,7 +51,17 @@ export class CompleteAttemptService extends BaseService {
error: completion.error,
},
include: {
taskRun: true,
taskRun: {
include: {
batchItem: true,
dependency: {
include: {
dependentAttempt: true,
dependentBatchRun: true,
},
},
},
},
backgroundWorkerTask: true,
},
});
@@ -53,7 +74,10 @@ export class CompleteAttemptService extends BaseService {
}
: undefined;
const environment = env ?? (await this.#getEnvironment(execution.environment.id));
const retryAt = new Date(completion.retry.timestamp);
// Retry the task run
await eventRepository.recordEvent(
retryConfig?.maxAttempts
@@ -61,7 +85,7 @@ export class CompleteAttemptService extends BaseService {
: `Retry #${execution.attempt.number} delay`,
{
taskSlug: taskRunAttempt.taskRun.taskIdentifier,
environment: env,
environment,
attributes: {
metadata: this.#generateMetadataAttributesForNextAttempt(execution),
properties: {
@@ -85,10 +109,29 @@ export class CompleteAttemptService extends BaseService {
}
);
await marqs?.nackMessage(taskRunAttempt.taskRunId, completion.retry.timestamp);
logger.debug("Retrying", { taskRun: taskRunAttempt.taskRun.friendlyId });
if (environment.type === "DEVELOPMENT") {
// This is already an EXECUTE message so we can just NACK
await marqs?.nackMessage(taskRunAttempt.taskRunId, completion.retry.timestamp);
} else {
// We have to replace a potential RESUME with EXECUTE to correctly retry the attempt
await marqs?.replaceMessage(
taskRunAttempt.taskRunId,
{
type: "EXECUTE",
taskIdentifier: taskRunAttempt.taskRun.taskIdentifier,
},
completion.retry.timestamp
);
}
return "RETRIED";
} else {
}
// Attempt succeeded or this was the last retry
else {
logger.debug("Completed attempt, ACKing message", taskRunAttempt);
await marqs?.acknowledgeMessage(taskRunAttempt.taskRunId);
// Now we need to "complete" the task run event/span
@@ -109,6 +152,124 @@ export class CompleteAttemptService extends BaseService {
});
}
const { batchItem, dependency } = taskRunAttempt.taskRun;
// This run is part of a batch so we should update its status
if (batchItem) {
logger.debug("Completing attempt with batch item", { batchItem });
await this._prisma.batchTaskRunItem.update({
where: {
id: batchItem.id,
},
data: {
status: completion.ok ? "COMPLETED" : "FAILED",
},
});
const finalizedBatchRun = await this._prisma.batchTaskRun.findFirst({
where: {
id: batchItem.batchTaskRunId,
dependentTaskAttemptId: {
not: null,
},
items: {
every: {
status: {
not: "PENDING",
},
},
},
},
include: {
dependentTaskAttempt: true,
items: {
include: {
taskRun: {
include: {
attempts: {
orderBy: {
completedAt: "desc",
},
take: 1,
select: {
id: true,
},
},
},
},
},
},
},
});
// This batch has a dependent attempt and just finalized, we should resume that attempt
if (finalizedBatchRun && finalizedBatchRun.dependentTaskAttempt) {
const environment =
env ?? (await this.#getEnvironment(taskRunAttempt.taskRun.runtimeEnvironmentId));
if (!environment) {
logger.error("Environment not found", {
attemptId: taskRunAttempt.id,
envId: taskRunAttempt.taskRun.runtimeEnvironmentId,
});
return "FAILED";
}
if (environment.type === "DEVELOPMENT") {
return "ACKNOWLEDGED";
}
await marqs?.replaceMessage(finalizedBatchRun.dependentTaskAttempt.taskRunId, {
type: "RESUME",
completedAttemptIds: finalizedBatchRun.items.map(
(item) => item.taskRun.attempts[0]?.id
),
});
}
}
if (dependency) {
logger.debug("Completing attempt with dependency", { dependency });
const environment =
env ?? (await this.#getEnvironment(taskRunAttempt.taskRun.runtimeEnvironmentId));
if (!environment) {
logger.error("Environment not found", {
attemptId: taskRunAttempt.id,
envId: taskRunAttempt.taskRun.runtimeEnvironmentId,
});
return "FAILED";
}
if (environment.type === "DEVELOPMENT") {
return "ACKNOWLEDGED";
}
if (dependency.dependentAttempt) {
const dependentRun = await this._prisma.taskRun.findFirst({
where: {
id: dependency.dependentAttempt.taskRunId,
},
});
if (!dependentRun) {
logger.error("Dependent task run does not exist", {
attemptId: taskRunAttempt.id,
envId: taskRunAttempt.taskRun.runtimeEnvironmentId,
taskRunId: dependency.taskRunId,
});
return "FAILED";
}
await marqs?.replaceMessage(dependentRun.id, {
type: "RESUME",
completedAttemptIds: [taskRunAttempt.id],
});
}
}
return "ACKNOWLEDGED";
}
}
@@ -123,4 +284,16 @@ export class CompleteAttemptService extends BaseService {
return flattenAttributes(context, "ctx");
}
async #getEnvironment(id: string) {
return await this._prisma.runtimeEnvironment.findUniqueOrThrow({
where: {
id,
},
include: {
project: true,
organization: true,
},
});
}
}
@@ -5,6 +5,7 @@ import { logger } from "~/services/logger.server";
import { generateFriendlyId } from "../friendlyIdentifiers";
import { marqs } from "../marqs.server";
import { BaseService } from "./baseService.server";
import { $transaction } from "~/db.server";
export class CreateBackgroundWorkerService extends BaseService {
public async call(
@@ -15,100 +16,123 @@ export class CreateBackgroundWorkerService extends BaseService {
return this.traceWithEnv("call", environment, async (span) => {
span.setAttribute("projectRef", projectRef);
const project = await this._prisma.project.findUniqueOrThrow({
where: {
externalRef: projectRef,
environments: {
some: {
id: environment.id,
const backgroundWorker = await $transaction(this._prisma, async (tx) => {
const project = await this._prisma.project.findUniqueOrThrow({
where: {
externalRef: projectRef,
environments: {
some: {
id: environment.id,
},
},
},
},
include: {
backgroundWorkers: {
where: {
runtimeEnvironmentId: environment.id,
include: {
backgroundWorkers: {
where: {
runtimeEnvironmentId: environment.id,
},
orderBy: {
createdAt: "desc",
},
take: 1,
},
orderBy: {
createdAt: "desc",
},
take: 1,
},
},
});
});
const latestBackgroundWorker = project.backgroundWorkers[0];
const latestBackgroundWorker = project.backgroundWorkers[0];
if (latestBackgroundWorker?.contentHash === body.metadata.contentHash) {
return latestBackgroundWorker;
}
if (latestBackgroundWorker?.contentHash === body.metadata.contentHash) {
return latestBackgroundWorker;
}
const nextVersion = calculateNextBuildVersion(project.backgroundWorkers[0]?.version);
const nextVersion = calculateNextBuildVersion(project.backgroundWorkers[0]?.version);
logger.debug(`Creating background worker`, {
nextVersion,
lastVersion: project.backgroundWorkers[0]?.version,
});
logger.debug(`Creating background worker`, {
nextVersion,
lastVersion: project.backgroundWorkers[0]?.version,
});
const backgroundWorker = await this._prisma.backgroundWorker.create({
data: {
friendlyId: generateFriendlyId("worker"),
version: nextVersion,
runtimeEnvironmentId: environment.id,
projectId: project.id,
metadata: body.metadata,
contentHash: body.metadata.contentHash,
const backgroundWorker = await this._prisma.backgroundWorker.create({
data: {
friendlyId: generateFriendlyId("worker"),
version: nextVersion,
runtimeEnvironmentId: environment.id,
projectId: project.id,
metadata: body.metadata,
contentHash: body.metadata.contentHash,
cliVersion: body.metadata.cliPackageVersion,
sdkVersion: body.metadata.packageVersion,
},
},
});
if (environment.type !== "DEVELOPMENT") {
await this._prisma.imageDetails.update({
where: {
projectId_runtimeEnvironmentId_contentHash: {
projectId: environment.projectId,
runtimeEnvironmentId: environment.id,
contentHash: backgroundWorker.contentHash,
},
},
data: {
backgroundWorkerId: backgroundWorker.id,
},
});
}
for (const task of body.metadata.tasks) {
await this._prisma.backgroundWorkerTask.create({
data: {
friendlyId: generateFriendlyId("task"),
projectId: project.id,
runtimeEnvironmentId: environment.id,
workerId: backgroundWorker.id,
slug: task.id,
filePath: task.filePath,
exportName: task.exportName,
retryConfig: task.retry,
queueConfig: task.queue,
},
});
const queueName = task.queue?.name ?? `task/${task.id}`;
const taskQueue = await this._prisma.taskQueue.upsert({
where: {
runtimeEnvironmentId_name: {
runtimeEnvironmentId: environment.id,
name: queueName,
},
},
update: {
concurrencyLimit: task.queue?.concurrencyLimit,
rateLimit: task.queue?.rateLimit,
},
create: {
friendlyId: generateFriendlyId("queue"),
name: queueName,
concurrencyLimit: task.queue?.concurrencyLimit,
runtimeEnvironmentId: environment.id,
projectId: project.id,
rateLimit: task.queue?.rateLimit,
type: task.queue?.name ? "NAMED" : "VIRTUAL",
},
});
if (taskQueue.concurrencyLimit) {
await marqs?.updateQueueConcurrency(
environment,
taskQueue.name,
taskQueue.concurrencyLimit
);
}
}
return backgroundWorker;
});
for (const task of body.metadata.tasks) {
await this._prisma.backgroundWorkerTask.create({
data: {
friendlyId: generateFriendlyId("task"),
projectId: project.id,
runtimeEnvironmentId: environment.id,
workerId: backgroundWorker.id,
slug: task.id,
filePath: task.filePath,
exportName: task.exportName,
retryConfig: task.retry,
queueConfig: task.queue,
},
});
const queueName = task.queue?.name ?? `task/${task.id}`;
const taskQueue = await this._prisma.taskQueue.upsert({
where: {
runtimeEnvironmentId_name: {
runtimeEnvironmentId: environment.id,
name: queueName,
},
},
update: {
concurrencyLimit: task.queue?.concurrencyLimit,
rateLimit: task.queue?.rateLimit,
},
create: {
friendlyId: generateFriendlyId("queue"),
name: queueName,
concurrencyLimit: task.queue?.concurrencyLimit,
runtimeEnvironmentId: environment.id,
projectId: project.id,
rateLimit: task.queue?.rateLimit,
type: task.queue?.name ? "NAMED" : "VIRTUAL",
},
});
if (taskQueue.concurrencyLimit) {
await marqs?.updateQueueConcurrency(
environment,
taskQueue.name,
taskQueue.concurrencyLimit
);
}
if (!backgroundWorker) {
throw new Error("Failed to create background worker");
}
return backgroundWorker;
@@ -0,0 +1,46 @@
import { CoordinatorToPlatformEvents } from "@trigger.dev/core/v3";
import type { Checkpoint } from "@trigger.dev/database";
import { PrismaClient, prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { generateFriendlyId } from "../friendlyIdentifiers";
import { marqs } from "../marqs.server";
export class CreateCheckpointService {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call(
params: Parameters<CoordinatorToPlatformEvents["CHECKPOINT_CREATED"]>[0]
): Promise<Checkpoint> {
const attempt = await this.#prismaClient.taskRunAttempt.findUniqueOrThrow({
where: {
id: params.attemptId,
},
include: {
taskRun: true,
},
});
logger.debug(`Creating checkpoint`, params);
const checkpoint = await this.#prismaClient.checkpoint.create({
data: {
friendlyId: generateFriendlyId("checkpoint"),
runtimeEnvironmentId: attempt.taskRun.runtimeEnvironmentId,
projectId: attempt.taskRun.projectId,
attemptId: attempt.id,
location: params.location,
type: params.docker ? "DOCKER" : "KUBERNETES",
reason: params.reason,
},
});
// TODO: Can't heartbeat when checkpointed, so we ACK to prevent automatic requeue
// await marqs?.acknowledgeMessage(attempt.taskRunId);
return checkpoint;
}
}
@@ -0,0 +1,75 @@
import { CreateImageDetailsRequestBody } from "@trigger.dev/core/v3";
import type { ImageDetails } from "@trigger.dev/database";
import { PrismaClient, prisma } from "~/db.server";
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { generateFriendlyId } from "../friendlyIdentifiers";
import { env } from "~/env.server";
import { workerQueue } from "~/services/worker.server";
function escapeStringForRegex(rawString: string) {
return rawString.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&").replace(/-/g, "\\x2d");
}
export class CreateImageDetailsService {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call(
projectRef: string,
environment: AuthenticatedEnvironment,
body: CreateImageDetailsRequestBody
): Promise<ImageDetails> {
const allowedTagPrefix = escapeStringForRegex(`${env.IMAGE_REGISTRY}/${env.IMAGE_REPO}:`);
if (!body.metadata.imageTag.match(`^${allowedTagPrefix}`)) {
if (env.NODE_ENV !== "development") {
throw new Error("Forbidden image tag");
}
}
const project = await this.#prismaClient.project.findUniqueOrThrow({
where: {
externalRef: projectRef,
environments: {
some: {
id: environment.id,
},
},
},
});
logger.debug(`Creating image details`, {
imageTag: body.metadata.imageTag,
});
const imageDetails = await this.#prismaClient.imageDetails.upsert({
where: {
projectId_runtimeEnvironmentId_contentHash: {
contentHash: body.metadata.contentHash,
runtimeEnvironmentId: environment.id,
projectId: project.id,
},
},
create: {
contentHash: body.metadata.contentHash,
friendlyId: generateFriendlyId("image"),
tag: body.metadata.imageTag,
runtimeEnvironmentId: environment.id,
projectId: project.id,
metadata: body.metadata,
},
update: {
tag: body.metadata.imageTag,
metadata: body.metadata,
},
});
await workerQueue.enqueue("indexTasks", { id: imageDetails.id });
return imageDetails;
}
}
@@ -0,0 +1,53 @@
import { PrismaClient, prisma } from "~/db.server";
import { socketIo } from "../handleSocketIo.server";
import { logger } from "~/services/logger.server";
export type IndexTasksServiceOptions = {
idempotencyKey?: string;
triggerVersion?: string;
traceContext?: Record<string, string | undefined>;
};
export class IndexTasksService {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call(imageDetailsId: string) {
const imageDetails = await this.#prismaClient.imageDetails.findUnique({
where: {
id: imageDetailsId,
},
});
if (!imageDetails) {
logger.error(`No image details with this ID: ${imageDetailsId}`);
return;
}
if (imageDetails.backgroundWorkerId) {
logger.debug(
`Image details have already been indexed for ${imageDetails.friendlyId}. Refreshing worker timestamp.`
);
await this.#prismaClient.backgroundWorker.update({
where: {
id: imageDetails.backgroundWorkerId,
},
data: {
updatedAt: new Date(),
},
});
return;
}
// just broadcast for now - there should only ever be one provider connected
socketIo.providerNamespace.emit("INDEX", {
version: "v1",
contentHash: imageDetails.contentHash,
imageTag: imageDetails.tag,
envId: imageDetails.runtimeEnvironmentId,
});
}
}
@@ -123,7 +123,7 @@ export class TriggerTaskService extends BaseService {
span.setAttribute("runId", taskRun.friendlyId);
if (body.options?.dependentAttempt) {
const dependentAttempt = await tx.taskRun.findUnique({
const dependentAttempt = await tx.taskRunAttempt.findUnique({
where: { friendlyId: body.options.dependentAttempt },
});
@@ -0,0 +1,94 @@
import {
MessageCatalogToSocketIoEvents,
ZodMessageHandler,
ZodMessageSender,
clientWebsocketMessages,
serverWebsocketMessages,
} from "@trigger.dev/core/v3";
import { Evt } from "evt";
import { randomUUID } from "node:crypto";
import { logger } from "~/services/logger.server";
import { SharedQueueConsumer } from "./marqs/sharedQueueConsumer.server";
import { DisconnectReason, Namespace, Socket } from "socket.io";
export class SharedSocketConnection {
public id: string;
public onClose: Evt<DisconnectReason> = new Evt();
private _sender: ZodMessageSender<typeof serverWebsocketMessages>;
private _sharedConsumer: SharedQueueConsumer;
private _messageHandler: ZodMessageHandler<typeof clientWebsocketMessages>;
constructor(
namespace: Namespace<
MessageCatalogToSocketIoEvents<typeof clientWebsocketMessages>,
MessageCatalogToSocketIoEvents<typeof serverWebsocketMessages>
>,
private socket: Socket<
MessageCatalogToSocketIoEvents<typeof clientWebsocketMessages>,
MessageCatalogToSocketIoEvents<typeof serverWebsocketMessages>
>,
logger?: (...args: any[]) => void
) {
this.id = randomUUID();
this._sender = new ZodMessageSender({
schema: serverWebsocketMessages,
sender: async (message) => {
return new Promise((resolve, reject) => {
try {
const { type, ...payload } = message;
namespace.emit(type, payload as any);
resolve();
} catch (err) {
reject(err);
}
});
},
});
this._sharedConsumer = new SharedQueueConsumer(this._sender);
socket.on("disconnect", this.#handleClose.bind(this));
socket.on("error", this.#handleError.bind(this));
this._messageHandler = new ZodMessageHandler({
schema: clientWebsocketMessages,
messages: {
READY_FOR_TASKS: async (payload) => {
this._sharedConsumer.start();
},
BACKGROUND_WORKER_DEPRECATED: async (payload) => {
// await this._sharedConsumer.deprecateBackgroundWorker(payload.backgroundWorkerId);
},
BACKGROUND_WORKER_MESSAGE: async (payload) => {
switch (payload.data.type) {
case "TASK_RUN_COMPLETED": {
// handled in coordinator namespace
break;
}
case "TASK_HEARTBEAT": {
// handled in coordinator namespace
break;
}
}
},
},
});
this._messageHandler.registerHandlers(this.socket, logger);
}
async initialize() {
this._sender.send("SERVER_READY", { id: this.id });
}
async #handleClose(ev: DisconnectReason) {
await this._sharedConsumer.stop();
this.onClose.post(ev);
}
async #handleError(ev: Error) {
logger.error("Websocket error", { ev });
}
}
+2
View File
@@ -142,6 +142,7 @@
"simple-oauth2": "^5.0.0",
"simplur": "^3.0.1",
"slug": "^6.0.0",
"socket.io": "^4.7.4",
"sonner": "^1.0.3",
"sqs-consumer": "^7.4.0",
"tailwind-merge": "^1.12.0",
@@ -201,6 +202,7 @@
"@typescript-eslint/parser": "^5.59.6",
"autoprefixer": "^10.4.13",
"datepicker": "link:@types/@react-aria/datepicker",
"engine.io": "^6.5.4",
"esbuild": "^0.15.10",
"eslint": "^8.24.0",
"eslint-config-prettier": "^8.5.0",
+15
View File
@@ -5,6 +5,8 @@ import morgan from "morgan";
import { createRequestHandler } from "@remix-run/express";
import { WebSocketServer } from "ws";
import { broadcastDevReady, logDevReady } from "@remix-run/server-runtime";
import type { Server as IoServer } from "socket.io";
import type { Server as EngineServer } from "engine.io";
const app = express();
@@ -53,6 +55,7 @@ app.all(
const port = process.env.REMIX_APP_PORT || process.env.PORT || 3000;
if (process.env.HTTP_SERVER_DISABLED !== "true") {
const socketIo: { io: IoServer } | undefined = build.entry.module.socketIo;
const wss: WebSocketServer | undefined = build.entry.module.wss;
const server = app.listen(port, () => {
@@ -77,6 +80,9 @@ if (process.env.HTTP_SERVER_DISABLED !== "true") {
});
});
socketIo?.io.attach(server);
server.removeAllListeners("upgrade"); // prevent duplicate upgrades from listeners created by io.attach()
server.on("upgrade", async (req, socket, head) => {
console.log(
`Attemping to upgrade connection at url ${req.url} with headers: ${JSON.stringify(
@@ -86,6 +92,15 @@ if (process.env.HTTP_SERVER_DISABLED !== "true") {
const url = new URL(req.url ?? "", "http://localhost");
// Upgrade socket.io connection
if (url.pathname.startsWith("/socket.io/")) {
console.log(`Socket.io client connected, upgrading their connection...`);
// https://github.com/socketio/socket.io/issues/4693
(socketIo?.io.engine as EngineServer).handleUpgrade(req, socket, head);
return;
}
// Only upgrade the connecting if the path is `/ws`
if (url.pathname !== "/ws") {
socket.destroy(
+3 -1
View File
@@ -27,7 +27,7 @@ services:
pgadmin:
container_name: pgadmin
image: dpage/pgadmin4:7
image: dpage/pgadmin4:8
restart: always
environment:
PGADMIN_DEFAULT_EMAIL: admin@example.com
@@ -55,7 +55,9 @@ services:
- 6379:6379
otel-collector:
container_name: otel-collector
image: otel/opentelemetry-collector-contrib:latest
restart: always
command: ["--config", "/etc/otel-collector-config.yaml"]
volumes:
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
+12
View File
@@ -43,7 +43,9 @@
"@types/object-hash": "^3.0.6",
"@types/react": "^18.2.48",
"@types/ws": "^8.5.3",
"cpy-cli": "^5.0.0",
"npm-run-all": "^4.1.5",
"npm-watch": "^0.11.0",
"open": "^10.0.3",
"p-retry": "^6.1.0",
"rimraf": "^3.0.2",
@@ -53,14 +55,23 @@
"vitest": "^0.34.4",
"xdg-app-paths": "^8.3.0"
},
"watch": {
"build:prod-containerfile": "src/Containerfile.prod"
},
"scripts": {
"typecheck": "tsc",
"build": "npm run clean && run-p build:**",
"build:main": "tsup",
"build:facade": "tsup --config tsup.facade.config.ts",
"build:prod-facade": "tsup --config tsup.prod-facade.config.ts",
"build:prod-worker": "esbuild --platform=node --bundle --format=esm --target=esnext --outfile=dist/prod-worker.mjs --banner:js=\"import { createRequire } from 'module';const require = createRequire(import.meta.url);const path = require('path');const __dirname = path.resolve();\" ./src/prod-worker.ts",
"build:prod-containerfile": "cpy --flat src/Containerfile.prod dist/",
"dev": "npm run clean && run-p dev:**",
"dev:main": "tsup --watch",
"dev:facade": "tsup --config tsup.facade.config.ts --watch",
"dev:prod-facade": "tsup --config tsup.prod-facade.config.ts --watch",
"dev:prod-worker": "esbuild --platform=node --bundle --format=esm --target=esnext --outfile=dist/prod-worker.mjs --banner:js=\"import { createRequire } from 'module';const require = createRequire(import.meta.url);const path = require('path');const __dirname = path.resolve();\" ./src/prod-worker.ts --watch",
"dev:prod-containerfile": "npm-watch",
"clean": "rimraf dist",
"start": "node dist/index.js",
"test": "vitest"
@@ -111,6 +122,7 @@
"react": "^18.2.0",
"react-error-boundary": "^4.0.12",
"simple-git": "^3.19.0",
"socket.io-client": "^4.7.4",
"source-map-support": "^0.5.21",
"supports-color": "^9.4.0",
"terminal-link": "^3.0.0",
+18
View File
@@ -0,0 +1,18 @@
FROM node:18-alpine@sha256:ca9f6cb0466f9638e59e0c249d335a07c867cd50c429b5c7830dda1bed584649 AS base
RUN apk add --no-cache dumb-init
FROM base
ENV TRIGGER_CONTENT_HASH=__CONTENT_HASH__
ENV TRIGGER_PROJECT_DIR=__PROJECT_DIR__
ENV TRIGGER_PROJECT_REF=__PROJECT_REF__
ENV TRIGGER_CLI_PACKAGE_VERSION=__CLI_PACKAGE_VERSION__
WORKDIR /app
COPY --chown=node:node . .
USER node
CMD [ "dumb-init", "node", "index.mjs" ]
+44 -6
View File
@@ -2,18 +2,24 @@ import { z } from "zod";
import {
CreateAuthorizationCodeResponseSchema,
GetPersonalAccessTokenResponseSchema,
GetProjectDevResponse,
CreateBackgroundWorkerRequestBody,
WhoAmIResponseSchema,
CreateBackgroundWorkerRequestBody,
CreateBackgroundWorkerResponse,
CreateImageDetailsRequestBody,
CreateImageDetailsResponse,
GetProjectDevResponse,
GetEnvironmentVariablesResponseBody,
} from "@trigger.dev/core/v3";
export class ApiClient {
export class CliApiClient {
private readonly apiURL: string;
constructor(
private readonly apiURL: string,
apiURL: string,
private readonly accessToken?: string
) {}
) {
this.apiURL = apiURL.replace(/\/$/, "");
}
async createAuthorizationCode() {
return zodfetch(
@@ -49,7 +55,7 @@ export class ApiClient {
async createBackgroundWorker(projectRef: string, body: CreateBackgroundWorkerRequestBody) {
if (!this.accessToken) {
throw new Error("indexProject: No access token");
throw new Error("createBackgroundWorker: No access token");
}
return zodfetch(
@@ -66,6 +72,25 @@ export class ApiClient {
);
}
async createImageDetails(projectRef: string, body: CreateImageDetailsRequestBody) {
if (!this.accessToken) {
throw new Error("createImageDetails: No access token");
}
return zodfetch(
CreateImageDetailsResponse,
`${this.apiURL}/api/v1/projects/${projectRef}/image-details`,
{
method: "POST",
headers: {
Authorization: `Bearer ${this.accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
}
);
}
async getProjectDevEnv({ projectRef }: { projectRef: string }) {
if (!this.accessToken) {
throw new Error("getProjectDevEnv: No access token");
@@ -79,6 +104,19 @@ export class ApiClient {
});
}
async getProjectProdEnv({ projectRef }: { projectRef: string }) {
if (!this.accessToken) {
throw new Error("getProjectDevEnv: No access token");
}
return zodfetch(GetProjectDevResponse, `${this.apiURL}/api/v1/projects/${projectRef}/prod`, {
headers: {
Authorization: `Bearer ${this.accessToken}`,
"Content-Type": "application/json",
},
});
}
async getEnvironmentVariables(projectRef: string) {
if (!this.accessToken) {
throw new Error("getEnvironmentVariables: No access token");
+3
View File
@@ -8,6 +8,7 @@ import { configureWhoamiCommand } from "../commands/whoami.js";
import { COMMAND_NAME } from "../consts.js";
import { getVersion } from "../utilities/getVersion.js";
import { printInitialBanner } from "../utilities/initialBanner.js";
import { configureBuildCommand } from "../commands/build.js";
export const program = new Command();
@@ -55,6 +56,8 @@ program
}
});
configureBuildCommand(program);
configureDevCommand(program);
program
+316
View File
@@ -0,0 +1,316 @@
import chalk from "chalk";
import { Command } from "commander";
import { build } from "esbuild";
import { execa } from "execa";
import { resolve as importResolve } from "import-meta-resolve";
import { createHash } from "node:crypto";
import fs, { readFileSync } from "node:fs";
import { join } from "node:path";
import { z } from "zod";
import * as packageJson from "../../package.json";
import { printStandloneInitialBanner } from "../utilities/initialBanner.js";
import { logger } from "../utilities/logger.js";
import { isLoggedIn } from "../utilities/session.js";
import { CommonCommandOptions } from "../cli/common.js";
import { getConfigPath, readConfig } from "../utilities/configFiles.js";
import { createTaskFileImports, gatherTaskFiles } from "../utilities/taskFiles.js";
import { ResolvedConfig } from "@trigger.dev/core/v3";
import { CliApiClient } from "../apiClient";
const BuildCommandOptions = CommonCommandOptions.extend({
registry: z.string(),
repo: z.string(),
skipTypecheck: z.boolean().optional(),
});
type BuildCommandOptions = z.infer<typeof BuildCommandOptions>;
export function configureBuildCommand(program: Command) {
program
.command("build")
.description("Build your Trigger.dev tasks locally")
.argument("[path]", "The path to the project", ".")
.option("-r, --repo <repo_name>", "The repo to push images to", "task")
.option("-R, --registry <registry_address>", "The registry to push images to", "")
.option("-T, --skip-typecheck", "Whether to skip the pre-build typecheck")
.option(
"-l, --log-level <level>",
"The log level to use (debug, info, log, warn, error, none)",
"log"
)
.action(async (path, options) => {
try {
await buildCommand(path, options);
} catch (e) {
//todo error reporting
throw e;
}
});
}
export async function buildCommand(dir: string, anyOptions: unknown) {
const options = BuildCommandOptions.safeParse(anyOptions);
if (!options.success) {
throw new Error(`Invalid options: ${options.error}`);
}
const authorization = await isLoggedIn();
if (!authorization.ok) {
if (authorization.error === "fetch failed") {
logger.error("Fetch failed. Platform down?");
} else {
logger.error("You must login first. Use `trigger.dev login` to login.");
}
process.exitCode = 1;
return;
}
await startBuild(dir, options.data, authorization.config);
}
async function startBuild(
dir: string,
options: BuildCommandOptions,
authorization: { apiUrl: string; accessToken: string }
) {
try {
if (options.logLevel) {
logger.loggerLevel = options.logLevel;
}
await printStandloneInitialBanner(true);
const configPath = await getConfigPath(dir);
const config = await readConfig(configPath);
const apiClient = new CliApiClient(authorization.apiUrl, authorization.accessToken);
const prodEnv = await apiClient.getProjectProdEnv({ projectRef: config.project });
if (!prodEnv.success) {
throw new Error(prodEnv.error);
}
const buildResult = await runBuild(config, options, {
apiUrl: authorization.apiUrl,
apiKey: prodEnv.data.apiKey,
});
const envClient = new CliApiClient(authorization.apiUrl, prodEnv.data.apiKey);
await envClient.createImageDetails(config.project, {
metadata: {
contentHash: buildResult.contentHash,
imageTag: buildResult.imageTag,
},
});
} catch (e) {
throw e;
}
}
async function runBuild(
config: ResolvedConfig,
options: BuildCommandOptions,
auth: { apiUrl: string; apiKey: string }
) {
const taskFiles = await gatherTaskFiles(config);
const prodFacade = readFileSync(
new URL(importResolve("./prod-facade.js", import.meta.url)).href.replace("file://", ""),
"utf-8"
);
const entryPointContents = prodFacade.replace("__TASKS__", createTaskFileImports(taskFiles));
if (!options.skipTypecheck) {
logger.log(chalk.dim("⎔ Typecheck..."));
const tscTypecheck = execa("npm", ["exec", "tsc", "--", "--noEmit"]);
tscTypecheck.stdout?.on("data", (chunk) => logger.log(chunk.toString()));
tscTypecheck.stderr?.on("data", (chunk) => logger.error(chunk.toString()));
try {
await new Promise((resolve, reject) => {
tscTypecheck.addListener("exit", (code) => (code === 0 ? resolve(code) : reject(code)));
});
} catch (error) {
throw new Error("Typecheck failed.");
}
logger.log(chalk.green(`Typecheck succeeded.\n`));
}
logger.log(chalk.dim("⎔ Bundling tasks..."));
const result = await build({
stdin: {
contents: entryPointContents,
resolveDir: process.cwd(),
sourcefile: "__entryPoint.ts",
},
bundle: true,
metafile: true,
write: false,
minify: false,
sourcemap: true,
logLevel: "warning",
platform: "node",
format: "esm",
target: ["node18", "es2020"],
outdir: "out",
banner: {
js: "import { createRequire } from 'module';const require = createRequire(import.meta.url);",
},
});
if (result.errors.length > 0) {
logger.error(result.errors);
throw new Error("Build failed");
}
if (!result || !result.outputFiles) {
throw new Error("Build failed: no result");
}
const metaOutputKey = join("out", `stdin.js`);
const metaOutput = result.metafile!.outputs[metaOutputKey];
if (!metaOutput) {
throw new Error(`Could not find metafile`);
}
const outputFileKey = join(config.projectDir, metaOutputKey);
const outputFile = result.outputFiles.find((file) => file.path === outputFileKey);
if (!outputFile) {
throw new Error(`Could not find output file for entry point ${metaOutput.entryPoint}`);
}
const sourceMapFileKey = join(config.projectDir, `${metaOutputKey}.map`);
const sourceMapFile = result.outputFiles.find((file) => file.path === sourceMapFileKey);
if (!sourceMapFile) {
throw new Error(`Could not find source map file for entry point ${metaOutput.entryPoint}`);
}
const md5Hasher = createHash("md5");
md5Hasher.update(Buffer.from(outputFile.contents.buffer));
const contentHash = md5Hasher.digest("hex");
const buildContextPath = join(config.projectDir, ".trigger");
try {
// Clean build context dir first
await fs.promises.rm(buildContextPath, { recursive: true, force: true });
} catch (err) {
} finally {
// ..then ensure it exists
await fs.promises.mkdir(buildContextPath, { recursive: true });
}
// Create a file at join(dir, ".trigger", path) with the fileContents
const fullPath = join(buildContextPath, `${contentHash}.mjs`);
await fs.promises.writeFile(fullPath, outputFile.text);
const sourceMapPath = `${fullPath}.map`;
await fs.promises.writeFile(sourceMapPath, sourceMapFile.text);
const prodWorkerPath = new URL(importResolve("./prod-worker.mjs", import.meta.url)).href.replace(
"file://",
""
);
await fs.promises.copyFile(prodWorkerPath, join(buildContextPath, "index.mjs"));
logger.log(chalk.green(`Bundling finished.\n`));
let localOnly = false;
if (!options.registry) {
logger.log(chalk.yellow(`No registry specified, enabling local only mode.\n`));
localOnly = true;
}
const registryWithRepo = localOnly ? options.repo : `${options.registry}/${options.repo}`;
if (!localOnly) {
logger.log(chalk.dim("⎔ Checking repo login..."));
const dockerLogin = execa("docker", ["login", registryWithRepo]);
dockerLogin.stdout?.on("data", (chunk) => logger.debug(chunk.toString()));
dockerLogin.stderr?.on("data", (chunk) => logger.error(chunk.toString()));
try {
await new Promise((resolve, reject) => {
dockerLogin.addListener("exit", (code) => (code === 0 ? resolve(code) : reject(code)));
});
} catch (error) {
throw new Error("Login failed. Please run `docker login` to authenticate.");
}
logger.log(chalk.green(`Login succeeded.\n`));
}
logger.log(chalk.dim("⎔ Starting docker build..."));
const containerfile = await fs.promises.readFile(
new URL(importResolve("./Containerfile.prod", import.meta.url)).href.replace("file://", ""),
"utf-8"
);
const containerfileContents = containerfile
.replace("__CONTENT_HASH__", contentHash)
.replace("__PROJECT_DIR__", config.projectDir)
.replace("__PROJECT_REF__", config.project)
.replace("__CLI_PACKAGE_VERSION__", packageJson.version);
const containerfilePath = join(buildContextPath, "Containerfile");
const imageTag = `${registryWithRepo}:${contentHash}`;
await fs.promises.writeFile(containerfilePath, containerfileContents);
const dockerBuild = execa("docker", [
"build",
"-f",
containerfilePath,
"-t",
imageTag,
join(config.projectDir, ".trigger"),
]);
dockerBuild.stdout?.pipe(process.stdout);
dockerBuild.stderr?.pipe(process.stderr);
try {
await new Promise((resolve, reject) => {
dockerBuild.addListener("exit", (code) => (code === 0 ? resolve(code) : reject(code)));
});
} catch (error) {
throw new Error("Build failed.");
}
logger.log(chalk.green(`Build finished.\n`));
if (!localOnly) {
logger.log(chalk.dim("⎔ Pushing image..."));
const dockerPush = execa("docker", ["push", imageTag]);
dockerPush.stdout?.pipe(process.stdout);
dockerPush.stderr?.pipe(process.stderr);
try {
await new Promise((resolve, reject) => {
dockerPush.addListener("exit", (code) => (code === 0 ? resolve(code) : reject(code)));
});
} catch (error) {
throw new Error("Push failed.");
}
logger.log(chalk.green(`Push complete.\n`));
}
return {
contentHash,
imageTag,
};
}
+14 -145
View File
@@ -1,5 +1,6 @@
import {
CreateBackgroundWorkerRequestBody,
ResolvedConfig,
TaskResource,
ZodMessageHandler,
ZodMessageSender,
@@ -10,48 +11,28 @@ import chalk from "chalk";
import { watch } from "chokidar";
import { Command } from "commander";
import { BuildContext, context } from "esbuild";
import { findUp } from "find-up";
import { resolve as importResolve } from "import-meta-resolve";
import { Box, Text, render, useApp, useInput } from "ink";
import { createHash } from "node:crypto";
import fs, { readFileSync } from "node:fs";
import { ClientRequestArgs } from "node:http";
import { basename, dirname, join, relative, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { basename, dirname, join } from "node:path";
import pThrottle from "p-throttle";
import { WebSocket } from "partysocket";
import React, { Suspense, useEffect } from "react";
import { ClientOptions, WebSocket as wsWebSocket } from "ws";
import { z } from "zod";
import * as packageJson from "../../package.json";
import { ApiClient } from "../apiClient.js";
import { CLOUD_API_URL } from "../consts.js";
import { BackgroundWorker, BackgroundWorkerCoordinator } from "../dev/backgroundWorker.js";
import { printStandloneInitialBanner } from "../utilities/initialBanner.js";
import { logger } from "../utilities/logger.js";
import { RequireKeys } from "../utilities/requiredKeys.js";
import { isLoggedIn } from "../utilities/session.js";
import { CommonCommandOptions } from "../cli/common.js";
import { getConfigPath, readConfig } from "../utilities/configFiles";
import { createTaskFileImports, gatherTaskFiles } from "../utilities/taskFiles";
import { CliApiClient } from "../apiClient";
const CONFIG_FILES = ["trigger.config.js", "trigger.config.mjs"];
const ConfigSchema = z.object({
project: z.string(),
triggerDirectories: z.string().array().optional(),
triggerUrl: z.string().optional(),
projectDir: z.string().optional(),
});
type Config = z.infer<typeof ConfigSchema>;
type ResolvedConfig = RequireKeys<Config, "triggerDirectories" | "triggerUrl" | "projectDir">;
type TaskFile = {
triggerDir: string;
filePath: string;
importPath: string;
importName: string;
};
let apiClient: ApiClient | undefined;
let apiClient: CliApiClient | undefined;
const DevCommandOptions = CommonCommandOptions.extend({
debugger: z.boolean().default(false),
@@ -92,7 +73,11 @@ export async function devCommand(dir: string, anyOptions: unknown) {
const authorization = await isLoggedIn();
if (!authorization.ok) {
logger.error("You must login first. Use `trigger.dev login` to login.");
if (authorization.error === "fetch failed") {
logger.error("Fetch failed. Platform down?");
} else {
logger.error("You must login first. Use `trigger.dev login` to login.");
}
process.exitCode = 1;
return;
}
@@ -143,7 +128,7 @@ async function startDev(
const accessToken = authorization.accessToken;
const apiUrl = authorization.apiUrl;
apiClient = new ApiClient(apiUrl, accessToken);
apiClient = new CliApiClient(apiUrl, accessToken);
const devEnv = await apiClient.getProjectDevEnv({ projectRef: config.project });
@@ -151,7 +136,7 @@ async function startDev(
throw new Error(devEnv.error);
}
const environmentClient = new ApiClient(apiUrl, devEnv.data.apiKey);
const environmentClient = new CliApiClient(apiUrl, devEnv.data.apiKey);
return (
<DevUI
@@ -188,7 +173,7 @@ type DevProps = {
config: ResolvedConfig;
apiUrl: string;
apiKey: string;
environmentClient: ApiClient;
environmentClient: CliApiClient;
projectName: string;
debuggerOn: boolean;
debugOtel: boolean;
@@ -623,119 +608,3 @@ function WebsocketFactory(apiKey: string) {
}
};
}
function createTaskFileImports(taskFiles: TaskFile[]) {
return taskFiles
.map(
(taskFile) =>
`import * as ${taskFile.importName} from "./${taskFile.importPath}"; TaskFileImports["${
taskFile.importName
}"] = ${taskFile.importName}; TaskFiles["${taskFile.importName}"] = ${JSON.stringify(
taskFile
)};`
)
.join("\n");
}
// Find all the top-level .js or .ts files in the trigger directories
async function gatherTaskFiles(config: ResolvedConfig): Promise<Array<TaskFile>> {
const taskFiles: Array<TaskFile> = [];
for (const triggerDir of config.triggerDirectories) {
const files = await fs.promises.readdir(triggerDir, { withFileTypes: true });
for (const file of files) {
if (!file.isFile()) continue;
if (!file.name.endsWith(".js") && !file.name.endsWith(".ts")) continue;
const fullPath = join(triggerDir, file.name);
const filePath = relative(config.projectDir, fullPath);
const importPath = filePath.replace(/\.(js|ts)$/, "");
const importName = importPath.replace(/\//g, "_");
taskFiles.push({ triggerDir, importPath, importName, filePath });
}
}
return taskFiles;
}
async function getConfigPath(dir: string): Promise<string> {
const path = await findUp(CONFIG_FILES, { cwd: dir });
if (!path) {
throw new Error("No config file found.");
}
return path;
}
async function readConfig(path: string): Promise<ResolvedConfig> {
try {
// import the config file
const userConfigModule = await import(`${pathToFileURL(path).href}?_ts=${Date.now()}`);
const rawConfig = await normalizeConfig(userConfigModule ? userConfigModule.default : {});
const config = ConfigSchema.parse(rawConfig);
return resolveConfig(path, config);
} catch (error) {
console.error(`Failed to load config file at ${path}`);
throw error;
}
}
async function resolveConfig(path: string, config: Config): Promise<ResolvedConfig> {
if (!config.triggerDirectories) {
config.triggerDirectories = await findTriggerDirectories(path);
}
config.triggerDirectories = resolveTriggerDirectories(config.triggerDirectories);
if (!config.triggerUrl) {
config.triggerUrl = CLOUD_API_URL;
}
if (!config.projectDir) {
config.projectDir = dirname(path);
}
return config as ResolvedConfig;
}
async function normalizeConfig(config: any): Promise<any> {
if (typeof config === "function") {
config = config();
}
return await config;
}
function resolveTriggerDirectories(dirs: string[]): string[] {
return dirs.map((dir) => resolve(dir));
}
const IGNORED_DIRS = ["node_modules", ".git", "dist", "build"];
async function findTriggerDirectories(filePath: string): Promise<string[]> {
const dirPath = dirname(filePath);
return getTriggerDirectories(dirPath);
}
async function getTriggerDirectories(dirPath: string): Promise<string[]> {
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
const triggerDirectories: string[] = [];
for (const entry of entries) {
if (!entry.isDirectory() || IGNORED_DIRS.includes(entry.name)) continue;
const fullPath = join(dirPath, entry.name);
if (entry.name === "trigger") {
triggerDirectories.push(fullPath);
}
triggerDirectories.push(...(await getTriggerDirectories(fullPath)));
}
return triggerDirectories;
}
+3 -3
View File
@@ -1,12 +1,12 @@
import { intro, log, outro, select, spinner } from "@clack/prompts";
import open from "open";
import pRetry, { AbortError } from "p-retry";
import { ApiClient } from "../apiClient.js";
import { ApiUrlOptionsSchema } from "../cli/index.js";
import { chalkLink } from "../utilities/colors.js";
import { readAuthConfigFile, writeAuthConfigFile } from "../utilities/configFiles.js";
import { logger } from "../utilities/logger.js";
import { whoAmI } from "./whoami.js";
import { CliApiClient } from "../apiClient.js";
export async function loginCommand(options: any) {
const result = ApiUrlOptionsSchema.safeParse(options);
@@ -29,7 +29,7 @@ export type LoginResult =
};
export async function login(apiUrl: string): Promise<LoginResult> {
const apiClient = new ApiClient(apiUrl);
const apiClient = new CliApiClient(apiUrl);
intro("Logging in to Trigger.dev");
@@ -118,7 +118,7 @@ export async function login(apiUrl: string): Promise<LoginResult> {
}
}
async function getPersonalAccessToken(apiClient: ApiClient, authorizationCode: string) {
async function getPersonalAccessToken(apiClient: CliApiClient, authorizationCode: string) {
const token = await apiClient.getPersonalAccessToken(authorizationCode);
if (!token.success) {
+10 -3
View File
@@ -1,5 +1,4 @@
import { note, spinner } from "@clack/prompts";
import { ApiClient } from "../apiClient.js";
import { chalkLink } from "../utilities/colors.js";
import { logger } from "../utilities/logger.js";
import { isLoggedIn } from "../utilities/session.js";
@@ -7,6 +6,7 @@ import { Command } from "commander";
import { printInitialBanner } from "../utilities/initialBanner.js";
import { CommonCommandOptions } from "../cli/common.js";
import { z } from "zod";
import { CliApiClient } from "../apiClient.js";
type WhoAmIResult =
| {
@@ -55,7 +55,11 @@ export async function whoAmI(options?: WhoamiCommandOptions): Promise<WhoAmIResu
const authentication = await isLoggedIn();
if (!authentication.ok) {
loadingSpinner.stop("You must login first. Use `trigger.dev login` to login.");
if (authentication.error === "fetch failed") {
loadingSpinner.stop("Fetch failed. Platform down?");
} else {
loadingSpinner.stop("You must login first. Use `trigger.dev login` to login.");
}
return {
success: false,
@@ -63,7 +67,10 @@ export async function whoAmI(options?: WhoamiCommandOptions): Promise<WhoAmIResu
};
}
const apiClient = new ApiClient(authentication.config.apiUrl, authentication.config.accessToken);
const apiClient = new CliApiClient(
authentication.config.apiUrl,
authentication.config.accessToken
);
const userData = await apiClient.whoAmI();
if (!userData.success) {
+1
View File
@@ -10,3 +10,4 @@ export const PKG_ROOT = path.join(distPath, "../");
export const COMMAND_NAME = "trigger.dev";
export const CLOUD_WEB_URL = "https://cloud.trigger.dev";
export const CLOUD_API_URL = "https://api.trigger.dev";
export const CONFIG_FILES = ["trigger.config.js", "trigger.config.mjs"];
+1 -1
View File
@@ -532,7 +532,7 @@ class TaskRunProcess {
}
taskRunCompletedNotification(completion: TaskRunExecutionResult, execution: TaskRunExecution) {
if (!completion.ok && typeof completion.retry === "undefined") {
if (!completion.ok && typeof completion.retry !== "undefined") {
return;
}
+378
View File
@@ -0,0 +1,378 @@
// import "source-map-support/register";
import { TracingSDK } from "@trigger.dev/core/v3/otel";
// import { OpenAIInstrumentation } from "@traceloop/instrumentation-openai";
// IMPORTANT: this needs to be the first import to work properly
// WARNING: [WARNING] Constructing "ImportInTheMiddle" will crash at run-time because it's an import namespace object, not a constructor [call-import-namespace]
// TODO: https://github.com/open-telemetry/opentelemetry-js/issues/3954
const tracingSDK = new TracingSDK({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? "http://0.0.0.0:4318",
resource: new Resource({
[SemanticInternalAttributes.CLI_VERSION]: packageJson.version,
}),
instrumentations: [
// new OpenAIInstrumentation(),
],
diagLogLevel: (process.env.OTEL_LOG_LEVEL as TracingDiagnosticLogLevel) ?? "none",
});
const otelTracer = tracingSDK.getTracer("trigger-prod-worker", packageJson.version);
const otelLogger = tracingSDK.getLogger("trigger-prod-worker", packageJson.version);
import { SpanKind } from "@opentelemetry/api";
import {
ConsoleInterceptor,
ProdRuntimeManager,
OtelTaskLogger,
SemanticInternalAttributes,
TaskMetadataWithFilePath,
TaskRunContext,
TaskRunErrorCodes,
TaskRunExecution,
TaskRunExecutionRetry,
TriggerTracer,
ZodMessageHandler,
ZodMessageSender,
accessoryAttributes,
calculateNextRetryDelay,
childToWorkerMessages,
logger,
parseError,
runtime,
taskContextManager,
workerToChildMessages,
type BackgroundWorkerProperties,
} from "@trigger.dev/core/v3";
import * as packageJson from "../package.json";
import { Resource } from "@opentelemetry/resources";
import { flattenAttributes } from "@trigger.dev/core/v3";
import { TaskMetadataWithFunctions } from "./types";
import { TracingDiagnosticLogLevel } from "@trigger.dev/core/v3/otel/tracingSDK";
const tracer = new TriggerTracer({ tracer: otelTracer, logger: otelLogger });
const consoleInterceptor = new ConsoleInterceptor(otelLogger);
const sender = new ZodMessageSender({
schema: childToWorkerMessages,
sender: async (message) => {
process.send?.(message);
},
});
const prodRuntimeManager = new ProdRuntimeManager(sender);
runtime.setGlobalRuntimeManager(prodRuntimeManager);
const otelTaskLogger = new OtelTaskLogger({
logger: otelLogger,
tracer: tracer,
level: "info",
});
logger.setGlobalTaskLogger(otelTaskLogger);
type TaskFileImport = Record<string, unknown>;
const TaskFileImports: Record<string, TaskFileImport> = {};
const TaskFiles: Record<string, string> = {};
__TASKS__;
declare const __TASKS__: Record<string, string>;
class TaskExecutor {
constructor(public task: TaskMetadataWithFunctions) {}
async determineRetrying(
execution: TaskRunExecution,
error: unknown
): Promise<TaskRunExecutionRetry | undefined> {
if (!this.task.retry) {
return;
}
const retry = this.task.retry;
const delay = calculateNextRetryDelay(retry, execution.attempt.number);
return typeof delay === "undefined" ? undefined : { timestamp: Date.now() + delay, delay };
}
async execute(
execution: TaskRunExecution,
worker: BackgroundWorkerProperties,
traceContext: Record<string, unknown>
) {
const parsedPayload = JSON.parse(execution.run.payload);
const ctx = TaskRunContext.parse(execution);
const attemptMessage = `Attempt ${execution.attempt.number}`;
const output = await taskContextManager.runWith(
{
ctx,
payload: parsedPayload,
worker,
},
async () => {
tracingSDK.asyncResourceDetector.resolveWithAttributes({
...taskContextManager.attributes,
[SemanticInternalAttributes.SDK_VERSION]: this.task.packageVersion,
[SemanticInternalAttributes.SDK_LANGUAGE]: "typescript",
});
return await tracer.startActiveSpan(
attemptMessage,
async (span) => {
return await consoleInterceptor.intercept(console, async () => {
const init = await this.#callTaskInit(parsedPayload, ctx);
try {
const output = await this.#callRun(parsedPayload, ctx, init);
span.setAttributes(flattenAttributes(output, SemanticInternalAttributes.OUTPUT));
return output;
} finally {
await this.#callTaskCleanup(parsedPayload, ctx, init);
}
});
},
{
kind: SpanKind.CONSUMER,
attributes: {
[SemanticInternalAttributes.STYLE_ICON]: "attempt",
...flattenAttributes(parsedPayload, SemanticInternalAttributes.PAYLOAD),
...accessoryAttributes({
items: [
{
text: ctx.task.filePath,
},
{
text: `${ctx.task.exportName}.run()`,
},
],
style: "codepath",
}),
},
},
tracer.extractContext(traceContext)
);
}
);
return { output: JSON.stringify(output), outputType: "application/json" };
}
async #callRun(payload: unknown, ctx: TaskRunContext, init: unknown) {
const runFn = this.task.fns.run;
const middlewareFn = this.task.fns.middleware;
if (!runFn) {
throw new Error("Task does not have a run function");
}
if (!middlewareFn) {
return runFn({ payload, ctx });
}
return middlewareFn({ payload, ctx, next: async () => runFn({ payload, ctx, init }) });
}
async #callTaskInit(payload: unknown, ctx: TaskRunContext) {
const initFn = this.task.fns.init;
if (!initFn) {
return {};
}
return tracer.startActiveSpan("init", async (span) => {
return await initFn({ payload, ctx });
});
}
async #callTaskCleanup(payload: unknown, ctx: TaskRunContext, init: unknown) {
const cleanupFn = this.task.fns.cleanup;
if (!cleanupFn) {
return;
}
return tracer.startActiveSpan("cleanup", async (span) => {
return await cleanupFn({ payload, ctx, init });
});
}
}
function getTasks(): Array<TaskMetadataWithFunctions> {
const result: Array<TaskMetadataWithFunctions> = [];
for (const [importName, taskFile] of Object.entries(TaskFiles)) {
const fileImports = TaskFileImports[importName];
for (const [exportName, task] of Object.entries(fileImports ?? {})) {
if ((task as any).__trigger) {
result.push({
id: (task as any).__trigger.id,
exportName,
packageVersion: (task as any).__trigger.packageVersion,
filePath: (taskFile as any).filePath,
queue: (task as any).__trigger.queue,
retry: (task as any).__trigger.retry,
fns: (task as any).__trigger.fns,
});
}
}
}
return result;
}
function getTaskMetadata(): Array<TaskMetadataWithFilePath> {
const result = getTasks();
// Remove the functions from the metadata
return result.map((task) => {
const { fns, ...metadata } = task;
return metadata;
});
}
const tasks = getTasks();
runtime.registerTasks(tasks);
const taskExecutors: Map<string, TaskExecutor> = new Map();
for (const task of tasks) {
taskExecutors.set(task.id, new TaskExecutor(task));
}
let _execution: TaskRunExecution | undefined;
let _isRunning = false;
const handler = new ZodMessageHandler({
schema: workerToChildMessages,
messages: {
EXECUTE_TASK_RUN: async ({ execution, traceContext, metadata }) => {
if (_isRunning) {
console.error("Worker is already running a task");
await sender.send("TASK_RUN_COMPLETED", {
execution,
result: {
ok: false,
id: execution.attempt.id,
error: {
type: "INTERNAL_ERROR",
code: TaskRunErrorCodes.TASK_ALREADY_RUNNING,
},
},
});
return;
}
process.title = `trigger-prod-worker: ${execution.task.id} ${execution.run.id}`;
const executor = taskExecutors.get(execution.task.id);
if (!executor) {
console.error(`Could not find executor for task ${execution.task.id}`);
await sender.send("TASK_RUN_COMPLETED", {
execution,
result: {
ok: false,
id: execution.attempt.id,
error: {
type: "INTERNAL_ERROR",
code: TaskRunErrorCodes.COULD_NOT_FIND_EXECUTOR,
},
},
});
return;
}
try {
_execution = execution;
_isRunning = true;
const result = await executor.execute(execution, metadata, traceContext);
return sender.send("TASK_RUN_COMPLETED", {
execution,
result: {
id: execution.attempt.id,
ok: true,
...result,
},
});
} catch (e) {
return sender.send("TASK_RUN_COMPLETED", {
execution,
result: {
id: execution.attempt.id,
ok: false,
error: parseError(e),
retry: await executor.determineRetrying(execution, e),
},
});
} finally {
_execution = undefined;
_isRunning = false;
}
},
TASK_RUN_COMPLETED_NOTIFICATION: async ({ completion, execution }) => {
prodRuntimeManager.resumeTask(completion, execution);
},
CLEANUP: async ({ flush, kill }) => {
if (kill) {
await tracingSDK.flush();
// Now we need to exit the process
await sender.send("READY_TO_DISPOSE", undefined);
} else {
if (flush) {
await tracingSDK.flush();
}
}
},
},
});
process.on("message", async (msg: any) => {
await handler.handleMessage(msg);
});
sender.send("TASKS_READY", { tasks: getTaskMetadata() }).catch((err) => {
console.error("Failed to send TASKS_READY message", err);
});
process.title = "trigger-prod-worker";
async function asyncHeartbeat(initialDelayInSeconds: number = 30, intervalInSeconds: number = 5) {
async function _doHeartbeat() {
while (true) {
if (_isRunning && _execution) {
try {
await sender.send("TASK_HEARTBEAT", { id: _execution.attempt.id });
} catch (err) {
console.error("Failed to send HEARTBEAT message", err);
}
}
await new Promise((resolve) => setTimeout(resolve, 1000 * intervalInSeconds));
}
}
// Wait for the initial delay
await new Promise((resolve) => setTimeout(resolve, 1000 * initialDelayInSeconds));
// Wait for 5 seconds before the next execution
return _doHeartbeat();
}
// Start the async interval after initial delay
asyncHeartbeat(5).catch((err) => {
console.error("Failed to start asyncHeartbeat", err);
});
+317
View File
@@ -0,0 +1,317 @@
import {
CoordinatorToProdWorkerEvents,
ProdWorkerToCoordinatorEvents,
TaskResource,
} from "@trigger.dev/core/v3";
import { HttpReply, getTextBody, SimpleLogger, getRandomPortNumber } from "@trigger.dev/core-apps";
import { createServer } from "node:http";
import { io, Socket } from "socket.io-client";
import { ProdBackgroundWorker } from "./prod/backgroundWorker";
const HTTP_SERVER_PORT = Number(process.env.HTTP_SERVER_PORT || getRandomPortNumber());
const COORDINATOR_HOST = process.env.COORDINATOR_HOST || "127.0.0.1";
const COORDINATOR_PORT = Number(process.env.COORDINATOR_PORT || 50080);
const MACHINE_NAME = process.env.MACHINE_NAME || "local";
const POD_NAME = process.env.POD_NAME || "some-pod";
const SHORT_HASH = process.env.TRIGGER_CONTENT_HASH!.slice(0, 9);
const logger = new SimpleLogger(`[${MACHINE_NAME}][${SHORT_HASH}]`);
class ProdWorker {
private apiUrl = process.env.TRIGGER_API_URL!;
private apiKey = process.env.TRIGGER_API_KEY!;
private contentHash = process.env.TRIGGER_CONTENT_HASH!;
private projectDir = process.env.TRIGGER_PROJECT_DIR!;
private projectRef = process.env.TRIGGER_PROJECT_REF!;
private envId = process.env.TRIGGER_ENV_ID!;
private cliPackageVersion = process.env.TRIGGER_CLI_PACKAGE_VERSION!;
private attemptId = process.env.TRIGGER_ATTEMPT_ID || "index-only";
private executing = false;
private completed = false;
#httpPort: number;
#backgroundWorker: ProdBackgroundWorker;
#httpServer: ReturnType<typeof createServer>;
#coordinatorSocket: Socket<CoordinatorToProdWorkerEvents, ProdWorkerToCoordinatorEvents>;
constructor(
port: number,
private host = "0.0.0.0"
) {
this.#coordinatorSocket = this.#createCoordinatorSocket();
this.#backgroundWorker = new ProdBackgroundWorker(this.#getWorkerEntryPath(this.contentHash), {
projectDir: this.projectDir,
env: {
TRIGGER_API_URL: this.apiUrl,
TRIGGER_API_KEY: this.apiKey,
},
contentHash: this.contentHash,
});
this.#backgroundWorker.onTaskHeartbeat.attach((attemptFriendlyId) => {
this.#coordinatorSocket.emit("TASK_HEARTBEAT", { version: "v1", attemptFriendlyId });
});
this.#backgroundWorker.onWaitForBatch.attach((message) => {
this.#coordinatorSocket.emit("WAIT_FOR_BATCH", { version: "v1", ...message });
});
this.#backgroundWorker.onWaitForDuration.attach((message) => {
this.#coordinatorSocket.emit(
"WAIT_FOR_DURATION",
{ version: "v1", ...message },
({ success }) => {
logger.log("WAIT_FOR_DURATION", { success });
}
);
});
this.#backgroundWorker.onWaitForTask.attach((message) => {
this.#coordinatorSocket.emit("WAIT_FOR_TASK", { version: "v1", ...message });
});
this.#httpPort = port;
this.#httpServer = this.#createHttpServer();
}
#createCoordinatorSocket() {
const socket: Socket<CoordinatorToProdWorkerEvents, ProdWorkerToCoordinatorEvents> = io(
`ws://${COORDINATOR_HOST}:${COORDINATOR_PORT}/prod-worker`,
{
transports: ["websocket"],
extraHeaders: {
"x-machine-name": MACHINE_NAME,
"x-pod-name": POD_NAME,
"x-trigger-content-hash": this.contentHash,
"x-trigger-cli-package-version": this.cliPackageVersion,
"x-trigger-project-ref": this.projectRef,
"x-trigger-attempt-id": this.attemptId,
"x-trigger-env-id": this.envId,
},
}
);
const logger = new SimpleLogger(`[coordinator][${socket.id ?? "NO_ID"}]`);
socket.on("connect_error", (err) => {
logger.error(`connect_error: ${err.message}`);
});
socket.on("connect", async () => {
logger.log("connect");
if (process.env.INDEX_TASKS === "true") {
const taskResources = await this.#initializeWorker();
const { success } = await socket.emitWithAck("INDEX_TASKS", {
version: "v1",
...taskResources,
});
if (success) {
logger.log("indexing done, shutting down..");
process.exit(0);
} else {
logger.log("indexing failure, shutting down..");
process.exit(1);
}
} else {
socket.emit("READY_FOR_EXECUTION", {
version: "v1",
attemptId: process.env.TRIGGER_ATTEMPT_ID!,
});
}
});
socket.on("disconnect", () => {
logger.log("disconnect");
});
socket.on("RESUME", async (message) => {
logger.log("[RESUME]", message);
for (let i = 0; i < message.completions.length; i++) {
const completion = message.completions[i];
const execution = message.executions[i];
if (!completion || !execution) continue;
this.#backgroundWorker.taskRunCompletedNotification(completion, execution);
}
});
socket.on("EXECUTE_TASK_RUN", async (message, callback) => {
logger.log("[EXECUTE_TASK_RUN]", { attempt: message.payload.execution.attempt });
if (this.executing || this.completed) {
return;
}
this.executing = true;
const completion = await this.#backgroundWorker.executeTaskRun(message.payload);
logger.log("completed", completion);
// TODO: replace ack with emit
callback({ completion });
this.completed = true;
this.executing = false;
setTimeout(() => {
process.exit(0);
}, 1000);
});
return socket;
}
#createHttpServer() {
const httpServer = createServer(async (req, res) => {
logger.log(`[${req.method}]`, req.url);
const reply = new HttpReply(res);
switch (req.url) {
case "/complete":
setTimeout(() => process.exit(0), 1000);
return reply.text("ok");
case "/date":
const date = new Date();
return reply.text(date.toString());
case "/fail":
setTimeout(() => process.exit(1), 1000);
return reply.text("ok");
case "/health":
return reply.text("ok");
case "/whoami":
return reply.text(this.contentHash);
case "/wait":
this.#coordinatorSocket.emit(
"WAIT_FOR_DURATION",
{
version: "v1",
ms: 60_000,
},
({ success }) => {
logger.log("WAIT_FOR_DURATION", { success });
}
);
// this is required when C/Ring established connections
this.#coordinatorSocket.close();
return reply.text("sent WAIT");
case "/connect":
this.#coordinatorSocket.connect();
return reply.empty();
case "/close":
this.#coordinatorSocket.emitWithAck("LOG", {
version: "v1",
text: "close without delay",
});
this.#coordinatorSocket.close();
return reply.empty();
case "/close-delay":
this.#coordinatorSocket.emitWithAck("LOG", {
version: "v1",
text: "close with delay",
});
setTimeout(() => {
this.#coordinatorSocket.close();
}, 200);
return reply.empty();
case "/log":
this.#coordinatorSocket.emitWithAck("LOG", {
version: "v1",
text: await getTextBody(req),
});
return reply.empty();
case "/preStop":
logger.log("should do preStop stuff, e.g. checkpoint and graceful shutdown");
return reply.text("got preStop request");
case "/ready":
this.#coordinatorSocket.emit("READY_FOR_EXECUTION", {
version: "v1",
attemptId: this.attemptId,
});
return reply.empty();
default:
return reply.empty(404);
}
});
httpServer.on("clientError", (err, socket) => {
socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
});
httpServer.on("listening", () => {
logger.log("http server listening on port", this.#httpPort);
});
httpServer.on("error", (error) => {
// @ts-expect-error
if (error.code != "EADDRINUSE") {
return;
}
logger.error(`port ${this.#httpPort} already in use, retrying with random port..`);
this.#httpPort = getRandomPortNumber();
setTimeout(() => {
this.start();
}, 100);
});
return httpServer;
}
#getWorkerEntryPath(contentHash: string) {
return `${contentHash}.mjs`;
}
async #initializeWorker() {
await this.#backgroundWorker.initialize();
let packageVersion: string | undefined;
const taskResources: Array<TaskResource> = [];
if (!this.#backgroundWorker.tasks) {
throw new Error(`Background Worker started without tasks`);
}
for (const task of this.#backgroundWorker.tasks) {
taskResources.push({
id: task.id,
filePath: task.filePath,
exportName: task.exportName,
});
packageVersion = task.packageVersion;
}
if (!packageVersion) {
throw new Error(`Background Worker started without package version`);
}
return {
packageVersion,
tasks: taskResources,
};
}
start() {
this.#httpServer.listen(this.#httpPort, this.host);
}
}
const prodWorker = new ProdWorker(HTTP_SERVER_PORT);
prodWorker.start();
@@ -0,0 +1,514 @@
import {
BackgroundWorkerProperties,
CreateBackgroundWorkerResponse,
ProdTaskRunExecutionPayload,
SemanticInternalAttributes,
TaskMetadataWithFilePath,
TaskRunBuiltInError,
TaskRunErrorCodes,
TaskRunExecution,
TaskRunExecutionPayload,
TaskRunExecutionResult,
ZodMessageHandler,
ZodMessageSender,
childToWorkerMessages,
correctErrorStackTrace,
workerToChildMessages,
} from "@trigger.dev/core/v3";
import { Evt } from "evt";
import { ChildProcess, fork } from "node:child_process";
import { safeDeleteFileSync } from "../utilities/fileSystem";
class UnexpectedExitError extends Error {
constructor(public code: number) {
super(`Unexpected exit with code ${code}`);
this.name = "UnexpectedExitError";
}
}
class CleanupProcessError extends Error {
constructor() {
super("Cancelled");
this.name = "CleanupProcessError";
}
}
type BackgroundWorkerParams = {
env: Record<string, string>;
projectDir: string;
contentHash: string;
debugOtel?: boolean;
};
export class ProdBackgroundWorker {
private _initialized: boolean = false;
private _handler = new ZodMessageHandler({
schema: childToWorkerMessages,
});
public onTaskHeartbeat: Evt<string> = new Evt();
public onWaitForBatch: Evt<{ version?: "v1"; id: string; runs: string[] }> = new Evt();
public onWaitForDuration: Evt<{ version?: "v1"; ms: number }> = new Evt();
public onWaitForTask: Evt<{ version?: "v1"; id: string }> = new Evt();
private _onClose: Evt<void> = new Evt();
public tasks: Array<TaskMetadataWithFilePath> = [];
_taskRunProcesses: Map<string, TaskRunProcess> = new Map();
private _closed: boolean = false;
constructor(
public path: string,
private params: BackgroundWorkerParams
) {}
close() {
if (this._closed) {
return;
}
this._closed = true;
this.onTaskHeartbeat.detach();
// We need to close all the task run processes
for (const taskRunProcess of this._taskRunProcesses.values()) {
taskRunProcess.cleanup(true);
}
// Delete worker files
this._onClose.post();
safeDeleteFileSync(this.path);
safeDeleteFileSync(`${this.path}.map`);
}
async initialize() {
if (this._initialized) {
throw new Error("Worker already initialized");
}
let resolved = false;
this.tasks = await new Promise<Array<TaskMetadataWithFilePath>>((resolve, reject) => {
const child = fork(this.path, {
stdio: [/*stdin*/ "ignore", /*stdout*/ "pipe", /*stderr*/ "pipe", "ipc"],
env: {
...this.params.env,
},
});
// Set a timeout to kill the child process if it doesn't respond
const timeout = setTimeout(() => {
if (resolved) {
return;
}
resolved = true;
child.kill();
reject(new Error("Worker timed out"));
}, 1000);
child.on("message", async (msg: any) => {
const message = this._handler.parseMessage(msg);
if (message.type === "TASKS_READY" && !resolved) {
clearTimeout(timeout);
resolved = true;
resolve(message.payload.tasks);
child.kill();
}
});
child.stdout?.on("data", (data) => {
console.log(data.toString());
});
child.stderr?.on("data", (data) => {
console.error(data.toString());
});
child.on("exit", (code) => {
if (!resolved) {
clearTimeout(timeout);
resolved = true;
reject(new Error(`Worker exited with code ${code}`));
}
});
});
this._initialized = true;
}
getMetadata(workerId: string, version: string): CreateBackgroundWorkerResponse {
return {
contentHash: this.params.contentHash,
id: workerId,
version: version,
};
}
// We need to notify all the task run processes that a task run has completed,
// in case they are waiting for it through triggerAndWait
async taskRunCompletedNotification(
completion: TaskRunExecutionResult,
execution: TaskRunExecution
) {
for (const taskRunProcess of this._taskRunProcesses.values()) {
taskRunProcess.taskRunCompletedNotification(completion, execution);
}
}
async #initializeTaskRunProcess(payload: ProdTaskRunExecutionPayload): Promise<TaskRunProcess> {
const metadata = this.getMetadata(
payload.execution.worker.id,
payload.execution.worker.version
);
if (!this._taskRunProcesses.has(payload.execution.run.id)) {
const taskRunProcess = new TaskRunProcess(
this.path,
{
...this.params.env,
...(payload.environment ?? {}),
},
metadata,
this.params
);
taskRunProcess.onExit.attach(() => {
this._taskRunProcesses.delete(payload.execution.run.id);
});
taskRunProcess.onTaskHeartbeat.attach((id) => {
this.onTaskHeartbeat.post(id);
});
taskRunProcess.onWaitForBatch.attach((message) => {
this.onWaitForBatch.post(message);
});
taskRunProcess.onWaitForDuration.attach((message) => {
this.onWaitForDuration.post(message);
});
taskRunProcess.onWaitForTask.attach((message) => {
this.onWaitForTask.post(message);
});
await taskRunProcess.initialize();
this._taskRunProcesses.set(payload.execution.run.id, taskRunProcess);
}
return this._taskRunProcesses.get(payload.execution.run.id) as TaskRunProcess;
}
// We need to fork the process before we can execute any tasks
async executeTaskRun(payload: ProdTaskRunExecutionPayload): Promise<TaskRunExecutionResult> {
try {
const taskRunProcess = await this.#initializeTaskRunProcess(payload);
const result = await taskRunProcess.executeTaskRun(payload);
// Kill the worker if the task was successful or if it's not going to be retried);
await taskRunProcess.cleanup(result.ok || result.retry === undefined);
if (result.ok) {
return result;
}
const error = result.error;
if (error.type === "BUILT_IN_ERROR") {
const mappedError = await this.#correctError(error, payload.execution);
return {
...result,
error: mappedError,
};
}
return result;
} catch (e) {
if (e instanceof CleanupProcessError) {
return {
id: payload.execution.attempt.id,
ok: false,
retry: undefined,
error: {
type: "INTERNAL_ERROR",
code: TaskRunErrorCodes.TASK_EXECUTION_ABORTED,
},
};
}
if (e instanceof UnexpectedExitError) {
return {
id: payload.execution.attempt.id,
ok: false,
retry: undefined,
error: {
type: "INTERNAL_ERROR",
code: TaskRunErrorCodes.TASK_PROCESS_EXITED_WITH_NON_ZERO_CODE,
},
};
}
return {
id: payload.execution.attempt.id,
ok: false,
retry: undefined,
error: {
type: "INTERNAL_ERROR",
code: TaskRunErrorCodes.TASK_EXECUTION_FAILED,
},
};
}
}
async #correctError(
error: TaskRunBuiltInError,
execution: TaskRunExecution
): Promise<TaskRunBuiltInError> {
return {
...error,
stackTrace: correctErrorStackTrace(error.stackTrace, this.params.projectDir),
};
}
}
class TaskRunProcess {
private _handler = new ZodMessageHandler({
schema: childToWorkerMessages,
});
private _sender: ZodMessageSender<typeof workerToChildMessages>;
private _child: ChildProcess | undefined;
private _attemptPromises: Map<
string,
{ resolver: (value: TaskRunExecutionResult) => void; rejecter: (err?: any) => void }
> = new Map();
private _attemptStatuses: Map<string, "PENDING" | "REJECTED" | "RESOLVED"> = new Map();
private _currentExecution: TaskRunExecution | undefined;
private _isBeingKilled: boolean = false;
public onTaskHeartbeat: Evt<string> = new Evt();
public onExit: Evt<number> = new Evt();
public onWaitForBatch: Evt<{ version?: "v1"; id: string; runs: string[] }> = new Evt();
public onWaitForDuration: Evt<{ version?: "v1"; ms: number }> = new Evt();
public onWaitForTask: Evt<{ version?: "v1"; id: string }> = new Evt();
constructor(
private path: string,
private env: NodeJS.ProcessEnv,
private metadata: BackgroundWorkerProperties,
private worker: BackgroundWorkerParams
) {
this._sender = new ZodMessageSender({
schema: workerToChildMessages,
sender: async (message) => {
if (this._child?.connected && !this._isBeingKilled && !this._child.killed) {
this._child?.send?.(message);
}
},
});
}
async initialize() {
this._child = fork(this.path, {
stdio: [/*stdin*/ "ignore", /*stdout*/ "pipe", /*stderr*/ "pipe", "ipc"],
env: {
...this.env,
OTEL_RESOURCE_ATTRIBUTES: JSON.stringify({
[SemanticInternalAttributes.PROJECT_DIR]: this.worker.projectDir,
}),
...(this.worker.debugOtel ? { OTEL_LOG_LEVEL: "debug" } : {}),
},
});
this._child.on("message", this.#handleMessage.bind(this));
this._child.on("exit", this.#handleExit.bind(this));
this._child.stdout?.on("data", this.#handleLog.bind(this));
this._child.stderr?.on("data", this.#handleStdErr.bind(this));
}
async cleanup(kill: boolean = false) {
if (kill && this._isBeingKilled) {
return;
}
await this._sender.send("CLEANUP", {
flush: true,
kill,
});
this._isBeingKilled = kill;
}
async executeTaskRun(payload: TaskRunExecutionPayload): Promise<TaskRunExecutionResult> {
let resolver: (value: TaskRunExecutionResult) => void;
let rejecter: (err?: any) => void;
const promise = new Promise<TaskRunExecutionResult>((resolve, reject) => {
resolver = resolve;
rejecter = reject;
});
this._attemptStatuses.set(payload.execution.attempt.id, "PENDING");
// @ts-expect-error - We know that the resolver and rejecter are defined
this._attemptPromises.set(payload.execution.attempt.id, { resolver, rejecter });
const { execution, traceContext } = payload;
this._currentExecution = execution;
await this._sender.send("EXECUTE_TASK_RUN", {
execution,
traceContext,
metadata: this.metadata,
});
const result = await promise;
this._currentExecution = undefined;
return result;
}
taskRunCompletedNotification(completion: TaskRunExecutionResult, execution: TaskRunExecution) {
if (!completion.ok && typeof completion.retry !== "undefined") {
return;
}
this._sender.send("TASK_RUN_COMPLETED_NOTIFICATION", {
completion,
execution,
});
}
async #handleMessage(msg: any) {
const message = this._handler.parseMessage(msg);
switch (message.type) {
case "TASK_RUN_COMPLETED": {
const { result, execution } = message.payload;
const promiseStatus = this._attemptStatuses.get(execution.attempt.id);
if (promiseStatus !== "PENDING") {
return;
}
this._attemptStatuses.set(execution.attempt.id, "RESOLVED");
const attemptPromise = this._attemptPromises.get(execution.attempt.id);
if (!attemptPromise) {
return;
}
const { resolver } = attemptPromise;
resolver(result);
break;
}
case "READY_TO_DISPOSE": {
this.#kill();
break;
}
case "TASK_HEARTBEAT": {
this.onTaskHeartbeat.post(message.payload.id);
break;
}
case "TASKS_READY": {
break;
}
case "WAIT_FOR_BATCH": {
this.onWaitForBatch.post(message.payload);
break;
}
case "WAIT_FOR_DURATION": {
this.onWaitForDuration.post(message.payload);
break;
}
case "WAIT_FOR_TASK": {
this.onWaitForTask.post(message.payload);
break;
}
}
}
async #handleExit(code: number) {
// Go through all the attempts currently pending and reject them
for (const [id, status] of this._attemptStatuses.entries()) {
if (status === "PENDING") {
this._attemptStatuses.set(id, "REJECTED");
const attemptPromise = this._attemptPromises.get(id);
if (!attemptPromise) {
continue;
}
const { rejecter } = attemptPromise;
if (this._isBeingKilled) {
rejecter(new CleanupProcessError());
} else {
rejecter(new UnexpectedExitError(code));
}
}
}
this.onExit.post(code);
}
#handleLog(data: Buffer) {
if (!this._currentExecution) {
return;
}
console.log(
`[${this.metadata.version}][${this._currentExecution.run.id}.${
this._currentExecution.attempt.number
}] ${data.toString()}`
);
}
#handleStdErr(data: Buffer) {
if (this._isBeingKilled) {
return;
}
if (!this._currentExecution) {
console.error(`[${this.metadata.version}] ${data.toString()}`);
return;
}
console.error(
`[${this.metadata.version}][${this._currentExecution.run.id}.${
this._currentExecution.attempt.number
}] ${data.toString()}`
);
}
#kill() {
if (this._child && !this._child.killed) {
this._child?.kill();
}
}
}
+7
View File
@@ -8,3 +8,10 @@ export type TaskMetadataWithFunctions = TaskMetadataWithFilePath & {
middleware?: (params: any) => Promise<void>;
};
};
export type TaskFile = {
triggerDir: string;
filePath: string;
importPath: string;
importName: string;
};
+58 -3
View File
@@ -1,12 +1,17 @@
import { mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import path, { dirname } from "node:path";
import xdgAppPaths from "xdg-app-paths";
import { z } from "zod";
import { readJSONFileSync } from "./fileSystem.js";
import { logger } from "./logger.js";
import { findUp } from "find-up";
import { CLOUD_API_URL, CONFIG_FILES } from "../consts.js";
import { pathToFileURL } from "node:url";
import { findTriggerDirectories, resolveTriggerDirectories } from "./taskFiles.js";
import { Config, ResolvedConfig } from "@trigger.dev/core/v3";
function getGlobalConfigFolderPath() {
const configDir = xdgAppPaths(".trigger").config();
const configDir = xdgAppPaths("trigger").config();
return configDir;
}
@@ -20,7 +25,7 @@ export const UserAuthConfigSchema = z.object({
export type UserAuthConfig = z.infer<typeof UserAuthConfigSchema>;
function getAuthConfigFilePath() {
return path.join(getGlobalConfigFolderPath(), "config", "default.json");
return path.join(getGlobalConfigFolderPath(), "default.json");
}
export function writeAuthConfigFile(config: UserAuthConfig) {
@@ -45,3 +50,53 @@ export function readAuthConfigFile(): UserAuthConfig | undefined {
return undefined;
}
}
export async function getConfigPath(dir: string): Promise<string> {
const path = await findUp(CONFIG_FILES, { cwd: dir });
if (!path) {
throw new Error("No config file found.");
}
return path;
}
export async function readConfig(path: string): Promise<ResolvedConfig> {
try {
// import the config file
const userConfigModule = await import(`${pathToFileURL(path).href}?_ts=${Date.now()}`);
const rawConfig = await normalizeConfig(userConfigModule ? userConfigModule.default : {});
const config = Config.parse(rawConfig);
return resolveConfig(path, config);
} catch (error) {
console.error(`Failed to load config file at ${path}`);
throw error;
}
}
export async function resolveConfig(path: string, config: Config): Promise<ResolvedConfig> {
if (!config.triggerDirectories) {
config.triggerDirectories = await findTriggerDirectories(path);
}
config.triggerDirectories = resolveTriggerDirectories(config.triggerDirectories);
if (!config.triggerUrl) {
config.triggerUrl = CLOUD_API_URL;
}
if (!config.projectDir) {
config.projectDir = dirname(path);
}
return config as ResolvedConfig;
}
export async function normalizeConfig(config: any): Promise<any> {
if (typeof config === "function") {
config = config();
}
return await config;
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { ApiClient } from "../apiClient.js";
import { CliApiClient } from "../apiClient.js";
import { readAuthConfigFile } from "./configFiles.js";
export async function isLoggedIn() {
@@ -8,7 +8,7 @@ export async function isLoggedIn() {
return { ok: false as const, error: "You must login first" };
}
const apiClient = new ApiClient(config.apiUrl, config.accessToken);
const apiClient = new CliApiClient(config.apiUrl, config.accessToken);
const userData = await apiClient.whoAmI();
if (!userData.success) {
@@ -0,0 +1,70 @@
import fs from "node:fs";
import { dirname, join, relative, resolve } from "node:path";
import { TaskFile } from "../types";
import { ResolvedConfig } from "@trigger.dev/core/v3";
export function createTaskFileImports(taskFiles: TaskFile[]) {
return taskFiles
.map(
(taskFile) =>
`import * as ${taskFile.importName} from "./${taskFile.importPath}"; TaskFileImports["${
taskFile.importName
}"] = ${taskFile.importName}; TaskFiles["${taskFile.importName}"] = ${JSON.stringify(
taskFile
)};`
)
.join("\n");
}
// Find all the top-level .js or .ts files in the trigger directories
export async function gatherTaskFiles(config: ResolvedConfig): Promise<Array<TaskFile>> {
const taskFiles: Array<TaskFile> = [];
for (const triggerDir of config.triggerDirectories) {
const files = await fs.promises.readdir(triggerDir, { withFileTypes: true });
for (const file of files) {
if (!file.isFile()) continue;
if (!file.name.endsWith(".js") && !file.name.endsWith(".ts")) continue;
const fullPath = join(triggerDir, file.name);
const filePath = relative(config.projectDir, fullPath);
const importPath = filePath.replace(/\.(js|ts)$/, "");
const importName = importPath.replace(/\//g, "_");
taskFiles.push({ triggerDir, importPath, importName, filePath });
}
}
return taskFiles;
}
export function resolveTriggerDirectories(dirs: string[]): string[] {
return dirs.map((dir) => resolve(dir));
}
const IGNORED_DIRS = ["node_modules", ".git", "dist", "build"];
export async function findTriggerDirectories(filePath: string): Promise<string[]> {
const dirPath = dirname(filePath);
return getTriggerDirectories(dirPath);
}
async function getTriggerDirectories(dirPath: string): Promise<string[]> {
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
const triggerDirectories: string[] = [];
for (const entry of entries) {
if (!entry.isDirectory() || IGNORED_DIRS.includes(entry.name)) continue;
const fullPath = join(dirPath, entry.name);
if (entry.name === "trigger") {
triggerDirectories.push(fullPath);
}
triggerDirectories.push(...(await getTriggerDirectories(fullPath)));
}
return triggerDirectories;
}
+3 -1
View File
@@ -25,7 +25,9 @@
"jsx": "react",
"paths": {
"@trigger.dev/core/v3": ["../core/src/v3"],
"@trigger.dev/core/v3/*": ["../core/src/v3/*"]
"@trigger.dev/core/v3/*": ["../core/src/v3/*"],
"@trigger.dev/core-apps": ["../core-apps/src"],
"@trigger.dev/core-apps/*": ["../core-apps/src/*"]
}
},
"exclude": ["node_modules"]
@@ -0,0 +1,16 @@
import { defineConfig } from "tsup";
export default defineConfig({
clean: false,
dts: true,
tsconfig: "tsconfig.json",
splitting: false,
entry: ["src/prod-facade.ts"],
format: ["esm"],
minify: false,
metafile: false,
sourcemap: true,
target: "esnext",
outDir: "dist",
noExternal: ["zod", /traceloop/, /opentelemetry/],
});
+1 -1
View File
@@ -39,7 +39,7 @@
"@types/gradient-string": "^1.1.2",
"@types/inquirer": "^9.0.3",
"@types/mock-fs": "^4.13.1",
"@types/node": "16",
"@types/node": "^18",
"@types/node-fetch": "^2.6.2",
"@types/ws": "^8.5.3",
"rimraf": "^3.0.2",
+40
View File
@@ -0,0 +1,40 @@
{
"name": "@trigger.dev/core-apps",
"description": "Backend core code used across apps",
"private": true,
"license": "MIT",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"module": "./dist/index.mjs",
"files": [
"dist"
],
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./package.json": "./package.json"
},
"scripts": {
"clean": "rimraf dist",
"build": "npm run clean && npm run build:tsup",
"build:tsup": "tsup --dts-resolve",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@trigger.dev/tsconfig": "workspace:*",
"@trigger.dev/tsup": "workspace:*",
"@types/node": "18",
"rimraf": "^3.0.2",
"tsup": "^8.0.1",
"typescript": "^5.3.0"
},
"engines": {
"node": ">=18.0.0"
}
}
+39
View File
@@ -0,0 +1,39 @@
import { IncomingMessage, RequestListener } from "http";
export const getTextBody = (req: IncomingMessage) =>
new Promise<string>((resolve) => {
let body = "";
req.on("readable", () => {
const chunk = req.read();
if (chunk) {
body += chunk;
}
});
req.on("end", () => {
resolve(body);
});
});
export class HttpReply {
constructor(private response: Parameters<RequestListener>[1]) {}
empty(status?: number) {
return this.response.writeHead(status ?? 200).end();
}
text(text: string, status?: number, contentType?: string) {
return this.response
.writeHead(status ?? 200, { "Content-Type": contentType || "text/plain" })
.end(text.endsWith("\n") ? text : `${text}\n`);
}
}
function getRandomInteger(min: number, max: number) {
const intMin = Math.ceil(min);
const intMax = Math.floor(max);
return Math.floor(Math.random() * (intMax - intMin + 1)) + intMin;
}
export function getRandomPortNumber() {
return getRandomInteger(8000, 9999);
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./http";
export * from "./logger";
+35
View File
@@ -0,0 +1,35 @@
export class SimpleLogger {
#debugEnabled = ["1", "true"].includes(process.env.DEBUG ?? "");
constructor(private prefix?: string) {}
log<TFirstArg>(arg0: TFirstArg, ...argN: any[]) {
console.log(...this.#getPrefixedArgs(arg0, ...argN));
return arg0;
}
debug<TFirstArg>(arg0: TFirstArg, ...argN: any[]) {
if (!this.#debugEnabled) {
return arg0;
}
console.debug(...this.#getPrefixedArgs("DEBUG", arg0, ...argN));
return arg0;
}
error<TFirstArg>(arg0: TFirstArg, ...argN: any[]) {
console.error(...this.#getPrefixedArgs(arg0, ...argN));
return arg0;
}
#getPrefixedArgs(...args: any[]) {
if (!this.prefix) {
return args;
}
return [this.prefix, ...args];
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "@trigger.dev/tsconfig/node18.json",
"include": ["src/globals.d.ts", "./src/**/*.ts", "tsup.config.ts"],
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"declaration": false,
"declarationMap": false
},
"exclude": ["node_modules"]
}
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "@trigger.dev/tsconfig/node18.json",
"include": ["src/globals.d.ts", "./src/**/*.ts", "tsup.config.ts", "./test/**/*.ts"],
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"declaration": false,
"declarationMap": false,
"lib": ["DOM", "DOM.Iterable"],
"paths": {
"@trigger.dev/core/v3": ["../core/src/v3"],
"@trigger.dev/core/v3/*": ["../core/src/v3/*"],
"@trigger.dev/tsup/*": ["../../config-packages/tsup/src/*"],
"@trigger.dev/tsup": ["../../config-packages/tsup/src/index"]
}
},
"exclude": ["node_modules"]
}
+6
View File
@@ -0,0 +1,6 @@
import { packageOptions, defineConfig } from "@trigger.dev/tsup";
export default defineConfig({
...packageOptions,
config: "tsconfig.build.json",
});
+2 -1
View File
@@ -74,6 +74,7 @@
"@opentelemetry/sdk-trace-node": "^1.21.0",
"@opentelemetry/semantic-conventions": "^1.21.0",
"humanize-duration": "^3.27.3",
"socket.io": "^4.7.4",
"ulidx": "^2.2.1",
"zod": "3.22.3",
"zod-error": "1.5.0"
@@ -83,7 +84,7 @@
"@trigger.dev/tsup": "workspace:*",
"@types/humanize-duration": "^3.27.1",
"@types/jest": "^29.5.3",
"@types/node": "16",
"@types/node": "^18",
"jest": "^29.6.2",
"rimraf": "^3.0.2",
"ts-jest": "^29.1.1",
+12 -9
View File
@@ -1,15 +1,14 @@
import { context, propagation } from "@opentelemetry/api";
import { zodfetch } from "../../zodfetch";
import {
BatchTriggerTaskRequestBody,
BatchTriggerTaskResponse,
GetBatchResponseBody,
TriggerTaskRequestBody,
TriggerTaskResponse,
} from "../schemas/api";
import { taskContextManager } from "../tasks/taskContextManager";
import { SafeAsyncLocalStorage } from "../utils/safeAsyncLocalStorage";
import { getEnvVar } from "../utils/getEnv";
import {
TriggerTaskRequestBody,
TriggerTaskResponse,
BatchTriggerTaskRequestBody,
BatchTriggerTaskResponse,
} from "../schemas";
export type TriggerOptions = {
spanParentAsLink?: boolean;
@@ -19,10 +18,14 @@ export type TriggerOptions = {
* Trigger.dev v3 API client
*/
export class ApiClient {
private readonly baseUrl: string;
constructor(
private readonly baseUrl: string,
baseUrl: string,
private readonly accessToken: string
) {}
) {
this.baseUrl = baseUrl.replace(/\/$/, "");
}
triggerTask(taskId: string, body: TriggerTaskRequestBody, options?: TriggerOptions) {
return zodfetch(TriggerTaskResponse, `${this.baseUrl}/api/v1/tasks/${taskId}/trigger`, {
+3
View File
@@ -3,9 +3,11 @@ import { BatchTriggerTaskRequestBody, TriggerTaskRequestBody } from "./schemas";
export * from "./schemas";
export * from "./apiClient";
export * from "./zodMessageHandler";
export * from "./zodNamespace";
export * from "./errors";
export * from "./runtime-api";
export * from "./logger-api";
export * from "./types";
export { SemanticInternalAttributes } from "./semanticInternalAttributes";
export { iconStringForSeverity } from "./icons";
export {
@@ -28,6 +30,7 @@ export function parseBatchTriggerTaskRequestBody(body: unknown) {
export { taskContextManager, TaskContextSpanProcessor } from "./tasks/taskContextManager";
export type { RuntimeManager } from "./runtime/manager";
export { DevRuntimeManager } from "./runtime/devRuntimeManager";
export { ProdRuntimeManager } from "./runtime/prodRuntimeManager";
export { TriggerTracer } from "./tracer";
export type { TaskLogger } from "./logger/taskLogger";
@@ -82,9 +82,16 @@ export class DevRuntimeManager implements RuntimeManager {
resumeTask(completion: TaskRunExecutionResult, execution: TaskRunExecution): void {
const wait = this._taskWaits.get(execution.run.id);
if (wait) {
wait.resolve(completion);
this._taskWaits.delete(execution.run.id);
if (!wait) {
return;
}
if (completion.ok) {
wait.resolve(completion);
} else {
wait.reject(completion);
}
this._taskWaits.delete(execution.run.id);
}
}
@@ -0,0 +1,114 @@
import {
BatchTaskRunExecutionResult,
TaskMetadataWithFilePath,
TaskRunContext,
TaskRunExecution,
TaskRunExecutionResult,
childToWorkerMessages,
} from "../schemas";
import { ZodMessageSender } from "../zodMessageHandler";
import { RuntimeManager } from "./manager";
export class ProdRuntimeManager implements RuntimeManager {
_taskWaits: Map<
string,
{ resolve: (value: TaskRunExecutionResult) => void; reject: (err?: any) => void }
> = new Map();
_batchWaits: Map<
string,
{ resolve: (value: BatchTaskRunExecutionResult) => void; reject: (err?: any) => void }
> = new Map();
_tasks: Map<string, TaskMetadataWithFilePath> = new Map();
constructor(private sender: ZodMessageSender<typeof childToWorkerMessages>) {}
disable(): void {
// do nothing
}
registerTasks(tasks: TaskMetadataWithFilePath[]): void {
for (const task of tasks) {
this._tasks.set(task.id, task);
}
}
getTaskMetadata(id: string): TaskMetadataWithFilePath | undefined {
return this._tasks.get(id);
}
async waitForDuration(ms: number): Promise<void> {
if (ms > 30_000) {
// TODO: sender with ack support
await this.sender.send("WAIT_FOR_DURATION", { ms });
// TODO: resolve after resume signal instead
}
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async waitUntil(date: Date): Promise<void> {
return this.waitForDuration(date.getTime() - Date.now());
}
async waitForTask(params: { id: string; ctx: TaskRunContext }): Promise<TaskRunExecutionResult> {
const promise = new Promise<TaskRunExecutionResult>((resolve, reject) => {
this._taskWaits.set(params.id, { resolve, reject });
});
await this.sender.send("WAIT_FOR_TASK", {
id: params.id,
});
return await promise;
}
async waitForBatch(params: {
id: string;
runs: string[];
ctx: TaskRunContext;
}): Promise<BatchTaskRunExecutionResult> {
if (!params.runs.length) {
return Promise.resolve({ id: params.id, items: [] });
}
const promise = Promise.all(
params.runs.map((runId) => {
return new Promise<TaskRunExecutionResult>((resolve, reject) => {
this._taskWaits.set(runId, { resolve, reject });
});
})
);
await this.sender.send("WAIT_FOR_BATCH", {
id: params.id,
runs: params.runs,
});
const results = await promise;
return {
id: params.id,
items: results,
};
}
resumeTask(completion: TaskRunExecutionResult, execution: TaskRunExecution): void {
const wait = this._taskWaits.get(execution.run.id);
if (!wait) {
return;
}
if (completion.ok) {
wait.resolve(completion);
} else {
wait.reject(completion);
}
this._taskWaits.delete(execution.run.id);
}
}
+14 -1
View File
@@ -1,5 +1,5 @@
import { z } from "zod";
import { BackgroundWorkerMetadata } from "./resources";
import { BackgroundWorkerMetadata, ImageDetailsMetadata } from "./resources";
import { QueueOptions } from "./messages";
export const WhoAmIResponseSchema = z.object({
@@ -88,3 +88,16 @@ export const GetEnvironmentVariablesResponseBody = z.object({
export type GetEnvironmentVariablesResponseBody = z.infer<
typeof GetEnvironmentVariablesResponseBody
>;
export const CreateImageDetailsRequestBody = z.object({
metadata: ImageDetailsMetadata,
});
export type CreateImageDetailsRequestBody = z.infer<typeof CreateImageDetailsRequestBody>;
export const CreateImageDetailsResponse = z.object({
id: z.string(),
contentHash: z.string(),
});
export type CreateImageDetailsResponse = z.infer<typeof CreateImageDetailsResponse>;
+1
View File
@@ -3,6 +3,7 @@ export * from "./api";
export * from "./resources";
export * from "./common";
export * from "./messages";
export * from "./schemas";
export * from "./style";
export * from "./fetch";
export * from "./eventFilter";
+37
View File
@@ -9,11 +9,35 @@ export const TaskRunExecutionPayload = z.object({
export type TaskRunExecutionPayload = z.infer<typeof TaskRunExecutionPayload>;
export const ProdTaskRunExecution = TaskRunExecution.extend({
worker: z.object({
id: z.string(),
contentHash: z.string(),
version: z.string(),
}),
});
export type ProdTaskRunExecution = z.infer<typeof ProdTaskRunExecution>;
export const ProdTaskRunExecutionPayload = z.object({
execution: ProdTaskRunExecution,
traceContext: z.record(z.unknown()),
environment: z.record(z.string()).optional(),
});
export type ProdTaskRunExecutionPayload = z.infer<typeof ProdTaskRunExecutionPayload>;
export const BackgroundWorkerServerMessages = z.discriminatedUnion("type", [
z.object({
type: z.literal("EXECUTE_RUNS"),
payloads: z.array(TaskRunExecutionPayload),
}),
z.object({
type: z.literal("SCHEDULE_ATTEMPT"),
id: z.string(),
image: z.string(),
envId: z.string(),
}),
]);
export type BackgroundWorkerServerMessages = z.infer<typeof BackgroundWorkerServerMessages>;
@@ -178,4 +202,17 @@ export const childToWorkerMessages = {
id: z.string(),
}),
READY_TO_DISPOSE: z.undefined(),
WAIT_FOR_DURATION: z.object({
version: z.literal("v1").default("v1"),
ms: z.number(),
}),
WAIT_FOR_TASK: z.object({
version: z.literal("v1").default("v1"),
id: z.string(),
}),
WAIT_FOR_BATCH: z.object({
version: z.literal("v1").default("v1"),
id: z.string(),
runs: z.string().array(),
}),
};
@@ -19,3 +19,10 @@ export const BackgroundWorkerMetadata = z.object({
});
export type BackgroundWorkerMetadata = z.infer<typeof BackgroundWorkerMetadata>;
export const ImageDetailsMetadata = z.object({
contentHash: z.string(),
imageTag: z.string(),
});
export type ImageDetailsMetadata = z.infer<typeof ImageDetailsMetadata>;
+117
View File
@@ -0,0 +1,117 @@
import { z } from "zod";
import { RequireKeys } from "../types";
import { TaskRunExecution, TaskRunExecutionResult } from "./common";
import { ProdTaskRunExecution } from "./messages";
import { TaskResource } from "./resources";
export const Config = z.object({
project: z.string(),
triggerDirectories: z.string().array().optional(),
triggerUrl: z.string().optional(),
projectDir: z.string().optional(),
});
export type Config = z.infer<typeof Config>;
export type ResolvedConfig = RequireKeys<
Config,
"triggerDirectories" | "triggerUrl" | "projectDir"
>;
export const Machine = z.object({
cpu: z.string().default("1").optional(),
memory: z.string().default("500Mi").optional(),
});
export type Machine = z.infer<typeof Machine>;
export const ProviderToPlatformMessages = {
LOG: z.object({
version: z.literal("v1").default("v1"),
data: z.string(),
}),
};
export const PlatformToProviderMessages = {
HEALTH: z.object({
// TODO: callback: (ack: { status: "ok" }) => void
version: z.literal("v1").default("v1"),
}),
INDEX: z.object({
version: z.literal("v1").default("v1"),
imageTag: z.string(),
contentHash: z.string(),
envId: z.string(),
}),
INVOKE: z.object({
version: z.literal("v1").default("v1"),
name: z.string(),
machine: Machine,
}),
RESTORE: z.object({
version: z.literal("v1").default("v1"),
id: z.string(),
attemptId: z.string(),
type: z.enum(["DOCKER", "KUBERNETES"]),
location: z.string(),
reason: z.string().optional(),
}),
DELETE: z.object({
// TODO: callback: (ack: { message: string }) => void
version: z.literal("v1").default("v1"),
name: z.string(),
}),
GET: z.object({
version: z.literal("v1").default("v1"),
name: z.string(),
}),
};
export const CoordinatorToPlatformMessages = {
LOG: z.object({
version: z.literal("v1").default("v1"),
metadata: z.any(),
text: z.string(),
}),
CREATE_WORKER: z.object({
version: z.literal("v1").default("v1"),
projectRef: z.string(),
envId: z.string(),
metadata: z.object({
cliPackageVersion: z.string(),
contentHash: z.string(),
packageVersion: z.string(),
tasks: TaskResource.array(),
}),
}),
// TODO: callback: (ack: { success: false } | { success: true; payload: ProdTaskRunExecutionPayload }) => void
READY_FOR_EXECUTION: z.object({
version: z.literal("v1").default("v1"),
attemptId: z.string(),
}),
TASK_RUN_COMPLETED: z.object({
version: z.literal("v1").default("v1"),
execution: ProdTaskRunExecution,
completion: TaskRunExecutionResult,
}),
TASK_HEARTBEAT: z.object({
version: z.literal("v1").default("v1"),
attemptFriendlyId: z.string(),
}),
CHECKPOINT_CREATED: z.object({
version: z.literal("v1").default("v1"),
attemptId: z.string(),
docker: z.boolean(),
location: z.string(),
reason: z.string().optional(),
}),
};
export const PlatformToCoordinatorMessages = {
RESUME: z.object({
version: z.literal("v1").default("v1"),
attemptId: z.string(),
image: z.string(),
completions: TaskRunExecutionResult.array(),
executions: TaskRunExecution.array(),
}),
};
+2
View File
@@ -0,0 +1,2 @@
export * from "./socketIo";
export * from "./utils";
+129
View File
@@ -0,0 +1,129 @@
import {
TaskResource,
ProdTaskRunExecutionPayload,
TaskRunExecutionResult,
TaskRunExecution,
ProdTaskRunExecution,
} from "../schemas";
export type VersionedMessage<TMessage> = { version: "v1" } & TMessage;
// provider <--> platform
export interface ProviderClientToServerEvents {
LOG: (message: VersionedMessage<{ data: string }>) => void;
}
export interface ProviderServerToClientEvents {
HEALTH: (message: VersionedMessage<{}>, callback: (ack: { status: "ok" }) => void) => void;
INDEX: (
message: VersionedMessage<{ imageTag: string; contentHash: string; envId: string }>
) => void;
RESTORE: (
message: VersionedMessage<{
id: string;
attemptId: string;
type: "DOCKER" | "KUBERNETES";
location: string;
reason?: string;
}>
) => void;
DELETE: (
message: VersionedMessage<{ name: string }>,
callback: (ack: { message: string }) => void
) => void;
GET: (message: VersionedMessage<{ name: string }>) => void;
}
// coordinator <--> prod worker
export interface ProdWorkerToCoordinatorEvents {
LOG: (message: VersionedMessage<{ text: string }>, callback: () => {}) => void;
INDEX_TASKS: (
message: VersionedMessage<{
tasks: TaskResource[];
packageVersion: string;
}>,
callback: (params: { success: boolean }) => {}
) => void;
READY_FOR_EXECUTION: (message: VersionedMessage<{ attemptId: string }>) => void;
TASK_HEARTBEAT: (message: VersionedMessage<{ attemptFriendlyId: string }>) => void;
WAIT_FOR_BATCH: (message: VersionedMessage<{ id: string; runs: string[] }>) => void;
WAIT_FOR_DURATION: (
message: VersionedMessage<{ ms: number }>,
callback: (ack: { success: boolean }) => void
) => void;
WAIT_FOR_TASK: (message: VersionedMessage<{ id: string }>) => void;
}
export interface CoordinatorToProdWorkerEvents {
RESUME: (
message: VersionedMessage<{
attemptId: string;
image: string;
completions: TaskRunExecutionResult[];
executions: TaskRunExecution[];
}>
) => void;
EXECUTE_TASK_RUN: (
message: VersionedMessage<{ payload: ProdTaskRunExecutionPayload }>,
callback: (ack: { completion: TaskRunExecutionResult }) => void
) => void;
}
export interface ProdWorkerSocketData {
cliPackageVersion: string;
contentHash: string;
projectRef: string;
envId: string;
attemptId: string;
podName: string;
}
// coordinator <--> platform
export interface CoordinatorToPlatformEvents {
LOG: (message: VersionedMessage<{ metadata: any; text: string }>) => void;
CREATE_WORKER: (
message: VersionedMessage<{
projectRef: string;
envId: string;
metadata: {
cliPackageVersion: string;
contentHash: string;
packageVersion: string;
tasks: TaskResource[];
};
}>,
callback: (ack: { success: boolean }) => void
) => void;
READY_FOR_EXECUTION: (
message: VersionedMessage<{ attemptId: string }>,
callback: (
ack: { success: false } | { success: true; payload: ProdTaskRunExecutionPayload }
) => void
) => void;
TASK_RUN_COMPLETED: (
message: VersionedMessage<{
execution: ProdTaskRunExecution;
completion: TaskRunExecutionResult;
}>
) => void;
TASK_HEARTBEAT: (message: VersionedMessage<{ attemptFriendlyId: string }>) => void;
CHECKPOINT_CREATED: (
message: VersionedMessage<{
attemptId: string;
docker: boolean;
location: string;
reason?: string;
}>
) => void;
}
export interface PlatformToCoordinatorEvents {
RESUME: (
message: VersionedMessage<{
attemptId: string;
image: string;
completions: TaskRunExecutionResult[];
executions: TaskRunExecution[];
}>
) => void;
}
+43 -3
View File
@@ -5,7 +5,7 @@ export interface ZodMessageCatalogSchema {
}
export type ZodMessageHandlers<TCatalogSchema extends ZodMessageCatalogSchema> = Partial<{
[K in keyof TCatalogSchema]: (payload: z.infer<TCatalogSchema[K]>) => Promise<void>;
[K in keyof TCatalogSchema]: (payload: z.infer<TCatalogSchema[K]>) => Promise<any>;
}>;
export type ZodMessageHandlerOptions<TMessageCatalog extends ZodMessageCatalogSchema> = {
@@ -31,6 +31,10 @@ const messageSchema = z.object({
payload: z.unknown(),
});
interface EventEmitterLike {
on(eventName: string | symbol, listener: (...args: any[]) => void): this;
}
export class ZodMessageHandler<TMessageCatalog extends ZodMessageCatalogSchema> {
#schema: TMessageCatalog;
#handlers: ZodMessageHandlers<TMessageCatalog> | undefined;
@@ -50,10 +54,13 @@ export class ZodMessageHandler<TMessageCatalog extends ZodMessageCatalogSchema>
const handler = this.#handlers[parsedMessage.type];
if (!handler) {
throw new Error(`Unknown message type: ${String(parsedMessage.type)}`);
console.error(`No handler for message type: ${String(parsedMessage.type)}`);
return;
}
await handler(parsedMessage.payload);
const ack = await handler(parsedMessage.payload);
return ack;
}
public parseMessage(message: unknown): MessageFromCatalog<TMessageCatalog> {
@@ -80,6 +87,35 @@ export class ZodMessageHandler<TMessageCatalog extends ZodMessageCatalogSchema>
payload: parsedPayload.data,
};
}
public registerHandlers(emitter: EventEmitterLike, logger?: (...args: any[]) => void) {
const log = logger ?? console.log;
if (!this.#handlers) {
log("No handlers provided");
return;
}
for (const eventName of Object.keys(this.#schema)) {
emitter.on(eventName, async (message: any, callback?: any): Promise<void> => {
log(`handling ${eventName}`, message);
let ack;
if ("payload" in message) {
ack = await this.handleMessage({ type: eventName, ...message });
} else {
// Handle messages not sent by ZodMessageSender
const { version, ...payload } = message;
ack = await this.handleMessage({ type: eventName, version, payload });
}
if (callback && typeof callback === "function") {
callback(ack);
}
});
}
}
}
type ZodMessageSenderCallback<TMessageCatalog extends ZodMessageCatalogSchema> = (message: {
@@ -121,3 +157,7 @@ export class ZodMessageSender<TMessageCatalog extends ZodMessageCatalogSchema> {
await this.#sender({ type, payload, version: "v1" });
}
}
export type MessageCatalogToSocketIoEvents<TCatalog extends ZodMessageCatalogSchema> = {
[K in keyof TCatalog]: (message: z.infer<TCatalog[K]>) => void;
};
+158
View File
@@ -0,0 +1,158 @@
import { DisconnectReason, Namespace, Server, Socket } from "socket.io";
import {
ZodMessageCatalogSchema,
ZodMessageHandlerOptions,
MessageCatalogToSocketIoEvents,
ZodMessageHandler,
ZodMessageSender,
} from "./zodMessageHandler";
interface ExtendedError extends Error {
data?: any;
}
export type ZodSocket<
TClientMessages extends ZodMessageCatalogSchema,
TServerMessages extends ZodMessageCatalogSchema,
> = Socket<
MessageCatalogToSocketIoEvents<TClientMessages>,
MessageCatalogToSocketIoEvents<TServerMessages>
>;
interface ZodNamespaceOptions<
TClientMessages extends ZodMessageCatalogSchema,
TServerMessages extends ZodMessageCatalogSchema,
> {
io: Server;
name: string;
clientMessages: TClientMessages;
serverMessages: TServerMessages;
messageHandler?: ZodMessageHandlerOptions<TClientMessages>["messages"];
authToken?: string;
preAuth?: (
socket: ZodSocket<TClientMessages, TServerMessages>,
next: (err?: ExtendedError) => void
) => void;
postAuth?: (
socket: ZodSocket<TClientMessages, TServerMessages>,
next: (err?: ExtendedError) => void
) => void;
onConnection?: (
socket: ZodSocket<TClientMessages, TServerMessages>,
handler: ZodMessageHandler<TClientMessages>,
sender: ZodMessageSender<TServerMessages>,
logger: (...args: any[]) => void
) => Promise<void>;
onDisconnect?: (
socket: ZodSocket<TClientMessages, TServerMessages>,
reason: DisconnectReason,
description: any,
logger: (...args: any[]) => void
) => Promise<void>;
onError?: (
socket: ZodSocket<TClientMessages, TServerMessages>,
err: Error,
logger: (...args: any[]) => void
) => Promise<void>;
}
export class ZodNamespace<
TClientMessages extends ZodMessageCatalogSchema,
TServerMessages extends ZodMessageCatalogSchema,
> {
#handler: ZodMessageHandler<TClientMessages>;
sender: ZodMessageSender<TServerMessages>;
io: Server;
namespace: Namespace<
MessageCatalogToSocketIoEvents<TClientMessages>,
MessageCatalogToSocketIoEvents<TServerMessages>
>;
constructor(opts: ZodNamespaceOptions<TClientMessages, TServerMessages>) {
this.#handler = new ZodMessageHandler({
schema: opts.clientMessages,
messages: opts.messageHandler,
});
this.io = opts.io;
this.namespace = this.io.of(opts.name);
this.sender = new ZodMessageSender({
schema: opts.serverMessages,
sender: async (message) => {
return new Promise((resolve, reject) => {
try {
// @ts-expect-error
this.namespace.emit(message.type, message.payload);
resolve();
} catch (err) {
reject(err);
}
});
},
});
if (opts.preAuth) {
this.namespace.use(opts.preAuth);
}
if (opts.authToken) {
this.namespace.use((socket, next) => {
const logger = createLogger(`[${opts.name}][${socket.id}][auth]`);
const { auth } = socket.handshake;
if (!("token" in auth)) {
logger("no token");
return socket.disconnect(true);
}
if (auth.token !== opts.authToken) {
logger("invalid token");
return socket.disconnect(true);
}
logger("success");
next();
});
}
if (opts.postAuth) {
this.namespace.use(opts.postAuth);
}
this.namespace.on("connection", async (socket) => {
const logger = createLogger(`[${opts.name}][${socket.id}]`);
logger("connection");
this.#handler.registerHandlers(socket, logger);
socket.on("disconnect", async (reason, description) => {
logger("disconnect", { reason, description });
if (opts.onDisconnect) {
await opts.onDisconnect(socket, reason, description, logger);
}
});
socket.on("error", async (error) => {
logger("error", error);
if (opts.onError) {
await opts.onError(socket, error, logger);
}
});
if (opts.onConnection) {
await opts.onConnection(socket, this.#handler, this.sender, logger);
}
});
}
}
function createLogger(prefix: string) {
return (...args: any[]) => console.log(prefix, ...args);
}
@@ -0,0 +1,30 @@
-- CreateTable
CREATE TABLE "ImageDetails" (
"id" TEXT NOT NULL,
"friendlyId" TEXT NOT NULL,
"contentHash" TEXT NOT NULL,
"tag" TEXT NOT NULL,
"backgroundWorkerId" TEXT,
"projectId" TEXT NOT NULL,
"runtimeEnvironmentId" TEXT NOT NULL,
"metadata" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ImageDetails_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ImageDetails_friendlyId_key" ON "ImageDetails"("friendlyId");
-- CreateIndex
CREATE UNIQUE INDEX "ImageDetails_projectId_runtimeEnvironmentId_tag_key" ON "ImageDetails"("projectId", "runtimeEnvironmentId", "tag");
-- AddForeignKey
ALTER TABLE "ImageDetails" ADD CONSTRAINT "ImageDetails_backgroundWorkerId_fkey" FOREIGN KEY ("backgroundWorkerId") REFERENCES "BackgroundWorker"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ImageDetails" ADD CONSTRAINT "ImageDetails_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ImageDetails" ADD CONSTRAINT "ImageDetails_runtimeEnvironmentId_fkey" FOREIGN KEY ("runtimeEnvironmentId") REFERENCES "RuntimeEnvironment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,11 @@
/*
Warnings:
- A unique constraint covering the columns `[projectId,runtimeEnvironmentId,contentHash]` on the table `ImageDetails` will be added. If there are existing duplicate values, this will fail.
*/
-- DropIndex
DROP INDEX "ImageDetails_projectId_runtimeEnvironmentId_tag_key";
-- CreateIndex
CREATE UNIQUE INDEX "ImageDetails_projectId_runtimeEnvironmentId_contentHash_key" ON "ImageDetails"("projectId", "runtimeEnvironmentId", "contentHash");
@@ -0,0 +1,30 @@
-- CreateEnum
CREATE TYPE "CheckpointType" AS ENUM ('DOCKER', 'KUBERNETES');
-- CreateTable
CREATE TABLE "Checkpoint" (
"id" TEXT NOT NULL,
"friendlyId" TEXT NOT NULL,
"type" "CheckpointType" NOT NULL,
"location" TEXT NOT NULL,
"reason" TEXT,
"attemptId" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"runtimeEnvironmentId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Checkpoint_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Checkpoint_friendlyId_key" ON "Checkpoint"("friendlyId");
-- AddForeignKey
ALTER TABLE "Checkpoint" ADD CONSTRAINT "Checkpoint_attemptId_fkey" FOREIGN KEY ("attemptId") REFERENCES "TaskRunAttempt"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Checkpoint" ADD CONSTRAINT "Checkpoint_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Checkpoint" ADD CONSTRAINT "Checkpoint_runtimeEnvironmentId_fkey" FOREIGN KEY ("runtimeEnvironmentId") REFERENCES "RuntimeEnvironment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+62 -3
View File
@@ -389,6 +389,8 @@ model RuntimeEnvironment {
taskQueues TaskQueue[]
batchTaskRuns BatchTaskRun[]
environmentVariableValues EnvironmentVariableValue[]
imageDetails ImageDetails[]
checkpoints Checkpoint[]
@@unique([projectId, slug, orgMemberId])
@@unique([projectId, shortcode])
@@ -432,6 +434,8 @@ model Project {
taskTags TaskTag[]
taskQueues TaskQueue[]
environmentVariables EnvironmentVariable[]
imageDetails ImageDetails[]
checkpoints Checkpoint[]
}
enum ProjectVersion {
@@ -1513,9 +1517,10 @@ model BackgroundWorker {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tasks BackgroundWorkerTask[]
attempts TaskRunAttempt[]
lockedRuns TaskRun[]
tasks BackgroundWorkerTask[]
attempts TaskRunAttempt[]
lockedRuns TaskRun[]
imageDetails ImageDetails[]
@@unique([projectId, runtimeEnvironmentId, version])
}
@@ -1670,6 +1675,8 @@ model TaskRunAttempt {
taskRunDependency TaskRunDependency? @relation("dependentAttempt")
batchTaskRunDependency BatchTaskRun?
checkpoints Checkpoint[]
@@unique([taskRunId, number])
}
@@ -1884,3 +1891,55 @@ model EnvironmentVariableValue {
@@unique([variableId, environmentId])
}
model ImageDetails {
id String @id @default(cuid())
friendlyId String @unique
contentHash String
tag String
backgroundWorker BackgroundWorker? @relation(fields: [backgroundWorkerId], references: [id], onDelete: Cascade, onUpdate: Cascade)
backgroundWorkerId String?
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade)
projectId String
runtimeEnvironment RuntimeEnvironment @relation(fields: [runtimeEnvironmentId], references: [id], onDelete: Cascade, onUpdate: Cascade)
runtimeEnvironmentId String
metadata Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([projectId, runtimeEnvironmentId, contentHash])
}
model Checkpoint {
id String @id @default(cuid())
friendlyId String @unique
type CheckpointType
location String
reason String?
attempt TaskRunAttempt @relation(fields: [attemptId], references: [id], onDelete: Cascade, onUpdate: Cascade)
attemptId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade)
projectId String
runtimeEnvironment RuntimeEnvironment @relation(fields: [runtimeEnvironmentId], references: [id], onDelete: Cascade, onUpdate: Cascade)
runtimeEnvironmentId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum CheckpointType {
DOCKER
KUBERNETES
}
+1 -1
View File
@@ -29,7 +29,7 @@
},
"devDependencies": {
"@trigger.dev/tsconfig": "workspace:*",
"@types/node": "16",
"@types/node": "^18",
"@types/react": "18.2.17",
"typescript": "^4.9.4"
},
+11 -3
View File
@@ -1,17 +1,25 @@
# OTLP Importer
## Getting started
Install dependencies:
```sh
```sh Mac
brew install protobuf
```
# Submodules
```sh Linux
apt install -y protobuf-compiler
```
Alternatively, follow the [manual install instructions](https://github.com/protocolbuffers/protobuf?tab=readme-ov-file#protobuf-compiler-installation) for the protobuf compiler.
## Submodules
**Submodule is always pointing to certain revision number. So updating the submodule repo will not have impact on your code.
Knowing this if you want to change the submodule to point to a different version (when for example proto has changed) here is how to do it:**
## Updating submodule to point to certain revision number
### Updating submodule to point to certain revision number
1. Make sure you are in the same folder as this instruction
+1 -1
View File
@@ -40,7 +40,7 @@
"@trigger.dev/tsconfig": "workspace:*",
"@trigger.dev/tsup": "workspace:*",
"@types/jest": "^29.5.3",
"@types/node": "16",
"@types/node": "^18",
"jest": "^29.6.2",
"rimraf": "^3.0.2",
"ts-jest": "^29.1.1",
+907 -165
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -3,6 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"build:trigger": "trigger.dev build",
"dev:trigger": "trigger.dev dev",
"start": "ts-node -r tsconfig-paths/register -r dotenv/config src/index.ts",
"start:concurrency": "ts-node -r tsconfig-paths/register -r dotenv/config src/concurrencyUsage.ts",
+2 -2
View File
@@ -1,7 +1,7 @@
import { parentTask, simpleParentTask } from "./trigger/simple";
import { parentTask } from "./trigger/simple";
export async function main() {
const handle = await simpleParentTask.trigger({
const handle = await parentTask.trigger({
payload: { message: "This is a message from the trigger-dev CLI" },
});