Skip to content

Commit 30c1fe8

Browse files
committed
Preserve custom subprocess coordinator compatibility
External language SDK coordinators can implement the documented command hook. Keeping that contract stable avoids unexpected-keyword failures when upgrading while allowing Dag-bundle-backed artifact discovery.
1 parent be55dcf commit 30c1fe8

9 files changed

Lines changed: 70 additions & 37 deletions

File tree

contributing-docs/30_new_language_sdk.rst

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,7 @@ the subprocess understands:
107107

108108
.. code-block:: python
109109
110-
def _build_execute_task_command(
111-
self, *, what: TaskInstanceDTO, roots: list[pathlib.Path]
112-
) -> tuple[list[str], str]: ...
110+
def _build_execute_task_command(self, *, what: TaskInstanceDTO) -> tuple[list[str], str]: ...
113111
114112
The method returns a ``(command, subprocess_schema_version)`` pair:
115113

@@ -119,10 +117,11 @@ The method returns a ``(command, subprocess_schema_version)`` pair:
119117
subprocess understands, used by the supervisor to negotiate message formats
120118
across SDK versions. See `Supervisor Schema`_ below.
121119

122-
``roots`` are the artifact directories the base class has already resolved from
123-
the coordinator's configured source — an explicit filesystem root, a named Dag
124-
bundle (``dag_bundle_name``), or the task's own bundle — so subclasses scan
125-
``roots`` rather than reading the configured root directly.
120+
Call ``self._get_scan_roots()`` to retrieve the artifact directories the base
121+
class has already resolved from the coordinator's configured source — an
122+
explicit filesystem root, a named Dag bundle (``dag_bundle_name``), or the
123+
task's own bundle. Subclasses should scan those roots rather than reading the
124+
configured root directly.
126125

127126
Supervisor Schema
128127
~~~~~~~~~~~~~~~~~

task-sdk/src/airflow/sdk/coordinators/_subprocess.py

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,7 @@ class SubprocessCoordinator(BaseCoordinator):
447447
# resolve roots without knowing the subclass field name.
448448
_configured_roots: list[pathlib.Path] = attrs.field(init=False, factory=list)
449449
_active_bundle_info: BundleInfo | None = attrs.field(init=False, default=None)
450+
_active_scan_roots: tuple[pathlib.Path, ...] | None = attrs.field(init=False, default=None)
450451

