Skip to content

Wire the retry policy engine into the scheduler behind a feature flag - #5001

Merged
dejanzele merged 8 commits into
armadaproject:masterfrom
dejanzele:scheduler-retry-by-category
Aug 5, 2026
Merged

Wire the retry policy engine into the scheduler behind a feature flag#5001
dejanzele merged 8 commits into
armadaproject:masterfrom
dejanzele:scheduler-retry-by-category

Conversation

@dejanzele

@dejanzele dejanzele commented Jul 8, 2026

Copy link
Copy Markdown
Member

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:

# armadactl create retry-policy -f policy.yaml
apiVersion: armadaproject.io/v1beta1
kind: RetryPolicy
name: oom-grow
retryLimit: 2
defaultAction: Fail
rules:
  - action: Retry
    onCategory: oom
    mutate:
      resources:
        memory:
          factor: 2
# armadactl create queue oom-grow-queue --retry-policies oom-grow

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.

@datadog-armadaproject

datadog-armadaproject Bot commented Jul 8, 2026

Copy link
Copy Markdown

Pipelines

⚠️ Warnings

🚦 4 Pipeline jobs failed

CI | All jobs succeeded   View in Datadog   GitHub Actions

CI | test / Golang Integration Tests   View in Datadog   GitHub Actions

See error Container exited with code 255 while executing 'docker compose -f _local/compose/full.yaml up -d --wait'.

Python Airflow Operator | airflow-integration-tests   View in Datadog   GitHub Actions

See error Error running 'docker compose -f _local/compose/full.yaml up -d --wait'. Process exited with code 1.

View all 4 failed jobs.

Useful? React with 👍 / 👎

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 7d4f335 | Docs | Datadog PR Page | Give us feedback!

@greptile-apps

greptile-apps Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR integrates category-based retry-policy decisions into scheduler failure and lease-expiry handling behind a disabled-by-default feature flag.

  • Adds policy lookup, retry budgets, retryable failure events, and terminal policy reasons.
  • Applies optional memory growth and node avoidance before requeueing.
  • Adds configuration validation, scheduler metrics, API fields, documentation, and end-to-end scheduler tests.

Confidence Score: 5/5

The 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.

Important Files Changed

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
Loading

Reviews (48): Last reviewed commit: "Clarify that the retry probe checks stat..." | Re-trigger Greptile

Comment thread internal/server/event/conversion/conversions.go
Comment thread internal/scheduler/scheduler.go Outdated
Comment thread internal/scheduler/scheduler.go
@dejanzele
dejanzele force-pushed the scheduler-retry-by-category branch 4 times, most recently from a5548c9 to 0dd1f0a Compare July 9, 2026 14:39
Comment thread internal/scheduler/retry/engine.go Outdated
@dejanzele
dejanzele force-pushed the scheduler-retry-by-category branch 4 times, most recently from ec2f749 to f1a4220 Compare July 9, 2026 16:26
@dejanzele

Copy link
Copy Markdown
Member Author

@greptileai

@dejanzele
dejanzele force-pushed the scheduler-retry-by-category branch 6 times, most recently from 1cd6880 to bc9552d Compare July 10, 2026 15:05
@dejanzele

Copy link
Copy Markdown
Member Author

@greptileai

@dejanzele
dejanzele force-pushed the scheduler-retry-by-category branch 10 times, most recently from de47ab7 to 94b0640 Compare July 17, 2026 09:55
@dejanzele
dejanzele force-pushed the scheduler-retry-by-category branch 2 times, most recently from d6700be to 6188cf9 Compare August 4, 2026 23:37
Comment thread internal/scheduler/jobdb/job.go
@dejanzele
dejanzele force-pushed the scheduler-retry-by-category branch 4 times, most recently from e0bfdd4 to ead1f14 Compare August 5, 2026 10:09
@dejanzele

Copy link
Copy Markdown
Member Author

@greptileai

@dejanzele
dejanzele force-pushed the scheduler-retry-by-category branch 2 times, most recently from 4dd5570 to e5d59f8 Compare August 5, 2026 10:58
masipauskas
masipauskas previously approved these changes Aug 5, 2026
Comment thread docs/retry_policies.md Outdated

## 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.

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.

Code should validate this is the case, and fail with retry policy being created which would violate this.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Same as the other thread, this needs the executors to report their category configs first. Planning it as a follow-up.

Comment thread docs/retry_policies.md

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.

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.

Code should validate this is the case, and fail with retry policy being created which would violate this.

@dejanzele dejanzele Aug 5, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

Comment thread docs/retry_policies.md Outdated
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.

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.

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.

@dejanzele dejanzele Aug 5, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.")

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.

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?

@dejanzele dejanzele Aug 5, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

Comment thread internal/scheduler/scheduler.go Outdated
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"},

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.

are we intentional on not exposing retries by queue+policy?

@dejanzele dejanzele Aug 5, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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})

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.

does it keep submit checker caching? as it can get very slow, very quickly?

@dejanzele dejanzele Aug 5, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

@mergify

mergify Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Queued — the merge queue status continues in this comment ↓.

Signed-off-by: Dejan Zele Pejchev <pejcev.dejan@gmail.com>
@dejanzele
dejanzele force-pushed the scheduler-retry-by-category branch from 7d7495f to ed79f3d Compare August 5, 2026 12:45
…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>
@mergify

mergify Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Merge Queue Status

  • 🟠 Waiting for queue conditions
  • ⏳ Enter queue
  • ⏳ Run checks
  • ⏳ Merge
Waiting for any of
  • check-neutral = Rule: Require approval from Armada maintainers (post_check)
  • check-skipped = Rule: Require approval from Armada maintainers (post_check)
  • check-success = Rule: Require approval from Armada maintainers (post_check)
All conditions
  • any of [🔀 queue conditions]:
    • all of [📌 queue conditions of queue rule default]:
      • any of [🛡 GitHub branch protection]:
        • check-neutral = Rule: Require approval from Armada maintainers (post_check)
        • check-skipped = Rule: Require approval from Armada maintainers (post_check)
        • check-success = Rule: Require approval from Armada maintainers (post_check)
      • github-require-last-push-approval [🛡 GitHub branch protection]
      • github-review-approved [🛡 GitHub branch protection]
      • any of [🛡 GitHub branch protection]:
        • check-success = All jobs succeeded
        • check-neutral = All jobs succeeded
        • check-skipped = All jobs succeeded
  • -closed [📌 queue requirement]
  • -conflict [📌 queue requirement]
  • -draft [📌 queue requirement]
  • any of [📌 queue -> configuration change requirements]:
    • -mergify-configuration-changed
    • check-success = Configuration changed

@dejanzele
dejanzele requested a review from masipauskas August 5, 2026 15:46
@mergify

mergify Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

queue

⚠️ Configuration not compatible with a branch protection setting

Details

The branch protection setting Require branches to be up to date before merging is not compatible with draft PR checks. To keep this branch protection enabled, update your Mergify configuration to enable in-place checks: set merge_queue.max_parallel_checks: 1, set every queue rule batch_size: 1, and avoid two-step CI (make merge_conditions identical to queue_conditions). Otherwise, disable this branch protection.

@dejanzele
dejanzele merged commit b8f26e8 into armadaproject:master Aug 5, 2026
40 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants