Skip to content
Open
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
1 change: 1 addition & 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
107 changes: 106 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,20 @@ interface DefaultEventsMap {
[event: string]: (...args: any[]) => void;
}

/**
* checkpointDeleteRequests counts the delete requests this supervisor makes, and every reason it
* decides not to: `sent`, `disabled`, `no_client`, `not_applicable`, `not_terminal`, `no_claims`,
* `no_project_ref`, `http_error`.
* Without the negative outcomes, "no deletes are happening" is indistinguishable from the feature
* being switched off - and with no lifecycle expiry, that difference is leaked storage.
*/
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 +198,16 @@ 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 whenever the token verifies, in either mode. They are for addressing a
* run's own resources locally (e.g. its checkpoint storage) - never for scoping the platform,
* which is why environmentId above 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 +224,84 @@ 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.
*
* Called only after the reply has been sent, so it never delays the runner - the same shape the
* suspend route uses. Every early return is counted: nothing reclaims storage behind this, so a
* silently skipped request leaks it, and silence must not look like success.
*
* This covers runner-driven completion only. A run that dies without posting one - killed pod,
* OOM, node loss, platform-side expiry - is finalised on the platform, which the worker never
* hears about, so those are not reclaimed here and are not reclaimable from this side.
*
* `RUN_PENDING_CANCEL` is terminal too - a run cancelled mid-execution never restores - so it is
* reclaimed alongside `RUN_FINISHED`. Retries are deliberately excluded: the prefix is run-level,
* so a retry's checkpoints are cleaned by the final completion.
*/
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 on lines +272 to +275

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Reclaim is inert unless deployment tokens are enabled

reclaimCheckpoints requires claims, which authorizeWorkloadRequest only populates when workloadTokensEnabled and the token verifies (apps/supervisor/src/workloadServer/index.ts:211-227). Clusters that turn on DELETE_CHECKPOINTS_ON_COMPLETION but leave WORKLOAD_TOKEN_SECRET unset will silently accumulate only no_claims counts and never reclaim any storage. Worth documenting the dependency on the token rollout (or asserting it at startup) so an operator doesn't enable the flag and assume storage is being freed.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


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 +462,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