451452
@property
452453
def _explicit_artifact_roots(self) -> tuple[str, Sequence[pathlib.Path]]:
@@ -526,14 +527,18 @@ def _init_root_source(
526527
raise FileNotFoundError(f"Dag bundle {target.name!r} resolved to {path}, which does not exist.")
527528
return [path], bundle
528529

529-
def _build_execute_task_command(
530-
self, *, what: TaskInstance, roots: list[pathlib.Path]
531-
) -> tuple[list[str], str | None]:
530+
def _get_scan_roots(self) -> tuple[pathlib.Path, ...]:
531+
"""Return the artifact roots resolved for the active task."""
532+
if self._active_scan_roots is None:
533+
raise RuntimeError("_get_scan_roots requires an active task; call it during execute_task.")
534+
return self._active_scan_roots
535+
536+
def _build_execute_task_command(self, *, what: TaskInstance) -> tuple[list[str], str | None]:
532537
"""
533538
Build the subprocess command and resolve its supervisor wire-schema version for *what*.
534539
535-
*roots* are the directories to scan for artifacts, already resolved by
536-
:meth:`_init_root_source` from the coordinator's configured source.
540+
Subclasses can retrieve the directories to scan for artifacts with
541+
:meth:`_get_scan_roots`.
537542
Returns a ``(command, subprocess_schema_version)`` pair. *command* MUST
538543
NOT include the ``--comm`` / ``--logs`` flags — those are appended by
539544
:class:`_PopenActivitySubprocess` once the listening sockets have been
@@ -558,6 +563,15 @@ def _set_current_bundle(self, bundle_info: BundleInfo):
558563
finally:
559564
self._active_bundle_info = None
560565

566+
@contextlib.contextmanager
567+
def _set_scan_roots(self, roots: Sequence[pathlib.Path]):
568+
"""Expose *roots* to the command builder for the duration of the task."""
569+
self._active_scan_roots = tuple(roots)
570+
try:
571+
yield
572+
finally:
573+
self._active_scan_roots = None
574+
561575
def execute_task(
562576
self,
563577
*,
@@ -584,7 +598,8 @@ def execute_task(
584598
bundle_version=resolved_bundle.version,
585599
)
586600
)
587-
command, subprocess_schema_version = self._build_execute_task_command(what=what, roots=roots)
601+
stack.enter_context(self._set_scan_roots(roots))
602+
command, subprocess_schema_version = self._build_execute_task_command(what=what)
588603
process = _PopenActivitySubprocess.start(
589604
what=what,
590605
dag_rel_path=dag_rel_path,

task-sdk/src/airflow/sdk/coordinators/executable/coordinator.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -354,8 +354,7 @@ class ExecutableCoordinator(SubprocessCoordinator):
354354
def _explicit_artifact_roots(self) -> tuple[str, list[pathlib.Path]]:
355355
return "executables_root", self.executables_root
356356

357-
def _build_execute_task_command(
358-
self, *, what: TaskInstance, roots: list[pathlib.Path]
359-
) -> tuple[list[str], str | None]:
357+
def _build_execute_task_command(self, *, what: TaskInstance) -> tuple[list[str], str | None]:
358+
roots = self._get_scan_roots()
360359
bundle = _Bundle.find(roots, what.dag_id)
361360
return [str(bundle.path)], bundle.schema_version

task-sdk/src/airflow/sdk/coordinators/java/coordinator.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -209,11 +209,10 @@ class JavaCoordinator(SubprocessCoordinator):
209209
def _explicit_artifact_roots(self) -> tuple[str, list[pathlib.Path]]:
210210
return "jars_root", self.jars_root
211211

212-
def _build_execute_task_command(
213-
self, *, what: TaskInstance, roots: list[pathlib.Path]
214-
) -> tuple[list[str], str | None]:
212+
def _build_execute_task_command(self, *, what: TaskInstance) -> tuple[list[str], str | None]:
215213
# Without main_class, the first executable JAR in walk order wins; tracked at
216214
# https://github.com/apache/airflow/issues/71134
215+
roots = self._get_scan_roots()
217216
jar = _JarInfo.find(roots, self.main_class)
218217
command = [
219218
self.java_executable,

task-sdk/src/airflow/sdk/coordinators/node/coordinator.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,10 +161,9 @@ class NodeCoordinator(SubprocessCoordinator):
161161
def _explicit_artifact_roots(self) -> tuple[str, list[pathlib.Path]]:
162162
return "bundles_root", self.bundles_root
163163

164-
def _build_execute_task_command(
165-
self, *, what: TaskInstance, roots: list[pathlib.Path]
166-
) -> tuple[list[str], str | None]:
164+
def _build_execute_task_command(self, *, what: TaskInstance) -> tuple[list[str], str | None]:
167165
# Multi-bundle routing should be added here by passing `what.dag_id` and
168166
# `what.task_id` into bundle selection and matching against metadata["dags"].
167+
roots = self._get_scan_roots()
169168
bundle = _find_bundle(roots)
170169
return [self.node_executable, os.fspath(bundle.path)], bundle.schema_version

task-sdk/tests/task_sdk/coordinators/executable/test_coordinator.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -399,9 +399,10 @@ def test_configured_dag_bundle_name_accepted(self, mock_manager):
399399
def test_build_command_scans_passed_roots_in_colocated_mode(self, tmp_path):
400400
binary = _build_bundle(tmp_path / "my_bundle", dag_ids=["tutorial_dag"])
401401
coordinator = ExecutableCoordinator()
402-
command, schema_version = coordinator._build_execute_task_command(
403-
what=_make_ti(dag_id="tutorial_dag"), roots=[tmp_path]
404-
)
402+
with coordinator._set_scan_roots([tmp_path]):
403+
command, schema_version = coordinator._build_execute_task_command(
404+
what=_make_ti(dag_id="tutorial_dag")
405+
)
405406
assert command == [str(binary.resolve())]
406407
assert schema_version == "2026-06-16"
407408

@@ -412,7 +413,8 @@ def test_returns_resolved_executable_and_schema_version(self, tmp_path):
412413
ti = _make_ti(dag_id="tutorial_dag")
413414

414415
coordinator = ExecutableCoordinator(executables_root=[tmp_path])
415-
command, schema_version = coordinator._build_execute_task_command(what=ti, roots=[tmp_path])
416+
with coordinator._set_scan_roots([tmp_path]):
417+
command, schema_version = coordinator._build_execute_task_command(what=ti)
416418
assert command == [str(binary.resolve())]
417419
assert schema_version == "2026-06-16"
418420

@@ -423,16 +425,22 @@ def test_raises_when_bundle_omits_schema_version(self, tmp_path):
423425
ti = _make_ti(dag_id="tutorial_dag")
424426

425427
coordinator = ExecutableCoordinator(executables_root=[tmp_path])
426-
with pytest.raises(FileNotFoundError, match="matching bundles were rejected"):
427-
coordinator._build_execute_task_command(what=ti, roots=[tmp_path])
428+
with (
429+
coordinator._set_scan_roots([tmp_path]),
430+
pytest.raises(FileNotFoundError, match="matching bundles were rejected"),
431+
):
432+
coordinator._build_execute_task_command(what=ti)
428433

429434
def test_raises_when_dag_id_not_found(self, tmp_path):
430435
_build_bundle(tmp_path / "my_bundle", dag_ids=["other_dag"])
431436
ti = _make_ti(dag_id="tutorial_dag")
432437

433438
coordinator = ExecutableCoordinator(executables_root=[tmp_path])
434-
with pytest.raises(FileNotFoundError, match="cannot find executable bundle"):
435-
coordinator._build_execute_task_command(what=ti, roots=[tmp_path])
439+
with (
440+
coordinator._set_scan_roots([tmp_path]),
441+
pytest.raises(FileNotFoundError, match="cannot find executable bundle"),
442+
):
443+
coordinator._build_execute_task_command(what=ti)
436444

437445

438446
@pytest.fixture

task-sdk/tests/task_sdk/coordinators/java/test_coordinator.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,8 @@ def test_unconfigured_dag_bundle_name_raises(self, mock_manager):
261261
def test_build_command_scans_passed_roots_in_colocated_mode(self, tmp_path):
262262
_make_jar(tmp_path / "app.jar", main_class="com.example.TaskRunner", schema_version="2026-06-16")
263263
coordinator = JavaCoordinator(main_class="com.example.TaskRunner")
264-
command, schema_version = coordinator._build_execute_task_command(what=_make_ti(), roots=[tmp_path])
264+
with coordinator._set_scan_roots([tmp_path]):
265+
command, schema_version = coordinator._build_execute_task_command(what=_make_ti())
265266
assert command[0] == "java"
266267
assert command[-1] == "com.example.TaskRunner"
267268
assert schema_version == "2026-06-16"

task-sdk/tests/task_sdk/coordinators/node/test_coordinator.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,8 @@ def test_unconfigured_dag_bundle_name_raises(self, mock_manager):
117117
def test_build_command_scans_passed_roots_in_colocated_mode(self, tmp_path):
118118
bundle = write_bundle(tmp_path)
119119
coordinator = NodeCoordinator()
120-
command, schema_version = coordinator._build_execute_task_command(what=_make_ti(), roots=[tmp_path])
120+
with coordinator._set_scan_roots([tmp_path]):
121+
command, schema_version = coordinator._build_execute_task_command(what=_make_ti())
121122
assert command == ["node", str(bundle)]
122123
assert schema_version == SCHEMA_VERSION
123124

@@ -240,7 +241,8 @@ def test_build_execute_task_command_returns_node_bundle_and_schema_version(self,
240241
bundles_root=tmp_path,
241242
)
242243

243-
command, schema_version = coordinator._build_execute_task_command(what=_make_ti(), roots=[tmp_path])
244+
with coordinator._set_scan_roots([tmp_path]):
245+
command, schema_version = coordinator._build_execute_task_command(what=_make_ti())
244246

245247
assert command == ["/opt/node/bin/node", str(bundle)]
246248
assert schema_version == SCHEMA_VERSION

task-sdk/tests/task_sdk/coordinators/test_subprocess.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -588,8 +588,8 @@ class _StubSubprocessCoordinator(SubprocessCoordinator):
588588
def _explicit_artifact_roots(self) -> tuple[str, list[pathlib.Path]]:
589589
return "explicit_roots", self.explicit_roots
590590

591-
def _build_execute_task_command(self, *, what, roots):
592-
self.recorded_roots.append(roots)
591+
def _build_execute_task_command(self, *, what):
592+
self.recorded_roots.append(list(self._get_scan_roots()))
593593
return list(self.command), self.schema_version
594594

595595

@@ -615,7 +615,7 @@ class _Plain(SubprocessCoordinator):
615615
pass
616616

617617
with pytest.raises(NotImplementedError):
618-
_Plain()._build_execute_task_command(what=_make_ti(), roots=[])
618+
_Plain()._build_execute_task_command(what=_make_ti())
619619

620620

621621
class TestSubprocessCoordinatorExecuteTask:
@@ -885,7 +885,7 @@ def test_subclass_without_overrides_defaults_to_task_bundle(self):
885885

886886
@attrs.define(kw_only=True)
887887
class _Bare(SubprocessCoordinator):
888-
def _build_execute_task_command(self, *, what, roots):
888+
def _build_execute_task_command(self, *, what):
889889
return [], None
890890

891891
assert _Bare()._artifact_source is _ArtifactSource.TASK_BUNDLE
@@ -1085,6 +1085,17 @@ def test_named_bundle_mode_locks_the_tree_it_scans(
10851085
mock_lock.assert_called_once_with(bundle_name="artifacts", bundle_version="sha-abc")
10861086

10871087

1088+
class TestGetScanRoots:
1089+
def test_returns_bound_roots_and_clears_them_on_exit(self, tmp_path):
1090+
coordinator = _StubSubprocessCoordinator(command=["x"])
1091+
1092+
with coordinator._set_scan_roots([tmp_path]):
1093+
assert coordinator._get_scan_roots() == (tmp_path,)
1094+
1095+
with pytest.raises(RuntimeError, match="requires an active task"):
1096+
coordinator._get_scan_roots()
1097+
1098+
10881099
class TestSetCurrentBundle:
10891100
def test_binds_for_the_block_and_clears_on_exit_allowing_reuse(self):
10901101
coordinator = _StubSubprocessCoordinator(command=["/bin/true"])

0 commit comments

Comments
 (0)