Skip to content

Report language SDK runtime launch failures in the task log - #75561

Merged
jason810496 merged 16 commits into
apache:mainfrom
ChickenBenny:fix-lang-sdk-launch-failure
Oct 27, 2026
Merged

jason810496 merged 16 commits into
apache:mainfrom
ChickenBenny:fix-lang-sdk-launch-failure

Conversation

@ChickenBenny

@ChickenBenny ChickenBenny commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

closes: #75557

supervise_task fails a task whose runtime cannot launch, with the reason in its task log, only when the coordinator raises TaskLaunchError. SubprocessCoordinator raised it only for a native Dag file that is missing or cannot run. Any other failure before the runtime connected reached the executor before the task instance was started: a @task.stub artifact that cannot be found, a runtime that cannot be executed (java not installed, wrong architecture), an unsupported supervisor schema version, or a runtime that exits or times out before connecting. The try then had no task log, the task was failed through the scheduler's "state mismatch" path, and the runtime's output, already read by _accept_connections, was dropped. A JVM that printed Error: Could not find or load main class ... left that line in no log at all.

Rather than catching each of these where it is raised, SubprocessCoordinator now raises any failure before the task instance is started as TaskLaunchError, through one context manager, _raise_as_launch_error, around two blocks:

  • in execute_task, resolving the Dag bundle and building the command;
  • in _PopenActivitySubprocess.start, starting the runtime until it connects.

_on_child_started, which starts the task instance, and wait() stay outside, because the supervisor starts the task instance itself when it handles a TaskLaunchError. A TaskLaunchError raised inside, such as a routing mistake that is not retryable, is kept as it is. The blocks are only indented, so the diff reads best with whitespace ignored.

The runtime's output does not go into the error. _accept_connections writes what the runtime printed before it failed to connect to the task log, one event per line under task.stdout and task.stderr, through the same forwarder a connected runtime's output goes through, so each line keeps its own level and timestamp. It keeps the last 16 KiB of each stream, enough for a JVM or Node stack trace, and says so when it drops earlier output. Output still buffered in the sockets is read without blocking, because a runtime can exit before its last lines are read, and a runtime that keeps printing while it never connects is read no further than a fixed budget. Reusing that forwarder is why WatchedSubprocess._target_loggers is split out of _get_target_loggers: the loggers to forward to have to be resolved before the subprocess object exists.

Most of the new failures are retryable, like a native Dag file that is missing or cannot run: an artifact can appear once a bundle finishes syncing, and a runtime that timed out on a busy worker can start on the next try. A supervisor schema version this worker's Task SDK does not know is not retryable, since retrying cannot teach it one. Catching Exception also reports a coordinator bug as a launch failure, without a retry; its traceback is then in the task log rather than in no log.

TaskLaunchError said a coordinator raises it before it starts the runtime, which no longer holds: a runtime that exits or times out before connecting is already past Popen. It and BaseCoordinator.execute_task now state the contract custom coordinators implement against as the task instance not having been started, which is what the supervisor does itself when it handles the error. A coordinator that knows whether retrying helps should say so rather than leave it to the exception type:

def _build_execute_task_command(self, *, what: TaskInstance) -> tuple[list[str], str | None]:
    if (jar := self._find_jar(what.dag_id)) is None:
        raise TaskLaunchError(f"no JAR for {what.dag_id!r}", retryable=False)
    return ["java", "-jar", str(jar)], _SCHEMA_VERSION

@boring-cyborg boring-cyborg Bot added area:coordinator Coordinator: The interface to spawn Lang-SDK subprocesses area:task-sdk labels Oct 24, 2026
Comment thread task-sdk/src/airflow/sdk/coordinators/_subprocess.py Outdated

@jason810496 jason810496 left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just made some changes myself to get this PR in 3.4.0 RC.

@jason810496 jason810496 added this to the Airflow 3.4.0 milestone Oct 27, 2026
@jason810496 jason810496 added backport-to-v3-4-test Backport to v3-4-test and removed need workflow approval labels Oct 27, 2026
Comment thread task-sdk/src/airflow/sdk/coordinators/_subprocess.py Outdated
Comment thread task-sdk/src/airflow/sdk/coordinators/_subprocess.py
Comment thread task-sdk/src/airflow/sdk/coordinators/_subprocess.py
Comment thread task-sdk/src/airflow/sdk/coordinators/_subprocess.py Outdated
@uranusjr

Copy link
Copy Markdown
Member

Also in oordinator.py, TaskLaunchError says "before it starts the runtime", and execute_task says "Raise it before the runtime starts". This PR raises it after Popen (the runtime exited or timed out before connecting).

The real invariant, which _raise_as_launch_error's own docstring states, is "before the task instance is started". Update both docstrings. Custom coordinators implement against this contract.

