Skip to content

Commit 0080efb

Browse files
committed
Start a coordinator's runtime from a fresh interpreter on macOS
A bare fork is unsafe on macOS, so the Dag processor already starts a fresh interpreter there. The Lang-SDK parse now does the same: it spawns an interpreter in its own process group, with the standard streams and the status socket mapped onto fds 0 to 3, which finds the coordinator whose Dag importer claims the file and execs its runtime.
1 parent aaff5b1 commit 0080efb

2 files changed

Lines changed: 155 additions & 6 deletions

File tree

‎airflow-core/src/airflow/dag_processing/processor.py‎

Lines changed: 97 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import os
2626
import selectors
2727
import signal
28+
import sys
2829
import time
2930
import traceback
3031
from collections.abc import Callable, Sequence
@@ -48,6 +49,7 @@
4849
from airflow.configuration import conf
4950
from airflow.dag_processing.bundles.base import BundleVersionLock
5051
from airflow.dag_processing.dagbag import BundleDagBag, DagBag
52+
from airflow.dag_processing.importer_routing import get_claiming_coordinator
5153
from airflow.models.dag import DagModel
5254
from airflow.sdk.coordinators._materialize import materialize_dag
5355
from airflow.sdk.coordinators._subprocess import _is_connection_from_pid, _ResourceTracker, _start_server
@@ -779,11 +781,46 @@ def report_schema_version(schema_version: str | None) -> None:
779781
report_schema_version=report_schema_version,
780782
)
781783
except BaseException as e:
782-
with contextlib.suppress(BaseException):
783-
status.sendall(json.dumps({"error": f"{type(e).__name__}: {e}"}).encode() + b"\n")
784+
_report_start_failure(status, e)
784785
os._exit(127)
785786

786787

