Wire the retry policy engine into the scheduler behind a feature flag - #5001
Conversation
|
Greptile SummaryThe PR integrates category-based retry-policy decisions into scheduler failure and lease-expiry handling behind a disabled-by-default feature flag.
Confidence Score: 5/5The PR appears safe to merge; the only remaining concern is a non-blocking redundant queue-policy lookup already reported in the existing thread. No blocking failure remains; the prior lease-expiry conversion and retry-budget issues are addressed, and the remaining outstanding concern is limited to repeated queue-policy map construction.
|
| Filename | Overview |
|---|---|
| internal/scheduler/scheduler.go | Wires policy evaluation into failed-run and lease-expiry paths, applies retry mutations, and emits retryable or terminal events. |
| internal/scheduler/jobdb/job.go | Adds genuine-failure counting that excludes returned and historically preempted runs. |
| internal/scheduler/jobdb/job_run.go | Tracks historical preemption in memory while reconstructing it from the durable preempted run state. |
| internal/server/event/conversion/conversions.go | Converts non-terminal errors into retryable client events and now supplies a reason for lease expiry. |
| internal/scheduler/configuration/validation.go | Validates node-label indexing required by retry anti-affinity and warns when the global retry kill switch is active. |
| pkg/api/event.proto | Extends the client-facing failed-job event with an additive retryable field. |
Sequence Diagram
sequenceDiagram
participant E as Executor
participant S as Scheduler
participant P as Retry policy engine
participant B as Event bus
E->>S: Report categorized run failure
S->>P: Evaluate policy and failure count
alt Retry granted
P-->>S: Retry with optional mutations
S->>S: Check mutated job schedulability
S->>B: "JobErrors(retryable=true)"
S->>B: JobRequeued
else Retry denied or budget exhausted
P-->>S: Terminal decision and reason
S->>B: Terminal JobErrors
end
Reviews (48): Last reviewed commit: "Clarify that the retry probe checks stat..." | Re-trigger Greptile
a5548c9 to
0dd1f0a
Compare
ec2f749 to
f1a4220
Compare
1cd6880 to
bc9552d
Compare
de47ab7 to
94b0640
Compare
d6700be to
6188cf9
Compare
e0bfdd4 to
ead1f14
Compare
4dd5570 to
e5d59f8
Compare
|
|
||
| ## Rollout guide for operators | ||
|
|
||
| **Configure `action: Delete` on retried categories before enabling the flag.** A retry reuses the failed attempt's pod name, so the executor must delete the failed pod to free the name (see [Pod naming and collision avoidance](#pod-naming-and-collision-avoidance)). Any failure category a retry rule matches on must set `action: Delete` in the executor's categorizer config. If it does not, the retry collides with the retained pod, the lease is returned as a recoverable submit error, and the job eventually fails terminally with a misleading `MaxRunsExceeded` reason (see [Pod naming and collision avoidance](#pod-naming-and-collision-avoidance) for the full failure mode). Audit the categorizer config against your retry rules before turning the flag on. |
There was a problem hiding this comment.
Code should validate this is the case, and fail with retry policy being created which would violate this.
There was a problem hiding this comment.
Same as the other thread, this needs the executors to report their category configs first. Planning it as a follow-up.
|
|
||
| Every attempt of a job reuses the same pod name, `armada-<jobId>-0`. A retry can therefore collide with the failed pod of the previous attempt if that pod is still terminating on the same cluster. To avoid this, the executor deletes a failed pod as soon as its failure is classified into a category configured with `action: Delete`, which frees the name before the retry is leased. | ||
|
|
||
| This has an operational consequence: **every failure category that a retry rule matches on must be configured with `action: Delete` on the executor.** If a retried category is left as the default `action: Retain`, the retained pod causes the retry's lease to fail with an `AlreadyExists` error. That surfaces as a recoverable submit error, so the run's lease is returned to the scheduler. A returned lease is not a categorized pod failure, so the retry engine does not decide it and it falls through to the legacy attempt-limit path. Once the legacy attempt limit is hit the job fails terminally with a `MaxRunsExceeded` reason that does not mention the collision, so the real cause is easy to miss. Collision handling also deletes the retained pod, so the debugging evidence that `Retain` was meant to preserve is gone anyway. |
There was a problem hiding this comment.
Code should validate this is the case, and fail with retry policy being created which would violate this.
There was a problem hiding this comment.
Agreed, but the server can't see the executors' categorizer configs today. Validating this at policy creation needs the executors to report their categories first, probably on the lease request. That's a protocol change I'd rather do as a follow-up. Until then the rollout guide carries it.
| factor: 1.5 | ||
| ``` | ||
|
|
||
| * `affinity.avoidSameNode`: when `true`, the retry avoids every node a previous run attempted. This matches the lease-return retry behaviour: the job fails if the anti-affinity makes it unschedulable. The check costs a per-job scheduling probe. Leave it off (the default) for categories where the node is not the cause, for example a plain application error. Turn it on for node-specific failures. The scheduler expresses the avoidance through its `nodeIdLabel`, so that label must be in the executor's `trackedNodeLabels`. When the label is not tracked, the scheduler cannot see it on any node, and the avoidance matches every node without effect. |
There was a problem hiding this comment.
Can we make this invariant in validation? Also this should also be in indexed node labels in scheduler config, as we expect a lot of jobs using this label.
There was a problem hiding this comment.
Good point on both. Config validation now fails startup when the retry engine is enabled and nodeIdLabel isn't in indexedNodeLabels. That part is inside the scheduler config, so we can enforce it. The trackedNodeLabels half is executor config the scheduler can't see, so it warns once per executor when reported nodes are missing the label. Proper validation there needs the executors to report their config first, which I'm planning as a follow-up.
| } | ||
|
|
||
| if c.Scheduling.RetryPolicy.Enabled && c.Scheduling.RetryPolicy.GlobalMaxRetries == 0 { | ||
| log.Warnf("scheduling.retryPolicy.enabled is true but globalMaxRetries is 0: the retry engine is active but will never grant a retry (kill-switch semantics). Set globalMaxRetries above 0 to allow policy retries.") |
There was a problem hiding this comment.
should we not allow this? i.e. fail in this case - as config is invalid? Why would I want retries enabled, but disabled at the same time?
There was a problem hiding this comment.
It's intentional. With the flag on and the cap at 0 the engine still runs and attributes failures, it just never grants anything. Turning the flag off instead changes what events new failures produce. So the 0 cap is how you freeze retries during an incident without touching event behaviour.
| Name: "armada_scheduler_retry_policy_decisions_total", | ||
| Help: "Retry policy engine decisions by policy and decision outcome. The queue is recorded in the scheduler log.", | ||
| }, | ||
| []string{"policy", "decision"}, |
There was a problem hiding this comment.
are we intentional on not exposing retries by queue+policy?
There was a problem hiding this comment.
The scheduler's state metrics already track error classification by queue and pool, and the periodic reset keeps the cardinality bounded. I've moved this counter into the same module, so it now carries queue, pool, policy and decision.
| if err != nil { | ||
| return nil, false, err | ||
| } | ||
| results, _, err := s.submitChecker.Check(ctx, []*jobdb.Job{bumpedJob}) |
There was a problem hiding this comment.
does it keep submit checker caching? as it can get very slow, very quickly?
There was a problem hiding this comment.
Yes, Check runs against the submit checker's cached snapshot, no live calls per job. The per-job calls under a mass failure are still a fair concern though. Check takes a batch, so the plan is a follow-up that collects a cycle's retry candidates and probes them in one call.
There was a problem hiding this comment.
The follow-up is up: #5097. One engine evaluation per job, then at most two batched Check calls for the whole round, and the queue-policy map builds once per cycle. Validated with the e2e retry cases against a live stack.
|
Queued — the merge queue status continues in this comment ↓. |
e5d59f8 to
7d7495f
Compare
Signed-off-by: Dejan Zele Pejchev <pejcev.dejan@gmail.com>
7d7495f to
ed79f3d
Compare
…ace as unschedulable, not as retries Signed-off-by: Dejan Zele Pejchev <pejcev.dejan@gmail.com>
…nd tighten its prose Signed-off-by: Dejan Zele Pejchev <pejcev.dejan@gmail.com>
…nodeIdLabel is not indexed Signed-off-by: Dejan Zele Pejchev <pejcev.dejan@gmail.com>
…nd pool labels Signed-off-by: Dejan Zele Pejchev <pejcev.dejan@gmail.com>
Signed-off-by: Dejan Zele Pejchev <pejcev.dejan@gmail.com>
…edence note to the attach section Signed-off-by: Dejan Zele Pejchev <pejcev.dejan@gmail.com>
…aits under fair share Signed-off-by: Dejan Zele Pejchev <pejcev.dejan@gmail.com>
Merge Queue Status
Waiting for any of
All conditions
|
|
Problem
A failed run is retried today only when a failed pod check matches it. The checks answer yes or no, and a retry returns the lease silently: no failure category on the event, no per-queue control, no budget per failure kind, and no way to change the job before it runs again. Operators cannot express "retry OOM kills with more memory" or "give this queue three retries for infrastructure failures, away from the node that failed".
Earlier work this builds on
This PR
This PR connects those pieces into scheduler behaviour, behind the scheduling.retryPolicy feature flag. With the flag off, the scheduler produces the same event sequences as before, byte for byte, and identity tests pin this. With the flag on, a failed run consults the policy attached to its queue. The policy decides whether the categorized failure retries, and its verdict replaces the attempt counting for those runs. A retryable failure emits an intermediate event with retryable=true. The wiring also applies the matched rule's mutations at requeue: avoidSameNode steers the retry away from every node the job failed on, and a memory bump grows the job so placement, accounting, and the retried pod all see the new size. Gangs and fail-fast jobs keep their existing behaviour.
One operator requirement: avoidSameNode expresses the avoidance through the scheduler's nodeIdLabel, so that label must be in the executor's trackedNodeLabels.
Two small event-stream changes apply with the flag off as well, both additive. A terminal lease expiry now emits a JobFailedEvent with the reason "Lease expired" instead of an empty reason. Failure category fields now appear on all failure reason kinds, not only pod errors. Consumers that match on empty reason strings will see the new values.
Validating the full loop
The OOM-with-memory-bump loop is the best single check. Enable the flag and give a category for OOMKilled conditions action Delete, then:
Submit a job with a 64Mi memory limit that writes 100Mi into a memory-backed emptyDir. Expect: the run fails with category oom and a retryable=true event, the pod disappears before that event arrives, and the same pod name comes back with a 128Mi limit and succeeds. Lookout shows two runs: one failed with the category, one succeeded. This sequence was validated on a live cluster, together with avoidSameNode relocating a retry to a second node and failing the job cleanly once every node had been tried.