Skip to content

Commit 0aec44e

Browse files
committed
Reject concurrent subprocess coordinator execution consistently
Bundle resolution does not need mutable instance state, while overwriting scan roots can make a command build against another task's artifacts.
1 parent ce617c8 commit 0aec44e

2 files changed

Lines changed: 25 additions & 65 deletions

File tree

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

Lines changed: 5 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -446,7 +446,6 @@ class SubprocessCoordinator(BaseCoordinator):
446446
# The subclass's explicit root, recorded at construction so the base can
447447
# resolve roots without knowing the subclass field name.
448448
_configured_roots: list[pathlib.Path] = attrs.field(init=False, factory=list)
449-
_active_bundle_info: BundleInfo | None = attrs.field(init=False, default=None)
450449
_active_scan_roots: tuple[pathlib.Path, ...] | None = attrs.field(init=False, default=None)
451450

452451
@property
@@ -500,7 +499,7 @@ def _classify_artifact_source(self) -> None:
500499
)
501500

502501
def _init_root_source(
503-
self, logger: FilteringBoundLogger
502+
self, bundle_info: BundleInfo, logger: FilteringBoundLogger
504503
) -> tuple[list[pathlib.Path], BaseDagBundle | None]:
505504
"""
506505
Resolve the directories to scan for artifacts, dispatched on the classified mode.
@@ -517,9 +516,7 @@ def _init_root_source(
517516
# NAMED_BUNDLE implies dag_bundle_name is set.
518517
target = BundleInfo(name=cast("str", self.dag_bundle_name))
519518
else:
520-
if self._active_bundle_info is None:
521-
raise RuntimeError("_init_root_source requires an active task; call it during execute_task.")
522-
target = self._active_bundle_info
519+
target = bundle_info
523520

524521
bundle = _initialize_pinned_bundle(target, logger)
525522
path = bundle.path
@@ -547,25 +544,11 @@ def _build_execute_task_command(self, *, what: TaskInstance) -> tuple[list[str],
547544
"""
548545
raise NotImplementedError
549546

550-
@contextlib.contextmanager
551-
def _set_current_bundle(self, bundle_info: BundleInfo):
552-
"""
553-
Bind *bundle_info* as the active task for the duration of the block, clearing it on exit.
554-
555-
Rejects a second concurrent bind: this coordinator runs one blocking task
556-
per process and is not re-entrant.
557-
"""
558-
if self._active_bundle_info is not None:
559-
raise RuntimeError("SubprocessCoordinator.execute_task is not re-entrant.")
560-
self._active_bundle_info = bundle_info
561-
try:
562-
yield
563-
finally:
564-
self._active_bundle_info = None
565-
566547
@contextlib.contextmanager
567548
def _set_scan_roots(self, roots: Sequence[pathlib.Path]):
568549
"""Expose *roots* to the command builder for the duration of the task."""
550+
if self._active_scan_roots is not None:
551+
raise RuntimeError("SubprocessCoordinator.execute_task is not re-entrant.")
569552
self._active_scan_roots = tuple(roots)
570553
try:
571554
yield
@@ -586,8 +569,7 @@ def execute_task(
586569
) -> BaseCoordinator.ExecutionResult:
587570
task_logger = logger or log
588571
with contextlib.ExitStack() as stack:
589-
stack.enter_context(self._set_current_bundle(bundle_info))
590-
roots, resolved_bundle = self._init_root_source(task_logger)
572+
roots, resolved_bundle = self._init_root_source(bundle_info, task_logger)
591573
if resolved_bundle is not None:
592574
# Hold the version lock across start()/wait() so bundle cleanup
593575
# cannot rmtree a version this task is still reading from,

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

Lines changed: 20 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -918,29 +918,23 @@ class TestInitRootSource:
918918
def test_returns_configured_root_and_no_bundle_in_explicit_mode(self):
919919
configured = [pathlib.Path("/a"), pathlib.Path("/b")]
920920
coordinator = _StubSubprocessCoordinator(command=["x"], explicit_roots=configured)
921-
roots, bundle = coordinator._init_root_source(log)
921+
roots, bundle = coordinator._init_root_source(MagicMock(), log)
922922
assert roots == configured
923923
assert bundle is None
924924

925925
@patch("airflow.sdk.coordinators._subprocess.initialize_ti_bundle")
926-
def test_task_bundle_mode_uses_active_task_bundle(self, mock_initialize, tmp_path):
926+
def test_task_bundle_mode_uses_passed_task_bundle(self, mock_initialize, tmp_path):
927927
resolved = MagicMock(path=tmp_path, version="v3")
928928
mock_initialize.return_value = resolved
929929
coordinator = _StubSubprocessCoordinator(command=["x"], explicit_roots=[])
930930
bundle_info = BundleInfo(name="dags", version="v3", version_data={"k": "v"})
931-
coordinator._active_bundle_info = bundle_info
932931

933-
roots, bundle = coordinator._init_root_source(log)
932+
roots, bundle = coordinator._init_root_source(bundle_info, log)
934933

935934
assert roots == [tmp_path]
936935
assert bundle is resolved
937936
mock_initialize.assert_called_once_with(bundle_info)
938937