@jason810496
jason810496 marked this pull request as draft October 27, 2026 06:10
@vatsrahul1001
vatsrahul1001 marked this pull request as ready for review October 27, 2026 06:26
@jason810496
jason810496 requested a review from ashb as a code owner October 27, 2026 06:56

@jason810496 jason810496 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the review.

Comment thread task-sdk/src/airflow/sdk/coordinators/_subprocess.py Outdated
Comment thread task-sdk/src/airflow/sdk/coordinators/_subprocess.py
Comment thread task-sdk/src/airflow/sdk/coordinators/_subprocess.py
Comment thread task-sdk/src/airflow/sdk/coordinators/_subprocess.py Outdated
@jason810496
jason810496 requested a review from uranusjr October 27, 2026 07:20
Comment thread task-sdk/src/airflow/sdk/coordinators/_subprocess.py Outdated
ChickenBenny and others added 6 commits October 27, 2026 08:07
The lock and the scan roots are entered inside the launch-error guard but
released only after wait() returns, which is the whole reason the stack is
there rather than a plain with.

Co-Authored-By: Claude <noreply@anthropic.com>
A worker whose Task SDK does not know the schema version an artifact speaks
cannot learn it by trying again, so the task should not burn its retries on
it. The check also moves out of the native Dag branch and now runs once for
every task, so an artifact found by scanning the bundle is rejected before
the runtime starts rather than by the attrs validator inside start().

Co-Authored-By: Claude <noreply@anthropic.com>
jason810496 and others added 9 commits October 27, 2026 08:08
The output a runtime wrote before it failed to connect went into the error
message, so the task log rendered it as one event whose continuation lines
carry no timestamp or level. It now goes to the log the way a connected
runtime's output does, one event per line under task.stdout and task.stderr,
and the message stays on one line.

The kept tail grows to 16 KiB, since a JVM or Node stack trace does not fit
in 4 KiB, and says so when it drops earlier output. Draining a runtime that
keeps printing while it never connects is capped, so it cannot hold up the
failure.

Co-Authored-By: Claude <noreply@anthropic.com>
The test matched the path with a wildcard, which hid whether the reason the
OS gave survives the wrap. It now asserts the whole message, and covers a
runtime that exists but cannot be executed as well as one that is missing.

_connect_without_output moves above the class that uses it, where every
other helper in the file sits.

Co-Authored-By: Claude <noreply@anthropic.com>
The pipes and the accepted connections were untracked as the argument to
_register_pipe_readers, so they were disowned before it ran. A failure in it,
or in _on_child_started after it, left four sockets that nothing closed: the
tracker no longer owned them and the half-built subprocess was discarded.

They now stay with the tracker until the task instance has started, which is
the point 'self' takes them over and closes them in wait().

Co-Authored-By: Claude <noreply@anthropic.com>
_report_startup_output decoded and logged the lines itself, which repeated
forward_to_log with small differences. It now feeds forward_to_log instead,
so a failed launch and a connected runtime split lines and decode them the
same way.

The loggers to forward to come from WatchedSubprocess._target_loggers, split
out of _get_target_loggers so both callers share it: 'self' does not exist
yet when the runtime fails to connect, and the module logger the tuple ends
with belongs to supervisor.py.

Co-Authored-By: Claude <noreply@anthropic.com>
TaskLaunchError said a coordinator raises it "before it starts the runtime",
but a runtime that exits or times out before connecting has already been
spawned. The invariant custom coordinators implement against is that the
task instance has not been started, which is what the supervisor does itself
when it handles the error.

The command builders now also say to raise TaskLaunchError with an explicit
retryable when the implementation knows the answer, rather than leaving it
to be guessed from the exception type.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
"the log keeps output for a try that never ran" reads as if the output shows
up against the next try. It does not: the log file is rendered per try, so
the lines stay in the file of the try whose launch failed. What happens is
narrower, and worth naming: the supervisor stops without reporting a state,
so the file is never uploaded and is left on the worker.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The process group test landed on main while this branch was open, against
the RuntimeError _accept_connections raised there. This branch raises
TaskLaunchError instead, which is not a RuntimeError.

Co-Authored-By: Claude <noreply@anthropic.com>
@jason810496
jason810496 force-pushed the fix-lang-sdk-launch-failure branch from 278ac48 to 180d7b7 Compare October 27, 2026 08:11
@jason810496
jason810496 enabled auto-merge (squash) October 27, 2026 08:13
Comment thread task-sdk/src/airflow/sdk/coordinators/_subprocess.py Outdated
Comment thread task-sdk/src/airflow/sdk/execution_time/supervisor.py
_get_target_loggers is a hook DagFileProcessorProcess overrides, so a helper
called _target_loggers reads like the thing to call. The startup path has to
call it directly, since there is no instance yet, and would then miss an
override. _default_target_loggers says which one is which.

