Skip to content

Commit d44c7e5

Browse files
1stvampTrigger.dev RepoOps
authored andcommitted
feat(supervisor): follow restores with one informer and report failed ones
The Kubernetes supervisor now follows checkpoint restores more cheaply, keeps slow restores from timing out, and reports failed restores so runs are requeued or failed promptly. Mono-RevId: eb4bb979ad6c27ec04aca0a99a13b64ecec32bf7
1 parent aba49bf commit d44c7e5

8 files changed

Lines changed: 1965 additions & 81 deletions

File tree

‎apps/supervisor/src/clients/kubernetes.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as k8s from "@kubernetes/client-node";
2-
import type { Informer, KubernetesObject, ListPromise } from "@kubernetes/client-node";
2+
import type { Informer, KubernetesObject, ListPromise, ObjectCache } from "@kubernetes/client-node";
33
import { assertExhaustive } from "@trigger.dev/core/utils";
44
import { SimpleStructuredLogger } from "@trigger.dev/core/v3/utils/structuredLogger";
55

@@ -15,8 +15,15 @@ export function createK8sApi() {
1515
listPromiseFn: ListPromise<T>,
1616
labelSelector?: string,
1717
fieldSelector?: string
18-
): Informer<T> {
19-
return k8s.makeInformer(kubeConfig, path, listPromiseFn, labelSelector, fieldSelector);
18+
): Informer<T> & ObjectCache<T> {
19+
// The client's informer is a ListWatch, which is also the cache it keeps.
20+
return k8s.makeInformer(
21+
kubeConfig,
22+
path,
23+
listPromiseFn,
24+
labelSelector,
25+
fieldSelector
26+
) as Informer<T> & ObjectCache<T>;
2027
}
2128

