Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/supervisor/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const Env = z
// also reject invalid tokens.
WORKLOAD_TOKEN_SECRET: z.string().optional(),
WORKLOAD_TOKEN_ENFORCEMENT: z.enum(["disabled", "log", "enforce"]).default("disabled"),
DELETE_CHECKPOINTS_ON_COMPLETION: BoolEnv.default(false), // irreversible; enable per cluster
// Absolute expiry for minted deployment tokens. Deterministic (no wall-clock issued-at) so every
// pod of a deployment carries an identical token; bump before this date. Must outlive any run.
WORKLOAD_TOKEN_EXP: z.string().datetime().default("2032-01-01T00:00:00.000Z"),
Expand Down Expand Up @@ -326,6 +327,14 @@ export const Env = z
path: ["WORKLOAD_TOKEN_SECRET"],
});
}
if (data.DELETE_CHECKPOINTS_ON_COMPLETION && data.WORKLOAD_TOKEN_ENFORCEMENT === "disabled") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"DELETE_CHECKPOINTS_ON_COMPLETION needs WORKLOAD_TOKEN_ENFORCEMENT set to log or enforce: the tenancy it deletes by comes from the deployment token, so with tokens disabled it would silently reclaim nothing",
path: ["DELETE_CHECKPOINTS_ON_COMPLETION"],
});
}
if (
data.TRIGGER_DEQUEUE_BACKPRESSURE_ENABLED &&
!data.TRIGGER_DEQUEUE_BACKPRESSURE_REDIS_HOST
Expand Down
88 changes: 87 additions & 1 deletion apps/supervisor/src/workloadServer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,16 @@ import EventEmitter from "node:events";
import type { IncomingMessage, ServerResponse } from "node:http";
import { type Namespace, Server, type Socket } from "socket.io";
import { z } from "zod";
import { tryCatch } from "@trigger.dev/core/utils";
import { Counter } from "prom-client";
import { env } from "../env.js";
import { register } from "../metrics.js";
import {
verifyDeploymentIdHeader,
workloadTokenEnforced,
workloadTokensEnabled,
} from "../workloadToken.js";
import type { WorkloadDeploymentTokenClaims } from "@trigger.dev/core/v3";
import {
ComputeSnapshotService,
type RunTraceContext,
Expand All @@ -50,6 +53,13 @@ interface DefaultEventsMap {
[event: string]: (...args: any[]) => void;
}

const checkpointDeleteRequests = new Counter({
name: "checkpoint_delete_requests_total",
help: "Checkpoint delete requests attempted at run completion, by outcome",
labelNames: ["result"],
registers: [register],
});

const WorkloadActionParams = z.object({
runFriendlyId: z.string(),
snapshotFriendlyId: z.string(),
Expand Down Expand Up @@ -181,10 +191,15 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
* environment_id to forward upstream. The env id is only forwarded in enforce mode: in log mode
* we still verify + record metrics but attach no header (so the platform never scopes). Only
* enforce fails a request, and only for a present-but-invalid token; absent and legacy ids pass.
*
* `claims` are returned on any valid token, for local use only - never to scope the platform,
* which is why environmentId stays gated on enforce.
*/
private async authorizeWorkloadRequest(
req: IncomingMessage
): Promise<{ ok: true; environmentId?: string } | { ok: false }> {
): Promise<
{ ok: true; environmentId?: string; claims?: WorkloadDeploymentTokenClaims } | { ok: false }
> {
if (!workloadTokensEnabled) {
return { ok: true };
}
Expand All @@ -201,9 +216,73 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
workloadTokenEnforced && result.outcome === "jwt_valid"
? result.claims.environment_id
: undefined,
claims: result.outcome === "jwt_valid" ? result.claims : undefined,
};
}

/**
* reclaimCheckpoints asks the checkpoint service to delete a finished run's checkpoint storage.
* Must be called after the reply is sent: it never delays the runner.
*/
private async reclaimCheckpoints(
req: IncomingMessage,
runFriendlyId: string,
attemptStatus: string,
claims: WorkloadDeploymentTokenClaims | undefined
): Promise<void> {
if (!env.DELETE_CHECKPOINTS_ON_COMPLETION) {
checkpointDeleteRequests.inc({ result: "disabled" });
return;
}

if (!this.checkpointClient) {
checkpointDeleteRequests.inc({ result: "no_client" });
return;
}

if (this.snapshotService) {
checkpointDeleteRequests.inc({ result: "not_applicable" });
return;
}

if (attemptStatus !== "RUN_FINISHED" && attemptStatus !== "RUN_PENDING_CANCEL") {
checkpointDeleteRequests.inc({ result: "not_terminal" });
return;
}

if (!claims) {
checkpointDeleteRequests.inc({ result: "no_claims" });
return;
}
Comment thread
nicktrn marked this conversation as resolved.

const projectRef = this.projectRefFromRequest(req);
if (!projectRef) {
checkpointDeleteRequests.inc({ result: "no_project_ref" });
this.logger.error("Cannot reclaim checkpoints without a project ref", { runFriendlyId });
return;
}

const [error, accepted] = await tryCatch(
this.checkpointClient.deleteCheckpoints({
runFriendlyId,
body: {
orgId: claims.org_id,
envId: claims.environment_id,
deploymentVersion: claims.deployment_version,
projectRef,
},
})
);

if (error || !accepted) {
checkpointDeleteRequests.inc({ result: "http_error" });
this.logger.error("Failed to request checkpoint reclaim", { runFriendlyId, error });
return;
}

checkpointDeleteRequests.inc({ result: "sent" });
}

/**
* Sets common route meta on the wide-event state from URL params.
*/
Expand Down Expand Up @@ -364,6 +443,13 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
}

reply.json(completeResponse.data satisfies WorkloadRunAttemptCompleteResponseBody);

await this.reclaimCheckpoints(
req,
params.runFriendlyId,
completeResponse.data.result.attemptStatus,
auth.claims
);
Comment thread
nicktrn marked this conversation as resolved.
return;
}
),
Expand Down
38 changes: 38 additions & 0 deletions packages/core/src/v3/serverOnly/checkpointClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,4 +119,42 @@ export class CheckpointClient {

return true;
}

/**
* Ask the checkpoint service to reclaim a finished run's checkpoint storage. Best-effort: the
* service enqueues and returns 202, so a `true` here means accepted, not deleted.
*/
async deleteCheckpoints({
runFriendlyId,
body,
}: {
runFriendlyId: string;
body: {
orgId: string;
envId: string;
projectRef: string;
deploymentVersion: string;
};
}): Promise<boolean> {
Comment thread
nicktrn marked this conversation as resolved.
const res = await fetch(
new URL(`/api/v1/runs/${runFriendlyId}/checkpoints/delete`, this.opts.apiUrl),
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
}
);

if (!res.ok) {
this.logger.error("[CheckpointClient] Delete checkpoints request failed", {
runFriendlyId,
status: res.status,
});
return false;
}

return true;
}
}
Loading