fix(k8s): skip health check failure if STEP_SUCCESS already in event log#33604
Conversation
Greptile SummaryThis PR fixes a race condition in
Confidence Score: 4/5The executor change itself is logically correct and handles the race condition well; the test added to validate it has a design flaw that would cause it to fail in practice. The production fix in step_delegating_executor.py is sound — the seen_storage_ids guard cleanly distinguishes unconsumed events from consumed ones, handling both the TTL and OOMKill races. The concern is in the new test: it runs a 3-op parallel job but only injects a mock unconsumed STEP_SUCCESS for slow_op_0. Health checks for slow_op_1 and slow_op_2 will find no matching unconsumed STEP_SUCCESS during their 2-second sleep window, so false failures will be emitted for them — breaking the test's own assertions of zero failures and a successful run. The new test in test_step_delegating_executor.py needs attention: the mock injecting an unconsumed STEP_SUCCESS must cover all steps in the job, not just slow_op_0.
|
| Filename | Overview |
|---|---|
| python_modules/dagster/dagster/_core/executor/step_delegating/step_delegating_executor.py | Adds a DB guard before emitting STEP_FAILURE: checks for unconsumed STEP_SUCCESS or STEP_UP_FOR_RETRY events in the event log before declaring a health-check failure, correctly filtering by seen_storage_ids to avoid suppressing genuine failures on retry attempts. Logic is sound; typo fix in comment also included. |
| python_modules/dagster/dagster_tests/execution_tests/engine_tests/test_step_delegating_executor.py | Adds two new tests for the health-check race fix and a consumed-STEP_UP_FOR_RETRY regression guard. The first new test has a design flaw: it only injects a mock STEP_SUCCESS for slow_op_0 while running a 3-op parallel job, leaving slow_op_1 and slow_op_2 unprotected from false failures. |
Sequence Diagram
sequenceDiagram
participant Pod as K8s Pod
participant DB as Event Log DB
participant Tailer as _pop_events
participant HealthCheck as check_step_health
participant Executor as StepDelegatingExecutor
Pod->>DB: Write STEP_SUCCESS
Note over Pod: TTL deletes job / OOMKill
HealthCheck->>HealthCheck: fires (interval elapsed)
HealthCheck->>Executor: returns unhealthy (job gone/failed)
Note over Executor: NEW: guard before emitting failure
Executor->>DB: "get_records_for_run(STEP_SUCCESS | STEP_UP_FOR_RETRY)"
DB-->>Executor: [STEP_SUCCESS record, storage_id not in seen_storage_ids]
Executor->>Executor: "step_already_handled = True, continue (skip failure)"
Tailer->>DB: get_records_for_run (next poll)
DB-->>Tailer: STEP_SUCCESS record
Tailer->>Executor: yield STEP_SUCCESS event
Executor->>Executor: del running_steps[step.key]
Reviews (3): Last reviewed commit: "test(step_delegating_executor): add heal..." | Re-trigger Greptile
|
trying you again @dpeng817 to get attention. maybe you could add relevent person from the team? |
|
Thanks for the fix @futurewasfree — the diagnosis is right, and the guard is in the correct place to close this race. Apologies for the slow turnaround. I pushed 4ecfb5c to your branch (it has "allow edits by maintainers" enabled) to scope the guard to records the event tailer hasn't consumed yet ( The remaining gap before this can merge is test coverage. The right spot is |
Fixes a race condition reported in dagster-io#22565 where steps would be marked as failed despite having already succeeded on the K8s executor. Two scenarios trigger this: 1. TTL race — the K8s job is cleaned up by ttlSecondsAfterFinished before the executor's _pop_events polling loop processes the STEP_SUCCESS event, causing check_step_health to return unhealthy (job not found). 2. OOMKill race — the pod is killed during post-success cleanup (after writing STEP_SUCCESS but before the process exits), causing the K8s job to show status.failed > 0. Fix: before emitting STEP_FAILURE from a health check result, query the event log for a STEP_SUCCESS event for the step. If found, skip the failure — _pop_events will consume the success on the next iteration and remove the step from running_steps cleanly.
…step_delegating_executor.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
…step_delegating_executor.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
A consumed STEP_UP_FOR_RETRY record from a prior attempt of the same step would otherwise suppress a genuine health-check failure of the current attempt on every polling round, leaving the step in running_steps forever and hanging the run. Restrict the guard to records the event tailer has not yet consumed, which is exactly the race window this fix targets. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
add regression test for unhealthy health checks when an unconsumed STEP_SUCCESS is present in event log storage assert STEP_FAILURE is not emitted in that unconsumed-event window add retry regression test to ensure a consumed prior-attempt STEP_UP_FOR_RETRY does not suppress a real failure on the next attempt assert STEP_FAILURE is emitted for the unhealthy second attempt
ae1a122 to
67251dd
Compare
Could you take a look again? :) @smackesey |
Summary & Motivation
Fixes a race condition in the K8s executor (#22565) where a step could be marked as failed even though it had already emitted
STEP_SUCCESS.The
StepDelegatingExecutorruns two concurrent concerns in the same polling loop: tailing the event log via_pop_events(every ~1s) and running a K8s job health check viacheck_step_health(every 20s). A step is only removed fromrunning_stepsonce_pop_eventsreturns aSTEP_SUCCESSevent. This creates a window where the health check fires, finds the K8s job gone or failed, and emitsSTEP_FAILURE— even thoughSTEP_SUCCESSis already in the database, just not yet consumed by the tailer.Two concrete triggers:
TTL race —
ttlSecondsAfterFinishedis short enough that Kubernetes deletes the job between the pod writingSTEP_SUCCESSand the executor polling it.get_job_statusreturns 404, health check returns unhealthy.OOMKill race — the pod is killed during post-success cleanup (IO manager teardown, connection closing, etc.) after writing
STEP_SUCCESSbut before the process exits. Kubernetes marks the job as failed (status.failed > 0), health check returns unhealthy.The fix is in
StepDelegatingExecutor.execute: when a health check returns unhealthy, query the event log directly for aSTEP_SUCCESSevent matching the step key. If one exists, skip emitting the failure — the next_pop_eventsiteration will consume the success event and cleanly remove the step fromrunning_steps.How I Tested These Changes
Manually traced the race condition through the executor polling loop. The query uses
of_type=DagsterEventType.STEP_SUCCESS, which is indexed in the event log schema, so the overhead on the genuine-failure path is one narrow DB query before the failure is emitted.Changelog
Fixed an intermittent false
STEP_FAILUREon the K8s executor where a step that had already succeeded could be marked as failed if the Kubernetes job was deleted (viattlSecondsAfterFinished) or killed (OOMKill during cleanup) before the executor's event tailer processed theSTEP_SUCCESSevent.