939-
def test_task_bundle_mode_without_active_task_raises(self):
940-
coordinator = _StubSubprocessCoordinator(command=["x"], explicit_roots=[])
941-
with pytest.raises(RuntimeError, match="requires an active task"):
942-
coordinator._init_root_source(log)
943-
944938
@patch("airflow.sdk.coordinators._subprocess.initialize_ti_bundle")
945939
def test_version_less_bundle_is_rematerialized_at_its_current_version(self, mock_initialize, tmp_path):
946940
"""A bundle resolved without a version is re-resolved at the version current now.
@@ -962,7 +956,7 @@ def test_version_less_bundle_is_rematerialized_at_its_current_version(self, mock
962956
coordinator.dag_bundle_name = "artifacts"
963957
coordinator._artifact_source = _ArtifactSource.NAMED_BUNDLE
964958

965-
roots, bundle = coordinator._init_root_source(log)
959+
roots, bundle = coordinator._init_root_source(MagicMock(), log)
966960

967961
assert roots == [pinned_tree]
968962
assert bundle is pinned
@@ -981,7 +975,7 @@ def test_unversioned_bundle_keeps_its_single_path(self, mock_initialize, tmp_pat
981975
coordinator.dag_bundle_name = "artifacts"
982976
coordinator._artifact_source = _ArtifactSource.NAMED_BUNDLE
983977

984-
roots, bundle = coordinator._init_root_source(log)
978+
roots, bundle = coordinator._init_root_source(MagicMock(), log)
985979

986980
assert roots == [tmp_path]
987981
assert bundle is resolved
@@ -996,11 +990,11 @@ def test_missing_resolved_path_raises(self, mock_initialize, tmp_path):
996990
coordinator._artifact_source = _ArtifactSource.NAMED_BUNDLE
997991

998992
with pytest.raises(FileNotFoundError, match="does not exist"):
999-
coordinator._init_root_source(log)
993+
coordinator._init_root_source(MagicMock(), log)
1000994

1001995

1002996
class TestExecuteTaskBundleWiring:
1003-
"""execute_task binds the task bundle, forwards resolved roots, and holds the version lock."""
997+
"""execute_task passes the task bundle, forwards resolved roots, and holds the version lock."""
1004998

1005999
@patch("airflow.sdk.coordinators._subprocess.BundleVersionLock")
10061000
@patch("airflow.sdk.coordinators._subprocess.initialize_ti_bundle")
@@ -1024,11 +1018,8 @@ def test_task_bundle_mode_binds_forwards_roots_and_locks(
10241018
subprocess_logs_to_stdout=False,
10251019
)
10261020

1027-
# Resolution runs against the bound task bundle.
10281021
mock_initialize.assert_called_once_with(bundle_info)
1029-
# The resolved root is forwarded to the subclass command builder.
10301022
assert coordinator.recorded_roots == [[tmp_path]]
1031-
# The version lock is held around the subprocess for the resolved bundle.
10321023
mock_lock.assert_called_once_with(bundle_name="dags", bundle_version="v9")
10331024
mock_lock.return_value.__enter__.assert_called_once()
10341025
mock_lock.return_value.__exit__.assert_called_once()
@@ -1095,34 +1086,21 @@ def test_returns_bound_roots_and_clears_them_on_exit(self, tmp_path):
10951086
with pytest.raises(RuntimeError, match="requires an active task"):
10961087
coordinator._get_scan_roots()
10971088

1098-
1099-
class TestSetCurrentBundle:
1100-
def test_binds_for_the_block_and_clears_on_exit_allowing_reuse(self):
1089+
def test_rejects_nested_reentry(self, tmp_path):
11011090
coordinator = _StubSubprocessCoordinator(command=["/bin/true"])
1102-
bundle = MagicMock()
1103-
with coordinator._set_current_bundle(bundle):
1104-
assert coordinator._active_bundle_info is bundle
1105-
assert coordinator._active_bundle_info is None
1106-
# A subsequent task on the same (reused) coordinator binds cleanly.
1107-
with coordinator._set_current_bundle(MagicMock()):
1108-
assert coordinator._active_bundle_info is not None
1109-
assert coordinator._active_bundle_info is None
1110-
1111-
def test_rejects_nested_reentry(self):
1112-
coordinator = _StubSubprocessCoordinator(command=["/bin/true"])
1113-
with coordinator._set_current_bundle(MagicMock()):
1091+
with coordinator._set_scan_roots([tmp_path]):
11141092
with pytest.raises(RuntimeError, match="not re-entrant"):
1115-
with coordinator._set_current_bundle(MagicMock()):
1093+
with coordinator._set_scan_roots([tmp_path]):
11161094
pass
11171095

1118-
def test_execute_task_is_not_reentrant(self, mock_client):
1096+
def test_execute_task_is_not_reentrant(self, mock_client, tmp_path):
11191097
coordinator = _StubSubprocessCoordinator(command=["/bin/true"])
1120-
coordinator._active_bundle_info = MagicMock() # simulate an in-flight task
1121-
with pytest.raises(RuntimeError, match="not re-entrant"):
1122-
coordinator.execute_task(
1123-
what=_make_ti(),
1124-
dag_rel_path="dag.py",
1125-
bundle_info=MagicMock(),
1126-
client=mock_client,
1127-
subprocess_logs_to_stdout=False,
1128-
)
1098+
with coordinator._set_scan_roots([tmp_path]):
1099+
with pytest.raises(RuntimeError, match="not re-entrant"):
1100+
coordinator.execute_task(
1101+
what=_make_ti(),
1102+
dag_rel_path="dag.py",
1103+
bundle_info=MagicMock(),
1104+
client=mock_client,
1105+
subprocess_logs_to_stdout=False,
1106+
)

0 commit comments

Comments
 (0)