AIP-97 Pillar 2: classify infra task failures and refund the retry budget - #6
AIP-97 Pillar 2: classify infra task failures and refund the retry budget#61fanwang wants to merge 3 commits into
Conversation
When a worker dies from outside the process (pod eviction, OOM, node drain), the scheduler reported the failure to the listener as a bare string and spent one of the user's retries on it. Rename the failure-context value object to TaskFailureInfo (avoiding the existing Kubernetes FailureDetails TypedDict), add a source field, and populate it on the executor-reported-failure path so the failed-task listener can tell infrastructure from application failures. When the source is infra, refund the attempt instead of charging the user's retry budget, gated by config and bounded by a cap. Extends the pre-execution behavior the Kubernetes executor already has (requeue without consuming a task retry) to the mid-execution case. Signed-off-by: 1fanwang <1fannnw@gmail.com>
… | timeout) Aligns the POC type contract with the AIP: source is the closed discriminant, preemption is infra + infra_reason, not a peer. Docstring + inline only; the foundation still populates infra on the worker-died path. 9/9 listener tests pass. Signed-off-by: 1fanwang <1fannnw@gmail.com>
The retry refund now lives in _maybe_refund_infra_attempt(): it refunds only when source=='infra', the flag is on, and under the max_infra_refunds cap. App/user/timeout and unclassified (None) failures never refund, so a real bug still spends the user's budget. Six deterministic tests pin the gate — the classifier decides *when* a failure is infra; this gate decides *whether* an infra classification refunds. Signed-off-by: 1fanwang <1fannnw@gmail.com>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…nt (done-criterion #6) Signed-off-by: 1fanwang <1fannnw@gmail.com>
|
Superseded by the split pillar #13 (infra refund) and the consolidated apache#66405. Closing; branch kept. |
Why
When a worker dies from outside — pod eviction, OOM kill (SIGKILL), spot preemption, heartbeat loss — Airflow can't tell it apart from a real task crash. Two costs:
on_task_instance_failedreceiveserror=None(or a stringified dict), so alerting and OpenLineage lump infra churn in with genuine bugs.retries=2plus two evictions leaves nothing for the code's first real failure.Airflow already refuses to spend a retry on infra for one case —
_is_pre_execution_failure()requeues a pod that died whileQUEUED"without consuming a task retry." It stops at theQUEUEDboundary because once a task isRUNNINGit can't tell eviction from a crash. This adds the missing signal and extends that behavior past the boundary.Stacked on the AIP-97 foundation (#5). This is the Pillar-2 delta: carry structured failure context to the listener, and refund the retry when the failure is infra.
What
TaskFailureInfo(source="infra", ...)and hands it to the failure path.on_task_instance_failednow carriesfailure_details, so listeners see structured origin instead of a flattened string._maybe_refund_infra_attempt()bumpsmax_triesonly whensource=="infra", the[core] infra_failure_refund_retriesflag is on, and under themax_infra_refundscap.try_numberstays monotonic — noretries==0special case.sourceis a closed set —user | infra | timeout; preemption isinfra+infra_reason="preemption", matching FlyteErrorKind, K8sDisruptionTarget, SparkexitCausedByApp.On classification — two layers, and what this POC proves
Classification is two layers. The base discriminator is the state-mismatch signal (
ti_queued): the executor reports the task finished while the TI still looksQUEUED/RUNNING, i.e. the worker died before self-reporting. Same signal_is_pre_execution_failureuses, extended past theQUEUEDboundary. On a real deployment an app failure catches in the worker and PATCHesstate=FAILEDbefore the executor event, so it never lands here; a killed worker does. This is executor-agnostic and needs only that workers self-report — which they do once an api-server is up.The base layer can't split a silent death that is genuinely infra (node evicted the pod) from one that is app-caused (app OOM). That lives in the executor — the K8s bridge (
collect_pod_failure_details()→OOMKilledvsEvictedvs an app exit code), done-criterion #2, which supplies the preciseinfra_reasonand excludes app OOM. This PR ships the contract + the base discriminator + the refund gate; the K8s bridge refines the residual.The safety property — an app/user/timeout/unclassified failure never refunds — is pinned by the refund gate with unit tests, independent of the classifier.
Testing Done
Live e2e on a real multi-component deployment —
airflow standalone(api-server on :8080 serving the Execution API + scheduler + dag-processor + triggerer), PostgreSQL 14, LocalExecutor, refund flag on (max_infra_refunds=3), one recording listener. Because the api-server is up, workers self-report — so an app failure and an eviction take different paths. Two DAGs,retries=1:app_fail_reproraisesValueError;evict_reproSIGKILLs its own running worker (eviction signature).The split is clean on one deployment — DB
task_instancefinal state:app_fail_reproValueErrorself-reported → retry consumed, not refundedevict_reproRaw listener + scheduler logs
(An earlier sqlite-only harness with no api-server mis-read the app failure as infra — the worker couldn't self-report, so it fell through the same branch as an eviction. Standing up the api-server so the worker self-reports is the fix; the split is clean above.)
Unit — the safety gate.
_maybe_refund_infra_attempt(): 6 deterministic cases — infra refunds once;None(app path),user,timeoutdon't; flag-off doesn't; the cap bounds it to 3. Plus 9 listener-type tests (construction, frozen, pluggy no-default dispatch). 15/15 pass.Out of scope / follow-ups
max_triesshares the counter with app retries. A precise "infra attempts never count" semantic needs a separate infra-attempt counter — noted as an AIP open question.Back-compat + opt-in (verified live)
on_task_instance_failed(previous_state, task_instance, error)hookimpl (nofailure_details) fires unchanged alongside one that declares the new param — pluggy dispatches by name. No migration for existing listeners.[core] infra_failure_refund_retriesdiffers: OFF (default) → infra kill spends the retry (max_tries=1, stock); ON → refunded to the cap (max_tries=4). Default behavior is exactly today's.TaskFailureInfowhen the refund is disabled, so a deployment can adopt the failure-source context without changing retry accounting.Risk
Pillar 1 (the listener arg) is additive —
failure_details=Noneat every existing call site; listeners that ignore it are unaffected. Pillar 2 (the refund) is opt-in, config-gated, capped, and unit-pinned tosource=="infra"only. Classification is heuristic; unknown staysNoneand behaves as today.