Fix duplicate heartbeat-timeout task callbacks#68008
Conversation
|
@hkc-8010 A few things need addressing before review — see our Pull Request quality criteria.
No rush. Note: This comment was drafted by an AI-assisted triage tool and may contain mistakes. Once you have addressed the points above, an Apache Airflow maintainer — a real person — will take the next look at your PR. We use this two-stage triage process so that our maintainers' limited time is spent where it matters most: the conversation with you. |
|
Thanks @potiuk. I addressed the static/pre-commit failure in The failed check was caused by Validated locally:
Could you please take another look when you get a chance? |
|
@hkc-8010 there are conflicts to resolve |
ca401fa to
e06ab5e
Compare
Thanks. I've resolved the conflicts. |
jscheffl
left a comment
There was a problem hiding this comment.
Looks good to me. But as I am not an expert on callbacks - is it OK from "state model" if callback is executed async that the state of the task is already set to failed? Or will this be achange to previous executions (that callbacks assumed the tasks are still in "running" state and will this harm callback logic?
|
Thanks @jscheffl, good question. I think this is OK from the state-model side and does not need an extra code change. This makes the heartbeat-timeout path consistent with the existing scheduler path for externally killed tasks: the scheduler creates/sends the The callback itself is still executed asynchronously from the callback request payload. That payload is built before The main issue this PR fixes is that leaving the DB row as |
|
This closes the duplicate-scan window, but it now makes the heartbeat-timeout request the durable callback path for retryable tasks. Since the request is built before |
…ligibility Address review feedback: the heartbeat-timeout TaskCallbackRequest didn't set task_callback_type, so a timed-out task with retries left could dispatch on_failure_callback instead of on_retry_callback. Mirrors the existing externally-killed-task pattern.
|
@Vamsi-klu Good catch, fixed. |
ephraimbuddy
left a comment
There was a problem hiding this comment.
Can you add this test I suggested?
The purge path called handle_failure() without ever loading ti.task, so fail_fast (ti.task.dag.fail_fast) silently no-opped and the external-kill email path in process_executor_events never ran for these TIs since the TI already left RUNNING/RESTARTING by the time any executor event for it was processed. It also computed task_callback_type with a max_tries > 0 guard that could disagree with what handle_failure() actually persists for a RESTARTING TI with max_tries=0. Load ti.task before calling handle_failure(), decide the callback type with plain is_eligible_to_retry() (matching fetch_handle_failure_context exactly), and send an EmailRequest alongside the existing TaskCallbackRequest. closes: apache#42553
|
@ephraimbuddy Addressed all three comments in 8f9eb4b — loads |
The heartbeat scan selected RUNNING/RESTARTING TIs unlocked, and the same session flowed into the purge. Between the scan and ti.handle_failure(), a worker could commit the task as SUCCESS; handle_failure() refreshes the TI but sets FAILED unconditionally, clobbering that terminal state after having already enqueued a failure callback. Lock the scan query with with_row_locks(of=TI, skip_locked=True) so a worker can't change the row between the scan and the handle_failure() that follows in the same transaction, matching how process_executor_events and the other TI-mutating paths in this file lock. As defense in depth, revalidate each row's committed state at the top of the purge loop and skip it (no callback, no email, no handle_failure) if it is no longer RUNNING/RESTARTING. closes: apache#42553
|
@ephraimbuddy Fixed the race in 3c9eef3: the scan now locks the rows (with_row_locks, skip_locked) and the purge revalidates each TI's committed state before failing it, so a concurrently-completed task isn't clobbered and gets no failure callback. Tests added for both the race and the lock. Mind taking another look? |
There was a problem hiding this comment.
Pull request overview
This PR updates the scheduler’s heartbeat-timeout cleanup so that, after enqueuing the callback request for a heartbeat-timed-out task instance, it immediately runs the normal task-instance failure transition to move the task instance out of RUNNING in the same scheduler pass—preventing duplicate same-try callback emission on subsequent scans.
Changes:
- Add row-locking to the heartbeat-timeout scan and re-check state in the purge loop to reduce race windows.
- In the heartbeat-timeout purge path, load the serialized task (when available), enqueue the callback request (and email request when configured), then immediately call
ti.handle_failure(...)to persist the non-RUNNINGstate. - Add/extend regression tests around deduplication, callback typing, email behavior,
fail_fast, concurrent completion, and row-locking semantics.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| airflow-core/src/airflow/jobs/scheduler_job_runner.py | Updates zombie/heartbeat-timeout detection + purge to enqueue callbacks and immediately transition TI state out of RUNNING, with added locking and context loading. |
| airflow-core/tests/unit/jobs/test_scheduler_job.py | Adds regression coverage to ensure heartbeat-timeout cleanup converges TI state before the next scan and preserves expected callback/email/fail_fast behaviors. |
ephraimbuddy
left a comment
There was a problem hiding this comment.
Nit.
Also fix the copilot review
Use a real MockExecutor(do_update=False) instead of a bare MagicMock() in test_heartbeat_timeout_converges_ti_state_before_next_scan, matching the other heartbeat-timeout tests, and assert on callback_sink.send. Reword the _resolve_ti_callback_bundle_info docstring: it is used by the heartbeat-timeout purge path; process_executor_events inlines the same resolution rather than sharing this helper.
closes: #42553
Description
When the scheduler detects a running task instance that has stopped heartbeating, it currently builds and enqueues a
TaskCallbackRequest, but the task instance can remain inRUNNINGuntil the callback is processed asynchronously.That leaves a window where the next scheduler heartbeat-timeout scan can find the same task instance attempt again and enqueue another callback for the same try.
This changes the heartbeat-timeout purge path to enqueue the existing callback request, then immediately run the established task-instance failure transition path so the task instance leaves
RUNNINGin the same scheduler pass.Executor cleanup via
executor.change_state(..., FAILED, remove_running=True)is unchanged.Related: #66767 handles callback type metadata for heartbeat-timed-out retries. This PR is scoped to preventing duplicate same-try callback emission.
Tests
uv run --frozen --no-sync pytest --with-db-init airflow-core/tests/unit/jobs/test_scheduler_job.py::TestSchedulerJob::test_heartbeat_timeout_converges_ti_state_before_next_scan -quv run --frozen --no-sync pytest airflow-core/tests/unit/jobs/test_scheduler_job.py::TestSchedulerJob::test_heartbeat_timeout_converges_ti_state_before_next_scan -quv run --frozen --no-sync pytest airflow-core/tests/unit/jobs/test_scheduler_job.py -k "heartbeat_timeout" -quv run --frozen --no-sync pytest airflow-core/tests/unit/jobs/test_scheduler_job.py -qprek run --files airflow-core/src/airflow/jobs/scheduler_job_runner.py airflow-core/tests/unit/jobs/test_scheduler_job.py.venv/bin/python scripts/ci/prek/run_mypy_full_dist_local_venv_or_breeze_in_ci.py airflow-corebreeze testing core-tests --backend postgres --python 3.10 --db-reset -- airflow-core/tests/unit/jobs/test_scheduler_job.py -k "heartbeat_timeout" -qbreeze testing core-tests --backend sqlite --python 3.10 --force-lowest-dependencies --db-reset -- airflow-core/tests/unit/jobs/test_scheduler_job.py -k "heartbeat_timeout" -q