Skip to content

Commit c370fee

Browse files
myftijaTrigger.dev RepoOps
authored andcommitted
feat(supervisor): release a run whose container or pod failed before it started
The supervisor can now give back a run whose container or pod could not be created, or whose pod failed before the run started, so the run is requeued at once instead of waiting out the pending execution timeout. Both parts are off by default. Set `RELEASE_UNSTARTED_RUNS_ENABLED=true` on the supervisor for creates that fail, and `RELEASE_FAILED_POD_RUNS_ENABLED=true` for pods that fail. Mono-RevId: 7b425fa0ec7bf68fe11de4d1f3a44319e933bcc1
1 parent ba51e55 commit c370fee

16 files changed

Lines changed: 1633 additions & 7 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: supervisor
3+
type: improvement
4+
---
5+
6+
Runs whose container or pod could not be created, or whose pod failed before the run started, can now be requeued straight away instead of waiting for the pending execution timeout. Both are off by default: set `RELEASE_UNSTARTED_RUNS_ENABLED=true` on a supervisor for creates that fail, and `RELEASE_FAILED_POD_RUNS_ENABLED=true` for pods that fail.

‎apps/supervisor/src/env.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,31 @@ describe("Env superRefine - backpressure source awareness", () => {
185185
});
186186
});
187187

