Repository navigation
Enforce execution_timeout from the task supervisor side - #73806
Conversation
The timeout is only enforced by a SIGALRM handler inside the task process. When that process is stuck in native code, deadlocked on a lock inherited across fork, or corrupted by SIGSEGV, the handler never runs and the task stays running forever. The task process now tells the supervisor its timeout before starting execute(), and the supervisor sends SIGTERM and then SIGKILL if the task has not reported a terminal state within a grace period. The in-process handler stays as the first responder because on_kill, the retry policy and callbacks can only run there.
…7-takeover Resolve supervisor.py: port the SetExecutionTimeout handling onto main's request-handler dispatch, replacing the isinstance branch with _handle_set_execution_timeout plus a register_request_method entry.
…7-takeover # Conflicts: # task-sdk/src/airflow/sdk/execution_time/schema/versions/__init__.py # task-sdk/src/airflow/sdk/execution_time/schema/versions/v2026_10_30.py # task-sdk/src/airflow/sdk/execution_time/supervisor.py
SameerMesiah97
left a comment
There was a problem hiding this comment.
Just 2 questions.
SameerMesiah97
left a comment
There was a problem hiding this comment.
It looks good to me but since this is touching a fairly sensitive part of airflow core, I think this needs opinions from the tagged maintainers for review before merge.
When a heartbeat gets 404, 409 or 410 back, the supervisor kills the task with SIGTERM and escalates to SIGKILL after kill()'s default of 5 seconds. killed_task_cleanup_time documents that wait, and Airflow 2 used it for this exact case, a task whose state was changed from outside. Pass it as the escalation delay, and rename the constant that holds it now that the execution_timeout backstop is not its only user.
…7-takeover Resolve schema/versions: take main's side and drop AddExecutionTimeoutSupervisorMessage. Main now documents in v2026_10_30.py that a brand-new message body needs no VersionChange entry, and the empty entry was exactly that case.
The supervisor schema gained SetExecutionTimeout, so the generated Go coordinator-protocol models were out of date and check-go-sdk-generated-drift failed in CI.
|
I'm not sure this will fix #57174 though -- as dag parsing is needed before the child can know the desired timeout to report to the scheduler.
Given not all tasks have timesouts, I think the SetTimeout(0) approach is "it's always correct, and easier to reason about even if fractionaly slower" |
The supervisor kept its own SIGTERM-then-SIGKILL state machine for a task that overran execution_timeout. kill(force=True) already escalates along the same path and handles a process that is already gone, so call it with killed_task_cleanup_time as the escalation delay instead. This drops the next-signal field and folds the timeout deadline into the monitor loop's existing select timeout. The tests now advance a fake monotonic clock and assert the kill call and the log record instead of private attributes.
83ebc09 to
9dbe650
Compare
I think this PR will fix that issue, since the log there shows the SIGTERM comes after parsing.
But yeah I agree we need this. Could we do it in a follow-up? (I would like to make this as small as possible) |
ashb
left a comment
There was a problem hiding this comment.
Much, much easier to understand now.
|
Thanks! |
|
Yes. I moved it to related bc I couldn't really reproduce it. |
#73806 added the SetExecutionTimeout message to the supervisor wire schema and regenerated the Go SDK models from it, but the TS SDK's generated `src/generated/supervisor.ts` was left behind. Its supervisor-schema static check has failed on main ever since, because the committed types no longer match what the schema produces.
The TS SDK read the supervisor wire schema straight from the Task SDK source and had no hook watching that source, so a schema change there — a new comms message, say — regenerated the Go SDK (whose sync hook does watch the source) but left `src/generated/supervisor.ts` stale, with nothing to catch it until some unrelated ts-sdk change happened to run the local check. That is how apache#73806 reddened main. Vendor the supervisor schema into `ts-sdk/schema/` alongside the Dag schema, and sync both from their Python sources in one `sync-ts-sdk-schemas` hook — a `VENDORED_SCHEMAS` list the way `sync-go-sdk-schemas` does it, replacing the single-schema `sync-ts-sdk-dag-schema`. A schema change now refreshes the vendored copy and fails loudly in the same commit instead of shipping a stale generated file.
The TS SDK read the supervisor wire schema straight from the Task SDK source and had no hook watching that source, so a schema change there — a new comms message, say — regenerated the Go SDK (whose sync hook does watch the source) but left `src/generated/supervisor.ts` stale, with nothing to catch it until some unrelated ts-sdk change happened to run the local check. That is how apache#73806 reddened main. Vendor the supervisor schema into `ts-sdk/schema/` alongside the Dag schema, and sync both from their Python sources in one `sync-ts-sdk-schemas` hook — a `VENDORED_SCHEMAS` list the way `sync-go-sdk-schemas` does it, replacing the single-schema `sync-ts-sdk-dag-schema`. A schema change now refreshes the vendored copy and fails loudly in the same commit instead of shipping a stale generated file.
* Sync the TS SDK vendored schemas from their Python sources The TS SDK read the supervisor wire schema straight from the Task SDK source and had no hook watching that source, so a schema change there — a new comms message, say — regenerated the Go SDK (whose sync hook does watch the source) but left `src/generated/supervisor.ts` stale, with nothing to catch it until some unrelated ts-sdk change happened to run the local check. That is how #73806 reddened main. Vendor the supervisor schema into `ts-sdk/schema/` alongside the Dag schema, and sync both from their Python sources in one `sync-ts-sdk-schemas` hook — a `VENDORED_SCHEMAS` list the way `sync-go-sdk-schemas` does it, replacing the single-schema `sync-ts-sdk-dag-schema`. A schema change now refreshes the vendored copy and fails loudly in the same commit instead of shipping a stale generated file. * Fix comment for schema sync hook in generate-supervisor.mjs Update comments to reflect the correct hook name for syncing schemas. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Add tests for the TS schema sync and fix references left by the rename Addresses the review on #74415: - Move sync_ts_sdk_schemas.py to scripts/ci/prek/ beside sync_go_sdk_schemas.py and add test_sync_ts_sdk_schemas.py mirroring the Go sync's tests, so the refresh and report logic is covered (in-sync, stale, missing copy/source, and the per-schema and combined reports). - List the script itself in the sync-ts-sdk-schemas files filter, so a change to its paths or report logic re-runs the hook, as sync-go-sdk-schemas does. - Point generate-dag-schema.mjs at the renamed sync-ts-sdk-schemas hook. - Update the selective-check comment and 04_selective_checks.md, which still described the dropped "defer ts-sdk regeneration to a follow-up PR" policy. --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
…uage SDK (#74869)" (#75376) (#75469) This reverts commit 8326025. 3.4.0 closes today and #75376 needs #75387 alongside it, which needs more bandwidth than is left. On v3-4-test alone, the Python task runner arms SIGALRM from task.execution_timeout while the supervisor arms its deadline from TIRunContext.execution_timeout. When the server value is smaller, the supervisor sends SIGTERM before the runner raises AirflowTaskTimeout, so on_kill, the retry policy, callbacks and listeners are skipped. Reverting avoids shipping that regression in 3.4.0. #74869 stays on main. SetExecutionTimeout (#73806) is untouched, so Python tasks keep the supervisor backstop they had before #75376. Co-authored-by: Claude <noreply@anthropic.com>
Exact same work in #72493. Created this because that one was closed by the PR number limit policy.
Why
execution_timeoutis only enforced by a SIGALRM handler inside the task runner process. When that process is stuck in native code or hit by SIGSEGV, the handler never runs and the task stays running forever (#57174, #57712, #45930).To tackle the problem at its root, the better solution is to handle the runner's timeout from the supervisor side.
What
The task process sends a new
SetExecutionTimeoutmessage to the supervisor right before it arms its own alarm, so both clocks start at the same point. If the task has not reported a terminal state within[core] killed_task_cleanup_timeseconds (default 60) after the timeout, the supervisor reuseskill(SIGTERM, force=True)to send SIGTERM to the process group, and SIGKILL after the same period again. kill() blocks the monitor loop while it waits, so no heartbeat is sent during that time. By default that is at most 120 seconds, under the 300 second task_instance_heartbeat_timeout.This keeps the task runner's SIGALRM handler instead of "moving it", which is a bit different from what #53337 asks for. The reason is that
on_kill, the retry policy and the failure callbacks can only run inside the task process, and dropping the handler would skip them on every timeout.closes: #53337, closes #57174, closes #57712, closes #45930
Was generative AI tooling used to co-author this PR?
Generated-by: Claude Code (Fable 5.1) following the guidelines