Repository navigation
Enforce execution_timeout for tasks run by a language SDK - #74869
Conversation
|
Congratulations on your first Pull Request and welcome to the Apache Airflow community! If you have any issues or are unsure about any anything please check our Contributors' Guide
|
henry3260
left a comment
There was a problem hiding this comment.
Thanks for the contribution! leave some comments
Doc nit: airflow-core/docs/core-concepts/tasks.rst:143 says the supervisor enforces the timeout "For tasks executed by the Python task runner". After this PR that is no longer true it also covers stub and native language-SDK tasks. Could we add a line saying so, or at least drop the "Python task runner" qualifier?
jason810496
left a comment
There was a problem hiding this comment.
Thanks for the PR.
Would it be better to align the execution_timeout at the core instead of fixing at each SDK?
By this way, we could treat the execution_timeout pin down at the Dag processing time as source of truth rather than retrieving the default value at the task runtime?
Additionally, for the SetExecutionTimeout introduced in #73806: would it be better to rename as StartExecutionTimeout and remove the execution_timeout field? -- cc @Andrushika
From my perspective, no matter it is Python task or any language SDK task, the execution_timeout should be figured out at the Execution API like current PR shape, and the runtime of each SDK should respect it from the TIRunContext. The coordinator level should be able to emit the signal once instead of implementing the signal for each SDK.
|
Thanks Jason for taking a look! Quick update on where the PR is: killing a timed-out process is already handled in one place by the supervisor. The language SDK coordinators go through ActivitySubprocess too, so Go / TS / Java don't need any changes. The only thing that's still different is where the value comes from: Python still works it out itself and sends it to the supervisor. Here's roughly what I'm planning to change:
A couple of things I'd like to check first:
|
Agree that it will be cleaner -- task runners in all SDKs don't need to work out the timeout by themselves anymore. But we still need to handle users' custom cleanup logic after the timeout. To be more specific, make all lang SDKs signal-aware (like what #74690 is doing for Java), so that lang SDKs only need to listen to the signal and handle their own cleanup.
Hmmm I think a better solution could be making the supervisor only start the clock on the start message for Python tasks (the task runner sends it right before
Thanks @ChickenBenny, I think it's fine to handle that in this PR. Since Also, two more things I want to mention:
But these should be separate follow-up PRs. Just put it here as a reminder. I think we can first get the timeout working here and try to make it for 3.4. |
|
FYI @ChickenBenny, this graph helped me understand the architecture between coordinators, executors, and the task runner a lot when I was a beginner. I hope this can help you as well :) flowchart LR
subgraph SP["Scheduler OS process"]
subgraph LE["LocalExecutor object"]
AQ["activity_queue<br/>IPC"]
RQ["result_queue<br/>IPC"]
WH["workers<br/>Process handles"]
end
end
subgraph WP["Local worker OS process|可重用"]
LOOP["_run_worker()<br/>get loop"]
CM["CoordinatorManager.for_queue()<br/>[sdk] queue_to_coordinator"]
PYC["_PythonCoordinator<br/>queue 沒對應時的 fallback"]
LSC["SubprocessCoordinator<br/>Executable/Java/Node"]
subgraph SV["Supervisor|supervise_task()"]
SUP["ActivitySubprocess object<br/>WatchedSubprocess subtype"]
PSUP["_PopenActivitySubprocess object<br/>ActivitySubprocess subtype"]
end
end
subgraph TP["Task Runner child process|每個 Python task 一個"]
TR["task_runner.main()<br/>operator user code"]
end
subgraph LP["Lang runtime process|每個 lang SDK task 一個"]
GO["Go: bundle binary<br/>BundleRef.Serve()"]
JV["Java: java -classpath ...<br/>Server.serve()"]
TS["TS: node bundle.mjs<br/>bundle.serve()"]
end
AQ ==>|worker gets workload| LOOP
LOOP -->|supervise_task per task| CM
CM -->|queue not mapped| PYC
CM -->|queue mapped| LSC
PYC -.->|creates| SUP
LSC -.->|creates| PSUP
SUP ==>|puts outcome| RQ
PSUP ==>|puts outcome| RQ
WH -->|tracks/joins/terminates| LOOP
SUP -->|forks/starts + waits| TR
PSUP -->|launches runtime + waits| GO & JV & TS
classDef focus fill:#fff3bf,stroke:#e67700,stroke-width:3px
class SUP,PSUP focus
|
jason810496
left a comment
There was a problem hiding this comment.
the task runner sends it right before execute()
I think it's fine to handle that in this PR
Yes, both statements are correct.
What I imagined is 1) replace the Python side StartExecutionTimeout with StartExecutionTimeout while we still keep the same place to emit the message, where the bundle initialization already finished 2) for the Language SDK (_PopenActivitySubprocess), we should emit StartExecutionTimeout after the runtime starts, before StartupDetails
658b814 to
98a60f5
Compare
|
Thanks both! I've updated the PR:
Follow-ups, as discussed:
|
jason810496
left a comment
There was a problem hiding this comment.
Thanks for the update, LGTM once address the following comments.
There was a problem hiding this comment.
Thanks for the update.
Following up on the single-writer thread: 0589fa1
Since StartExecutionTimeout has no payload, the value has to be stored at startup anyway, so how about one writer per field: _on_child_started sets _execution_timeout_seconds, and _arm_execution_timeout() takes no argument and only writes _execution_timeout_enforce_at.
|
Thanks! I've updated the PR and its description. The description now has a "Known gaps" section. The main one: for a Python task, the runner's SIGALRM timer still reads To keep this PR small, I'll open a separate PR right after this one that makes the runner read |
jason810496
left a comment
There was a problem hiding this comment.
Nice, thanks for the update, LGTM now.
Andrushika
left a comment
There was a problem hiding this comment.
Thanks, LGTM!
Please keep working on the follow-up PRs if you would.
jason810496
left a comment
There was a problem hiding this comment.
Would you mind to rebase the PR when you have a moment? Thanks.
The Python task runner fails a task with a negative execution_timeout right away. Sent as-is, a negative value would instead move the supervisor's SIGTERM deadline to an arbitrary earlier point, and treating it as no timeout would let the task run forever. Clamping it to zero makes the supervisor treat the timeout as already elapsed, which is the closest the outside layer can get to the Python behavior.
8cbfc4f to
ef13d37
Compare
|
No problem, I've rebased it. The previous CI failure was a mypy error: after the rebase, ti_run assigned two different values to the same task variable. I've renamed mine to fix it. Fix with 287202e |
Backport successfully created: v3-4-testNote: As of Merging PRs targeted for Airflow 3.X In matter of doubt please ask in #release-management Slack channel.
|
…apache#74869) * Extract a helper that looks up the stub task * Add execution_timeout to TIRunContext * Add execution_timeout to the supervisor schema * Arm the supervisor execution timeout from the run context * Document that the supervisor timeout covers language SDK tasks * Treat a negative execution_timeout as elapsed for language SDK tasks The Python task runner fails a task with a negative execution_timeout right away. Sent as-is, a negative value would instead move the supervisor's SIGTERM deadline to an arbitrary earlier point, and treating it as no timeout would let the task run forever. Clamping it to zero makes the supervisor treat the timeout as already elapsed, which is the closest the outside layer can get to the Python behavior. * Send execution_timeout in the run context for all tasks * Start the execution timeout for language SDK tasks in the coordinator * Replace SetExecutionTimeout with StartExecutionTimeout * Look up the task once in ti_run * Fill the default execution_timeout of Lang-SDK tasks from the config * Arm the execution timeout only in _arm_execution_timeout * Store execution_timeout at startup and arm it in _on_child_started * Align docstrings with the execution timeout changes * Rename the task variable in ti_run to fix mypy (cherry picked from commit 597aff1) Co-authored-by: ChickenBenny <zxc123benny14159@gmail.com>
…apache#74869) * Extract a helper that looks up the stub task * Add execution_timeout to TIRunContext * Add execution_timeout to the supervisor schema * Arm the supervisor execution timeout from the run context * Document that the supervisor timeout covers language SDK tasks * Treat a negative execution_timeout as elapsed for language SDK tasks The Python task runner fails a task with a negative execution_timeout right away. Sent as-is, a negative value would instead move the supervisor's SIGTERM deadline to an arbitrary earlier point, and treating it as no timeout would let the task run forever. Clamping it to zero makes the supervisor treat the timeout as already elapsed, which is the closest the outside layer can get to the Python behavior. * Send execution_timeout in the run context for all tasks * Start the execution timeout for language SDK tasks in the coordinator * Replace SetExecutionTimeout with StartExecutionTimeout * Look up the task once in ti_run * Fill the default execution_timeout of Lang-SDK tasks from the config * Arm the execution timeout only in _arm_execution_timeout * Store execution_timeout at startup and arm it in _on_child_started * Align docstrings with the execution timeout changes * Rename the task variable in ti_run to fix mypy (cherry picked from commit 597aff1) Co-authored-by: ChickenBenny <zxc123benny14159@gmail.com>
…#74869) (#75376) * Extract a helper that looks up the stub task * Add execution_timeout to TIRunContext * Add execution_timeout to the supervisor schema * Arm the supervisor execution timeout from the run context * Document that the supervisor timeout covers language SDK tasks * Treat a negative execution_timeout as elapsed for language SDK tasks The Python task runner fails a task with a negative execution_timeout right away. Sent as-is, a negative value would instead move the supervisor's SIGTERM deadline to an arbitrary earlier point, and treating it as no timeout would let the task run forever. Clamping it to zero makes the supervisor treat the timeout as already elapsed, which is the closest the outside layer can get to the Python behavior. * Send execution_timeout in the run context for all tasks * Start the execution timeout for language SDK tasks in the coordinator * Replace SetExecutionTimeout with StartExecutionTimeout * Look up the task once in ti_run * Fill the default execution_timeout of Lang-SDK tasks from the config * Arm the execution timeout only in _arm_execution_timeout * Store execution_timeout at startup and arm it in _on_child_started * Align docstrings with the execution timeout changes * Rename the task variable in ti_run to fix mypy (cherry picked from commit 597aff1) Co-authored-by: ChickenBenny <zxc123benny14159@gmail.com>
Language SDK tasks never had their
execution_timeoutenforced, because only the Python task runner told the supervisor about it. Following the review, the timeout is now resolved once on the server and enforced by the supervisor for every task:The supervisor takes
execution_timeoutonly fromTIRunContext. The Execution API resolves it from the serialized Dag and sends it for all tasks, Python included. This also covers mapped (.expand()) language SDK tasks, which previously got no timeout. A zero timeout means no timeout and a negative one has already elapsed, as in the Python task runner.[core] default_task_execution_timeoutnow applies to language SDK tasks. A language SDK runtime cannot read the Airflow config, so the Dag processor fills the default into the serialized Dag'sclient_defaultswhen it stores the Dag, as it already does for the Dag-level defaults. A value the task sets wins over it.SetExecutionTimeoutis replaced withStartExecutionTimeout. The message no longer carries the seconds, and the Python task runner still sends it from the same place, right beforeexecute().SetExecutionTimeoutshipped in 3.4.0b1 and 3.4.0b2 but is not in a final release yet, so the rename needs no supervisor schema migration if it lands before 3.4.0.When the clock starts:
StartExecutionTimeout, so bundle loading and Dag parsing are not counted. Dag parsing already has[core] dagbag_import_timeout.StartupDetails. Starting the runtime process is not counted; it has its own startup timeout.Either way, a task that has not stopped
[core] killed_task_cleanup_timeseconds after the timeout elapses is sent SIGTERM, then SIGKILL.The
execution_timeoutfield is added at2026-10-30in both the Execution API and the supervisor schema, so this needsbackport-to-v3-4-testbefore 3.4.0.Known gaps until the follow-ups land:
2026-10-30withoutexecution_timeout, so a worker with this change running against a beta API server gets no supervisor timeout. The Python task runner's own timer still applies.task.execution_timeoutfrom the Dag file it parses, while the supervisor reads the serialized Dag. They can disagree when the bundle is not versioned and the Dag file changed after it was serialized, or when the Dag processor and the worker have different[core] default_task_execution_timeout, since each resolves that default when it imports the Dag file. If only the runner sees a timeout, the supervisor ignoresStartExecutionTimeout; if only the serialized Dag has one, the runner never sends it. Follow-up 2 removes this gap.Follow-ups (separate PRs):
execution_timeout+killed_task_cleanup_time.execution_timeoutfromTIRunContextinstead oftask.execution_timeout, so both timers use the serialized value. This is a small change that does not need to wait for 3. Worker-only settings of[core] default_task_execution_timeoutthen stop applying to Python tasks.[core] default_task_execution_timeout. The default only fills tasks that leaveexecution_timeoutout, and the Go and TypeScript task options have no way to write an explicitnull, so a task cannot ask for no timeout yet.