188+
describe("releasing runs that never started", () => {
189+
it("is off by default for both failed creates and failed pods", () => {
190+
const env = Env.parse(base);
191+
expect(env.RELEASE_UNSTARTED_RUNS_ENABLED).toBe(false);
192+
expect(env.RELEASE_FAILED_POD_RUNS_ENABLED).toBe(false);
193+
});
194+
195+
it("limits releases to five different runs a minute unless told otherwise, never fewer than none", () => {
196+
expect(Env.parse(base).RUN_RELEASES_PER_MINUTE).toBe(5);
197+
expect(Env.parse({ ...base, RUN_RELEASES_PER_MINUTE: "0" }).RUN_RELEASES_PER_MINUTE).toBe(0);
198+
expect(() => Env.parse({ ...base, RUN_RELEASES_PER_MINUTE: "-1" })).toThrow();
199+
expect(Env.parse({ ...base, RUN_RELEASES_PER_MINUTE: " " }).RUN_RELEASES_PER_MINUTE).toBe(5);
200+
});
201+
202+
it("turns on each path on its own", () => {
203+
const creates = Env.parse({ ...base, RELEASE_UNSTARTED_RUNS_ENABLED: "true" });
204+
expect(creates.RELEASE_UNSTARTED_RUNS_ENABLED).toBe(true);
205+
expect(creates.RELEASE_FAILED_POD_RUNS_ENABLED).toBe(false);
206+
207+
const pods = Env.parse({ ...base, RELEASE_FAILED_POD_RUNS_ENABLED: "1" });
208+
expect(pods.RELEASE_UNSTARTED_RUNS_ENABLED).toBe(false);
209+
expect(pods.RELEASE_FAILED_POD_RUNS_ENABLED).toBe(true);
210+
});
211+
});
212+
188213
describe("worker cell name", () => {
189214
it("treats an empty value as unset", () => {
190215
expect(

‎apps/supervisor/src/env.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,17 @@ export const Env = z
363363
// Report a run whose runner the runtime killed, so it fails as out of memory only
364364
// when the runtime says so. Without it the run waits out the heartbeat timeout.
365365
RUNNER_KILL_REPORTS_ENABLED: BoolEnv.default(true),
366+
// Release a run whose container or pod could not be created, so it is requeued at once instead
367+
// of waiting out the pending execution timeout.
368+
RELEASE_UNSTARTED_RUNS_ENABLED: BoolEnv.default(false),
369+
// Release a run whose pod failed before the run started, likewise. Independent of the flag above.
370+
RELEASE_FAILED_POD_RUNS_ENABLED: BoolEnv.default(false),
371+
// Different runs released within a minute, by either flag, past which every release waits out
372+
// the timeout until a slot frees. 0 withholds every release; blank means the default.
373+
RUN_RELEASES_PER_MINUTE: z.preprocess(
374+
(value) => (typeof value === "string" && value.trim() === "" ? undefined : value),
375+
z.coerce.number().int().min(0).default(5)
376+
),
366377
FAILED_POD_HANDLER_RECONNECT_INTERVAL_MS: z.coerce.number().int().default(1000),
367378
FAILED_POD_HANDLER_WATCH_TIMEOUT_SECONDS: z.coerce.number().int().positive().default(300),
368379

‎apps/supervisor/src/index.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ import { register } from "./metrics.js";
4242
import { PodCleaner } from "./services/podCleaner.js";
4343
import { FailedPodHandler } from "./services/failedPodHandler.js";
4444
import { RunnerKillReporter } from "./services/runnerKills.js";
45+
import { dequeuedRun, RunReleaser } from "./services/runReleases.js";
46+
import { WorkloadNotCreatedError } from "./workloadManager/createErrors.js";
4547
import { DequeueDrain } from "./services/dequeueDrain.js";
4648
import { getWorkerToken } from "./workerToken.js";
4749
import { mintDeploymentToken } from "./workloadToken.js";
@@ -124,6 +126,7 @@ class ManagedSupervisor {
124126
private readonly restoreWatcher?: RestoreWatcher;
125127
private readonly runnerKillReporter?: RunnerKillReporter;
126128
private readonly runnerKillInformer?: RunnerKillInformer;
129+
private readonly runReleaser?: RunReleaser;
127130
private readonly logger = new SimpleStructuredLogger("managed-supervisor");
128131
private readonly resourceMonitor: ResourceMonitor;
129132
private readonly checkpointClient?: CheckpointClient;
@@ -180,8 +183,18 @@ class ManagedSupervisor {
180183
additionalEnvVars: env.RUNNER_ADDITIONAL_ENV_VARS,
181184
dockerAutoremove: env.DOCKER_AUTOREMOVE_EXITED_CONTAINERS,
182185
checkpointsEnabled: !!env.TRIGGER_CHECKPOINT_URL,
186+
releaseUnstartedRuns: env.RELEASE_UNSTARTED_RUNS_ENABLED,
183187
} satisfies WorkloadManagerOptions;
184188

189+
this.runReleaser =
190+
env.RELEASE_UNSTARTED_RUNS_ENABLED || env.RELEASE_FAILED_POD_RUNS_ENABLED
191+
? new RunReleaser({
192+
register,
193+
client: () => this.workerSession.httpClient,
194+
runsReleasedAMinute: env.RUN_RELEASES_PER_MINUTE,
195+
})
196+
: undefined;
197+
185198
this.resourceMonitor = env.RESOURCE_MONITOR_ENABLED
186199
? this.isKubernetes
187200
? new KubernetesResourceMonitor(createK8sApi(), env.TRIGGER_WORKER_INSTANCE_NAME)
@@ -287,6 +300,7 @@ class ManagedSupervisor {
287300
namespace: env.KUBERNETES_NAMESPACE,
288301
reconnectIntervalMs: env.FAILED_POD_HANDLER_RECONNECT_INTERVAL_MS,
289302
});
303+
const runReleaser = env.RELEASE_FAILED_POD_RUNS_ENABLED ? this.runReleaser : undefined;
290304
this.failedPodHandler = new FailedPodHandler({
291305
register,
292306
namespace: env.KUBERNETES_NAMESPACE,
@@ -295,6 +309,7 @@ class ManagedSupervisor {
295309
onRunnerKilled: this.runnerKillReporter
296310
? (runnerId, kill) => this.runnerKillReporter!.onRunnerKilled(runnerId, kill)
297311
: undefined,
312+
onRunPodFailed: runReleaser ? (run) => runReleaser.release(run, "PodFailed") : undefined,
298313
});
299314
} else {
300315
this.logger.warn("Failed pod handler disabled");
@@ -303,6 +318,12 @@ class ManagedSupervisor {
303318
this.logger.log("🧹 Pod cleaner and failed pod handler disabled, the operator owns run pods");
304319
}
305320

321+
if (env.RELEASE_FAILED_POD_RUNS_ENABLED && !this.failedPodHandler) {
322+
this.logger.warn(
323+
"RELEASE_FAILED_POD_RUNS_ENABLED has no effect without the failed pod handler"
324+
);
325+
}
326+
306327
if (env.TRIGGER_DEQUEUE_INTERVAL_MS > env.TRIGGER_DEQUEUE_IDLE_INTERVAL_MS) {
307328
this.logger.warn(
308329
`⚠️ TRIGGER_DEQUEUE_INTERVAL_MS (${env.TRIGGER_DEQUEUE_INTERVAL_MS}) is greater than TRIGGER_DEQUEUE_IDLE_INTERVAL_MS (${env.TRIGGER_DEQUEUE_IDLE_INTERVAL_MS}) - did you mix them up?`
@@ -968,6 +989,10 @@ class ManagedSupervisor {
968989
runId: message.run.friendlyId,
969990
error,
970991
});
992+
993+
if (error instanceof WorkloadNotCreatedError) {
994+
await this.runReleaser?.release(dequeuedRun(message), "CreateFailed");
995+
}
971996
}
972997
}
973998

‎apps/supervisor/src/services/failedPodHandler.ts‎

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { createK8sApi } from "../clients/kubernetes.js";
77
import { ReconnectingInformer } from "../clients/reconnectingInformer.js";
88
import { register } from "../metrics.js";
99
import { podKill, type RunnerKill } from "./runnerKills.js";
10+
import { runOfPod, type DequeuedRun } from "./runReleases.js";
1011

1112
// Operator-owned pods carry app=task-run too, since the network policy selects
1213
// on it, so they have to be excluded by their own label instead. Unconditional:
@@ -25,6 +26,8 @@ export type FailedPodHandlerOptions = {
2526
register?: Registry;
2627
/** Called with the pod's name, its runner id, when the runtime killed its run-controller. */
2728
onRunnerKilled?: (runnerId: string, kill: RunnerKill) => Promise<unknown>;
29+
/** Called with the run a failed pod was dequeued for, unless the pod shut down gracefully. */
30+
onRunPodFailed?: (run: DequeuedRun) => Promise<unknown>;
2831
};
2932

3033
export class FailedPodHandler {
@@ -33,6 +36,7 @@ export class FailedPodHandler {
3336
private readonly k8s: K8sApi;
3437
private readonly namespace: string;
3538
private readonly onRunnerKilled?: FailedPodHandlerOptions["onRunnerKilled"];
39+
private readonly onRunPodFailed?: FailedPodHandlerOptions["onRunPodFailed"];
3640

3741
private isRunning = false;
3842

@@ -58,6 +62,7 @@ export class FailedPodHandler {
5862

5963
this.namespace = opts.namespace;
6064
this.onRunnerKilled = opts.onRunnerKilled;
65+
this.onRunPodFailed = opts.onRunPodFailed;
6166

6267
this.watch = new ReconnectingInformer({
6368
name: "failed-pod-informer",
@@ -217,16 +222,21 @@ export class FailedPodHandler {
217222
private async onPodFailed(pod: V1Pod) {
218223
this.logger.debug("pod-failed", this.podSummary(pod));
219224

225+
let run: DequeuedRun | undefined;
220226
try {
221-
await this.processFailedPod(pod);
227+
run = await this.processFailedPod(pod);
222228
} catch (error) {
223229
this.logger.error("pod-failed: error processing pod", this.podSummary(pod), { error });
224-
} finally {
225-
await this.deletePod(pod);
230+
}
231+
const deleted = await this.deletePod(pod);
232+
233+
// The run's next pod takes this pod's name, so it is released only once this one is gone.
234+
if (deleted && run && this.onRunPodFailed) {
235+
await this.onRunPodFailed(run);
226236
}
227237
}
228238

229-
private async processFailedPod(pod: V1Pod) {
239+
private async processFailedPod(pod: V1Pod): Promise<DequeuedRun | undefined> {
230240
this.logger.verbose("pod-failed: processing pod", this.podSummary(pod));
231241

232242
const mainContainer = pod.status?.containerStatuses?.find((c) => c.name === "run-controller");
@@ -252,25 +262,41 @@ export class FailedPodHandler {
252262
if (kill && this.onRunnerKilled) {
253263
await this.onRunnerKilled(pod.metadata!.name!, kill);
254264
}
265+
266+
return runOfPod(pod);
255267
}
256268

257-
private async deletePod(pod: V1Pod) {
269+
private async deletePod(pod: V1Pod): Promise<boolean> {
258270
this.logger.verbose("pod-failed: deleting pod", this.podSummary(pod));
271+
// With a release the run's next pod takes this name within seconds, so the delete must spare it.
272+
const releasing = this.onRunPodFailed !== undefined;
259273
try {
260274
await this.k8s.core.deleteNamespacedPod({
261275
name: pod.metadata!.name!,
262276
namespace: this.namespace,
277+
...(releasing ? { body: { preconditions: { uid: pod.metadata!.uid! } } } : {}),
263278
});
264279
this.deletedPodsTotal.inc({
265280
namespace: this.namespace,
266281
status: this.podStatus(pod),
267282
});
283+
return true;
268284
} catch (error) {
285+
const code = (error as { code?: unknown } | null)?.code;
286+
if (releasing && code === 404) {
287+
this.logger.info("pod-failed: pod already gone", this.podSummary(pod));
288+
return true;
289+
}
290+
if (releasing && code === 409) {
291+
this.logger.info("pod-failed: pod already replaced, leaving it", this.podSummary(pod));
292+
return false;
293+
}
269294
this.logger.error("pod-failed: error deleting pod", this.podSummary(pod), { error });
270295
this.deletionErrorsTotal.inc({
271296
namespace: this.namespace,
272297
error_type: error instanceof Error ? error.name : "unknown",
273298
});
299+
return false;
274300
}
275301
}
276302

0 commit comments

Comments
 (0)