start() also took subprocess_logs_to_stdout out of **kwargs to resolve those
loggers, which hid a value every caller already passes. It is an argument
now.

Co-Authored-By: Claude <noreply@anthropic.com>
@jason810496
jason810496 merged commit 21a8a98 into apache:main Oct 27, 2026
97 checks passed
@github-actions

Copy link
Copy Markdown
Contributor

Backport successfully created: v3-4-test

Note: As of Merging PRs targeted for Airflow 3.X
the committer who merges the PR is responsible for backporting the PRs that are bug fixes (generally speaking) to the maintenance branches.

In matter of doubt please ask in #release-management Slack channel.

Status Branch Result
✅ v3-4-test PR Link

vatsrahul1001 pushed a commit that referenced this pull request Oct 27, 2026
…og (#75561) (#75741)

* Add tests for language SDK tasks that cannot launch

* Report language SDK runtime launch failures in the task log

* Resolve artifact bundles the same way as native Dag bundles

* Fail coordinator bugs before launch without a retry

* Say why execute_task uses an ExitStack

The lock and the scan roots are entered inside the launch-error guard but
released only after wait() returns, which is the whole reason the stack is
there rather than a plain with.

* Fail an unsupported supervisor schema version without a retry

A worker whose Task SDK does not know the schema version an artifact speaks
cannot learn it by trying again, so the task should not burn its retries on
it. The check also moves out of the native Dag branch and now runs once for
every task, so an artifact found by scanning the bundle is rejected before
the runtime starts rather than by the attrs validator inside start().

* Write a failed launch's runtime output to the task log

The output a runtime wrote before it failed to connect went into the error
message, so the task log rendered it as one event whose continuation lines
carry no timestamp or level. It now goes to the log the way a connected
runtime's output does, one event per line under task.stdout and task.stderr,
and the message stays on one line.

The kept tail grows to 16 KiB, since a JVM or Node stack trace does not fit
in 4 KiB, and says so when it drops earlier output. Draining a runtime that
keeps printing while it never connects is capped, so it cannot hold up the
failure.

* Assert why a runtime that cannot be executed fails

The test matched the path with a wildcard, which hid whether the reason the
OS gave survives the wrap. It now asserts the whole message, and covers a
runtime that exists but cannot be executed as well as one that is missing.

_connect_without_output moves above the class that uses it, where every
other helper in the file sits.

* Keep the runtime's sockets tracked until the launch has finished

The pipes and the accepted connections were untracked as the argument to
_register_pipe_readers, so they were disowned before it ran. A failure in it,
or in _on_child_started after it, left four sockets that nothing closed: the
tracker no longer owned them and the half-built subprocess was discarded.

They now stay with the tracker until the task instance has started, which is
the point 'self' takes them over and closes them in wait().

* Forward a failed launch's output through the supervisor's forwarder

_report_startup_output decoded and logged the lines itself, which repeated
forward_to_log with small differences. It now feeds forward_to_log instead,
so a failed launch and a connected runtime split lines and decode them the
same way.

The loggers to forward to come from WatchedSubprocess._target_loggers, split
out of _get_target_loggers so both callers share it: 'self' does not exist
yet when the runtime fails to connect, and the module logger the tuple ends
with belongs to supervisor.py.

* State the launch-error contract in terms of the task instance

TaskLaunchError said a coordinator raises it "before it starts the runtime",
but a runtime that exits or times out before connecting has already been
spawned. The invariant custom coordinators implement against is that the
task instance has not been started, which is what the supervisor does itself
when it handles the error.

The command builders now also say to raise TaskLaunchError with an explicit
retryable when the implementation knows the answer, rather than leaving it
to be guessed from the exception type.

* Drop the comment on execute_task's ExitStack

* Say what a cleared task does to a failed launch's output

"the log keeps output for a try that never ran" reads as if the output shows
up against the next try. It does not: the log file is rendered per try, so
the lines stay in the file of the try whose launch failed. What happens is
narrower, and worth naming: the supervisor stops without reporting a state,
so the file is never uploaded and is left on the worker.

* Keep a Dag file command's launch error and its retryability

* Expect a launch error when a runtime dies before connecting

The process group test landed on main while this branch was open, against
the RuntimeError _accept_connections raised there. This branch raises
TaskLaunchError instead, which is not a RuntimeError.

---------
(cherry picked from commit 21a8a98)

Co-authored-by: ChickenBenny <zxc123benny14159@gmail.com>
Co-authored-by: ZHE YOU LIU <zhu424.dev@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:coordinator Coordinator: The interface to spawn Lang-SDK subprocesses area:task-sdk backport-to-v3-4-test Backport to v3-4-test need workflow approval

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Language SDK task gets no task log when its runtime cannot launch

4 participants