2229
const api = {

‎apps/supervisor/src/env.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,27 @@ export const Env = z
204204
// runtime can restore, and COMPUTE_SNAPSHOTS_ENABLED sends suspends to the operator.
205205
// Unrelated to the task runtime (node-24, bun), which is the Runner's taskRuntime.
206206
KUBERNETES_RUNNER_RUNTIME: z.enum(["container", "microvm"]).default("container"),
207+
// How often a suspend's or a restore's outcome is read back off the Runner when
208+
// polling. Backs off on 429 and 5xx.
209+
KUBERNETES_RUNNER_POLL_INTERVAL_MS: z.coerce.number().int().positive().default(5_000),
210+
// Follow restores with one informer on labelled Runners instead of a poll per
211+
// restore. Needs list and watch on runners.
212+
KUBERNETES_RUNNER_RESTORE_INFORMER_ENABLED: BoolEnv.default(false),
213+
// The operator's 15 minute pod start deadline, after up to 5 minutes waiting
214+
// out a predecessor pod of the same name, plus a margin, so its failure
215+
// arrives first and carries the reason.
216+
KUBERNETES_RUNNER_RESTORE_TIMEOUT_MS: z.coerce
217+
.number()
218+
.int()
219+
.positive()
220+
.default(21 * 60_000),
221+
// Below the platform's stall timeout for a dequeued run, so a slow but healthy
222+
// restore is not requeued.
223+
KUBERNETES_RUNNER_RESTORE_HEARTBEAT_INTERVAL_MS: z.coerce
224+
.number()
225+
.int()
226+
.positive()
227+
.default(20_000),
207228
KUBERNETES_NAMESPACE: z.string().default("default"),
208229
KUBERNETES_WORKER_NODETYPE_LABEL: NodeLabelValue.default("v4-worker"),
209230
KUBERNETES_IMAGE_PULL_SECRETS: z.string().optional(), // csv

‎apps/supervisor/src/index.ts‎

Lines changed: 211 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,14 @@ import {
1818
type ResourceMonitor,
1919
} from "./resourceMonitor.js";
2020
import { KubernetesWorkloadManager } from "./workloadManager/kubernetes.js";
21-
import { RunCrdWorkloadManager } from "./workloadManager/runCrd.js";
21+
import {
22+
RunCrdWorkloadManager,
23+
RunnerRestoreInformer,
24+
restoreHeartbeat,
25+
settleRestoreFailure,
26+
type RestoreRunner,
27+
type RestoreWatchResult,
28+
} from "./workloadManager/runCrd.js";
2229
import { DockerWorkloadManager } from "./workloadManager/docker.js";
2330
import { ComputeWorkloadManager } from "./workloadManager/compute.js";
2431
import {
@@ -81,6 +88,37 @@ const outboundRequestDuration = new Histogram({
8188
registers: [register],
8289
});
8390

91+
const restoreWatchesInFlight = new Gauge({
92+
name: "supervisor_restore_watches_in_flight",
93+
help: "Restore Runners the supervisor is waiting on to start or fail.",
94+
registers: [register],
95+
});
96+
97+
const restoreOutcomesTotal = new Counter({
98+
name: "supervisor_restore_outcomes_total",
99+
help: "Restore watches ended, by outcome and reason: the operator's failure reason, or Timeout, RunnerGone or ReadFailed from the watch itself.",
100+
labelNames: ["outcome", "reason"],
101+
registers: [register],
102+
});
103+
104+
const restoreDuration = new Histogram({
105+
name: "supervisor_restore_duration_seconds",
106+
help: "Time from a restore Runner's creation to the supervisor seeing it Running or Failed.",
107+
labelNames: ["outcome"],
108+
buckets: [1, 2.5, 5, 10, 20, 30, 60, 120, 300, 600, 900],
109+
registers: [register],
110+
});
111+
112+
const restoreReportsTotal = new Counter({
113+
name: "supervisor_restore_reports_total",
114+
help: "Failed restores reported to the platform, by outcome (requeue or fail) and result: ok, conflict when the run had already moved on, or error.",
115+
labelNames: ["outcome", "result"],
116+
registers: [register],
117+
});
118+
119+
/** `uid` is the Runner the watch is bound to; `abort` ends a watch a newer Runner replaced. */
120+
type WatchedRestore = { uid?: string; snapshotFriendlyId: string; abort: AbortController };
121+
84122
class ManagedSupervisor {
85123
private readonly workerSession: SupervisorSession;
86124
private readonly metricsServer?: HttpServer;
@@ -89,6 +127,7 @@ class ManagedSupervisor {
89127
private readonly workloadManagerBackend: "compute" | "kubernetes" | "run-crd" | "docker";
90128
private readonly computeManager?: ComputeWorkloadManager;
91129
private readonly runCrdManager?: RunCrdWorkloadManager;
130+
private readonly restoreInformer?: RunnerRestoreInformer;
92131
private readonly logger = new SimpleStructuredLogger("managed-supervisor");
93132
private readonly resourceMonitor: ResourceMonitor;
94133
private readonly checkpointClient?: CheckpointClient;
@@ -98,7 +137,8 @@ class ManagedSupervisor {
98137
private readonly failedPodHandler?: FailedPodHandler;
99138
private readonly tracing?: OtlpTraceService;
100139
private readonly backpressureMonitors: BackpressureMonitor[] = [];
101-
private readonly watchedRestores = new Set<string>();
140+
/** Restore Runners being watched, each with the latest snapshot dequeued for it. */
141+
private readonly watchedRestores = new Map<string, WatchedRestore>();
102142
private readonly backpressureRedis?: Redis;
103143

104144
private readonly isKubernetes = isKubernetesEnvironment(env.KUBERNETES_FORCE_ENABLED);
@@ -198,6 +238,27 @@ class ManagedSupervisor {
198238
this.workloadManager = computeManager;
199239
this.workloadManagerBackend = "compute";
200240
} else if (this.isKubernetes && env.KUBERNETES_RUN_CRD_ENABLED) {
241+
// Only a process that dequeues creates resumes, and only the microvm lane restores.
242+
if (
243+
env.KUBERNETES_RUNNER_RESTORE_INFORMER_ENABLED &&
244+
env.TRIGGER_DEQUEUE_ENABLED &&
245+
env.KUBERNETES_RUNNER_RUNTIME === "microvm"
246+
) {
247+
this.restoreInformer = new RunnerRestoreInformer({
248+
namespace: env.KUBERNETES_NAMESPACE,
249+
onUnwatchedFailure: ({ runFriendlyId, snapshotFriendlyId, runnerId, outcome }) =>
250+
void this.reportRestoreFailure(runFriendlyId, snapshotFriendlyId, runnerId, outcome),
251+
onUnwatchedRestore: ({ runnerId, uid, runFriendlyId, snapshotFriendlyId }) => {
252+
if (this.runCrdManager && runFriendlyId && snapshotFriendlyId) {
253+
void this.watchRestore(
254+
this.runCrdManager,
255+
{ runFriendlyId, snapshotFriendlyId },
256+
{ runnerId, uid }
257+
);
258+
}
259+
},
260+
});
261+
}
201262
const runCrdManager = new RunCrdWorkloadManager({
202263
...workloadManagerOptions,
203264
namespace: env.KUBERNETES_NAMESPACE,
@@ -207,6 +268,9 @@ class ManagedSupervisor {
207268
delayMs: env.COMPUTE_SNAPSHOT_DELAY_MS,
208269
dispatchLimit: env.COMPUTE_SNAPSHOT_DISPATCH_LIMIT,
209270
},
271+
suspendPollMs: env.KUBERNETES_RUNNER_POLL_INTERVAL_MS,
272+
restoreTimeoutMs: env.KUBERNETES_RUNNER_RESTORE_TIMEOUT_MS,
273+
restoreInformer: this.restoreInformer,
210274
});
211275
this.runCrdManager = runCrdManager;
212276
this.workloadManager = runCrdManager;
@@ -710,12 +774,16 @@ class ManagedSupervisor {
710774
) {
711775
const restoreStart = performance.now();
712776
try {
713-
const runnerId = await manager.restore(await this.createOptionsFor(message), checkpoint);
777+
const restore = await manager.restore(await this.createOptionsFor(message), checkpoint);
714778
recordPhaseSince("restore", restoreStart, undefined);
715779
setExtra(fromContext(), "did_restore", true);
716780
this.logger.debug("Runner restore created", { runId: message.run.id });
717781
// Not awaited: the resume can take minutes, and the dequeue is done.
718-
void this.watchRestore(manager, message.run.friendlyId, runnerId);
782+
void this.watchRestore(
783+
manager,
784+
{ runFriendlyId: message.run.friendlyId, snapshotFriendlyId: message.snapshot.friendlyId },
785+
restore
786+
);
719787
} catch (error) {
720788
recordPhaseSince(
721789
"restore",
@@ -730,31 +798,157 @@ class ManagedSupervisor {
730798
/** A resume that fails on the node is otherwise silent until the run's heartbeat stalls. */
731799
private async watchRestore(
732800
manager: RunCrdWorkloadManager,
733-
runFriendlyId: string,
734-
runnerId: string
801+
run: { runFriendlyId: string; snapshotFriendlyId: string },
802+
restore: RestoreRunner
735803
) {
736-
// A redelivered restore finds the same Runner, which needs only one watch.
737-
if (this.watchedRestores.has(runnerId)) {
804+
const { runnerId, uid } = restore;
805+
const { runFriendlyId } = run;
806+
// A redelivered restore finds the same Runner, which needs only one watch,
807+
// but its beats and report must name the snapshot dequeued last.
808+
const watched = this.watchedRestores.get(runnerId);
809+
if (watched && (watched.uid === undefined || uid === undefined || watched.uid === uid)) {
810+
watched.snapshotFriendlyId = run.snapshotFriendlyId;
738811
return;
739812
}
740-
this.watchedRestores.add(runnerId);
813+
// The redelivery replaced the Runner, so the watch on the old one has nothing left to say.
814+
watched?.abort.abort();
815+
const entry: WatchedRestore = {
816+
uid,
817+
snapshotFriendlyId: run.snapshotFriendlyId,
818+
abort: new AbortController(),
819+
};
820+
this.watchedRestores.set(runnerId, entry);
821+
restoreWatchesInFlight.inc();
822+
const heartbeat = restoreHeartbeat(
823+
() => this.heartbeatRestore(runFriendlyId, entry.snapshotFriendlyId, runnerId),
824+
env.KUBERNETES_RUNNER_RESTORE_HEARTBEAT_INTERVAL_MS
825+
);
741826
const outcome = await manager
742-
.awaitRestore(runnerId)
743-
.finally(() => this.watchedRestores.delete(runnerId));
827+
.awaitRestore(restore, heartbeat.onPhase, entry.abort.signal)
828+
.finally(() => {
829+
heartbeat.stop();
830+
restoreWatchesInFlight.dec();
831+
if (this.watchedRestores.get(runnerId) === entry) {
832+
this.watchedRestores.delete(runnerId);
833+
}
834+
});
835+
if (entry.abort.signal.aborted) {
836+
return;
837+
}
838+
const { snapshotFriendlyId } = entry;
839+
const outcomeLabel = outcome.ok ? "started" : "failed";
840+
restoreOutcomesTotal.inc({
841+
outcome: outcomeLabel,
842+
reason: outcome.ok ? "Started" : outcome.reason,
843+
});
844+
if (outcome.createdAt) {
845+
restoreDuration.observe(
846+
{ outcome: outcomeLabel },
847+
(Date.now() - outcome.createdAt.getTime()) / 1000
848+
);
849+
}
744850
if (outcome.ok) {
745851
this.logger.debug("Runner restore started", { runFriendlyId, runnerId });
746852
return;
747853
}
748-
this.logger.error("Runner restore failed (run-crd)", {
854+
await this.reportRestoreFailure(runFriendlyId, snapshotFriendlyId, runnerId, outcome);
855+
}
856+
857+
/** Keeps the dequeued snapshot from stalling while the operator brings the resume up. */
858+
private async heartbeatRestore(
859+
runFriendlyId: string,
860+
snapshotFriendlyId: string,
861+
runnerId: string
862+
) {
863+
const result = await this.workerSession.httpClient.heartbeatRun(
749864
runFriendlyId,
750-
runnerId,
751-
error: outcome.error,
752-
});
865+
snapshotFriendlyId,
866+
{},
867+
runnerId
868+
);
869+
if (!result.success) {
870+
this.logger.warn("Restore heartbeat failed", {
871+
runFriendlyId,
872+
snapshotFriendlyId,
873+
runnerId,
874+
error: result.error,
875+
});
876+
}
877+
}
878+
879+
private async reportRestoreFailure(
880+
runFriendlyId: string | undefined,
881+
snapshotFriendlyId: string | undefined,
882+
runnerId: string,
883+
outcome: RestoreWatchResult & { ok: false }
884+
) {
885+
const fields = { runFriendlyId, snapshotFriendlyId, runnerId, reason: outcome.reason };
886+
this.logger.error("Runner restore failed (run-crd)", { ...fields, error: outcome.error });
887+
if (!runFriendlyId) {
888+
return;
889+
}
753890
await this.workerSession.httpClient.sendDebugLog(runFriendlyId, {
754891
time: new Date(),
755892
message: "restore failed on the node",
756893
properties: { runnerId, error: outcome.error },
757894
});
895+
const manager = this.runCrdManager;
896+
if (!snapshotFriendlyId || !manager) {
897+
return;
898+
}
899+
const settled = await settleRestoreFailure(
900+
{ runnerId, outcome },
901+
{
902+
deleteRunner: (name, uid) => manager.deleteRestoreRunner(name, uid),
903+
report: (body) =>
904+
this.workerSession.httpClient.reportRestoreOutcome(
905+
runFriendlyId,
906+
snapshotFriendlyId,
907+
body,
908+
runnerId
909+
),
910+
}
911+
);
912+
switch (settled.action) {
913+
case "none":
914+
this.logger.info("Restore failure left to the stall timeout", fields);
915+
return;
916+
case "kept":
917+
if (settled.forbidden) {
918+
this.logger.error(
919+
"Restore Runner delete forbidden, so the run was left to the stall timeout; grant delete on runners",
920+
{ ...fields, error: settled.error }
921+
);
922+
return;
923+
}
924+
this.logger.warn("Restore Runner not deleted, so the run was not requeued", {
925+
...fields,
926+
error: settled.error,
927+
});
928+
return;
929+
case "reported":
930+
restoreReportsTotal.inc({ outcome: settled.outcome, result: settled.result });
931+
if (settled.result === "error") {
932+
this.logger.warn("Restore outcome report failed", {
933+
...fields,
934+
outcome: settled.outcome,
935+
error: settled.error,
936+
});
937+
} else {
938+
this.logger.info(
939+
settled.result === "conflict"
940+
? "Restore outcome not applied, the run has moved on"
941+
: "Restore outcome reported",
942+
{ ...fields, outcome: settled.outcome }
943+
);
944+
}
945+
if (settled.cleanupError) {
946+
this.logger.warn("Failed restore Runner not deleted after its report", {
947+
...fields,
948+
error: settled.cleanupError,
949+
});
950+
}
951+
}
758952
}
759953

760954
private async createWorkload(message: DequeuedMessage, timings: WarmStartTimings) {
@@ -868,6 +1062,7 @@ class ManagedSupervisor {
8681062
this.backpressureMonitors.forEach((m) => m.start());
8691063
await this.podCleaner?.start();
8701064
await this.failedPodHandler?.start();
1065+
await this.restoreInformer?.start();
8711066
await this.metricsServer?.start();
8721067

8731068
if (env.TRIGGER_WORKLOAD_API_ENABLED) {
@@ -897,6 +1092,7 @@ class ManagedSupervisor {
8971092
await this.backpressureRedis?.quit();
8981093
await this.podCleaner?.stop();
8991094
await this.failedPodHandler?.stop();
1095+
await this.restoreInformer?.stop();
9001096
await this.metricsServer?.stop();
9011097
}
9021098
}

0 commit comments

Comments
 (0)