788+
def _report_start_failure(status: socket, error: BaseException) -> None:
789+
with contextlib.suppress(BaseException):
790+
status.sendall(json.dumps({"error": f"{type(error).__name__}: {error}"}).encode() + b"\n")
791+
792+
793+
_LANG_SDK_EXEC_BOOTSTRAP = "from airflow.dag_processing.processor import _exec_lang_sdk_runtime_main\n_exec_lang_sdk_runtime_main()\n"
794+
795+
796+
def _exec_lang_sdk_runtime_main() -> NoReturn:
797+
"""
798+
Run in the fresh interpreter that replaces a forked Dag-parse child where fork is unsafe.
799+
800+
The interpreter cannot inherit the coordinator, so it finds the coordinator again from the
801+
configuration: the one whose Dag importer claims the file.
802+
"""
803+
status = socket(fileno=3)
804+
status.set_inheritable(False)
805+
args = json.loads(sys.argv[1])
806+
(comm_host, comm_port), (logs_host, logs_port) = args["comm_address"], args["logs_address"]
807+
try:
808+
coordinator = get_claiming_coordinator(args["path"], args["bundle_name"])
809+
if coordinator is None:
810+
raise RuntimeError(f"No coordinator's Dag importer claims {args['path']}")
811+
except BaseException as e:
812+
_report_start_failure(status, e)
813+
os._exit(127)
814+
_exec_lang_sdk_runtime(
815+
coordinator,
816+
path=Path(args["path"]),
817+
bundle_path=Path(args["bundle_path"]),
818+
comm_address=(comm_host, comm_port),
819+
logs_address=(logs_host, logs_port),
820+
status=status,
821+
)
822+
823+
787824
def _check_task_graph_is_acyclic(data: dict[str, Any]) -> None:
788825
"""
789826
Raise ``ValueError`` when the serialized Dag's task graph has a cycle.
@@ -868,14 +905,26 @@ def start( # type: ignore[override]
868905
child_ends = (stdout_w, stderr_w, status_w)
869906
parent_ends = (comm_listener, logs_listener, stdout_r, stderr_r, status_r)
870907

871-
pid = os.fork()
872-
if pid == 0:
908+
comm_address = comm_listener.getsockname()[:2]
909+
logs_address = logs_listener.getsockname()[:2]
910+
if supervisor._should_use_exec():
911+
pid = cls._spawn_child(
912+
path=Path(path),
913+
bundle_path=bundle_path,
914+
bundle_name=bundle_name,
915+
comm_address=comm_address,
916+
logs_address=logs_address,
917+
stdout=stdout_w,
918+
stderr=stderr_w,
919+
status=status_w,
920+
)
921+
elif (pid := os.fork()) == 0:
873922
cls._run_child(
874923
coordinator,
875924
path=Path(path),
876925
bundle_path=bundle_path,
877-
comm_address=comm_listener.getsockname()[:2],
878-
logs_address=logs_listener.getsockname()[:2],
926+
comm_address=comm_address,
927+
logs_address=logs_address,
879928
parent_ends=parent_ends,
880929
stdout=stdout_w,
881930
stderr=stderr_w,
@@ -961,6 +1010,48 @@ def run(
9611010
proc.close()
9621011
return cast("DagFileParsingResult", proc.parsing_result)
9631012

1013+
@staticmethod
1014+
def _spawn_child(
1015+
*,
1016+
path: Path,
1017+
bundle_path: Path,
1018+
bundle_name: str,
1019+
comm_address: tuple[str, int],
1020+
logs_address: tuple[str, int],
1021+
stdout: socket,
1022+
stderr: socket,
1023+
status: socket,
1024+
) -> int:
1025+
"""
1026+
Start a fresh interpreter that execs the runtime, where a bare fork is unsafe (macOS).
1027+
1028+
Its standard streams and the status socket are mapped onto fds 0 to 3, and it leads its own
1029+
process group, as part of the spawn.
1030+
"""
1031+
args = {
1032+
"path": os.fspath(path),
1033+
"bundle_path": os.fspath(bundle_path),
1034+
"bundle_name": bundle_name,
1035+
"comm_address": list(comm_address),
1036+
"logs_address": list(logs_address),
1037+
}
1038+
devnull = os.open(os.devnull, os.O_RDONLY)
1039+
try:
1040+
return os.posix_spawn(
1041+
sys.executable,
1042+
[sys.executable, "-c", _LANG_SDK_EXEC_BOOTSTRAP, json.dumps(args)],
1043+
os.environ,
1044+
file_actions=[
1045+
(os.POSIX_SPAWN_DUP2, devnull, 0),
1046+
(os.POSIX_SPAWN_DUP2, stdout.fileno(), 1),
1047+
(os.POSIX_SPAWN_DUP2, stderr.fileno(), 2),
1048+
(os.POSIX_SPAWN_DUP2, status.fileno(), 3),
1049+
],
1050+
setpgroup=0,
1051+
)
1052+
finally:
1053+
os.close(devnull)
1054+
9641055
@staticmethod
9651056
def _run_child(
9661057
coordinator: SubprocessCoordinator,

‎airflow-core/tests/unit/dag_processing/test_processor.py‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import inspect
2121
import json
2222
import logging
23+
import os
2324
import pathlib
2425
import selectors
2526
import signal
@@ -3042,3 +3043,60 @@ def test_check_task_graph_rejects_a_cycle():
30423043
)
30433044
def test_check_task_graph_accepts_an_acyclic_graph(edges):
30443045
_check_task_graph_is_acyclic(_build_graph_payload(edges))
3046+
3047+
3048+
class TestLangSDKDagFileProcessorProcessSpawn:
3049+
"""Where a bare fork is unsafe, a fresh interpreter finds the coordinator again and execs the runtime."""
3050+
3051+
@pytest.fixture(autouse=True)
3052+
def _spawn_a_fresh_interpreter(self, monkeypatch):
3053+
monkeypatch.setattr(supervisor, "_should_use_exec", lambda: True)
3054+
monkeypatch.setenv("PYTHONPATH", os.pathsep.join(sys.path))
3055+
3056+
@staticmethod
3057+
def _run(tmp_path, *, timeout: float = 120, **spec) -> DagFileParsingResult:
3058+
return LangSDKDagFileProcessorProcess.run(
3059+
coordinator=FakeCoordinator(),
3060+
path=write_native_file(tmp_path / "dag.native", **spec),
3061+
bundle_path=tmp_path,
3062+
bundle_name="testing",
3063+
dag_file_rel_path="dag.native",
3064+
timeout=timeout,
3065+
logger=structlog.get_logger(),
3066+
)
3067+
3068+
@patch("airflow.dag_processing.processor.os.fork", autospec=True)
3069+
def test_parses_through_the_coordinator_found_again(self, mock_fork, monkeypatch, tmp_path):
3070+
classpath = f"{FakeCoordinator.__module__}.FakeCoordinator"
3071+
monkeypatch.setenv("AIRFLOW__SDK__COORDINATORS", json.dumps({"fake": {"classpath": classpath}}))
3072+
3073+
result = self._run(tmp_path, dags=["native_dag"])
3074+
3075+
assert [dag.dag_id for dag in result.serialized_dags] == ["native_dag"]
3076+
mock_fork.assert_not_called()
3077+
3078+
def test_the_runtime_leads_its_own_process_group(self, monkeypatch, tmp_path):
3079+
classpath = f"{FakeCoordinator.__module__}.FakeCoordinator"
3080+
monkeypatch.setenv("AIRFLOW__SDK__COORDINATORS", json.dumps({"fake": {"classpath": classpath}}))
3081+
grandchild_pid_file = tmp_path / "grandchild.pid"
3082+
3083+
result = self._run(
3084+
tmp_path,
3085+
dags=["native_dag"],
3086+
grandchild_holds=120,
3087+
grandchild_pid_file=str(grandchild_pid_file),
3088+
timeout=30,
3089+
)
3090+
3091+
assert [dag.dag_id for dag in result.serialized_dags] == ["native_dag"]
3092+
_assert_process_ends(int(grandchild_pid_file.read_text()))
3093+
3094+
def test_reports_a_file_no_coordinator_claims(self, monkeypatch, tmp_path):
3095+
monkeypatch.delenv("AIRFLOW__SDK__COORDINATORS", raising=False)
3096+
3097+
result = self._run(tmp_path, dags=["native_dag"])
3098+
3099+
assert result.import_errors == {
3100+
"dag.native": "Cannot start the Lang-SDK runtime: "
3101+
f"RuntimeError: No coordinator's Dag importer claims {tmp_path / 'dag.native'}"
3102+
}

0 commit comments

Comments
 (0)