|
25 | 25 | import os |
26 | 26 | import selectors |
27 | 27 | import signal |
| 28 | +import sys |
28 | 29 | import time |
29 | 30 | import traceback |
30 | 31 | from collections.abc import Callable, Sequence |
|
48 | 49 | from airflow.configuration import conf |
49 | 50 | from airflow.dag_processing.bundles.base import BundleVersionLock |
50 | 51 | from airflow.dag_processing.dagbag import BundleDagBag, DagBag |
| 52 | +from airflow.dag_processing.importer_routing import get_claiming_coordinator |
51 | 53 | from airflow.models.dag import DagModel |
52 | 54 | from airflow.sdk.coordinators._materialize import materialize_dag |
53 | 55 | 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: |
779 | 781 | report_schema_version=report_schema_version, |
780 | 782 | ) |
781 | 783 | 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) |
784 | 785 | os._exit(127) |
785 | 786 |
|
786 | 787 |
|
| 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 | + |
787 | 824 | def _check_task_graph_is_acyclic(data: dict[str, Any]) -> None: |
788 | 825 | """ |
789 | 826 | Raise ``ValueError`` when the serialized Dag's task graph has a cycle. |
@@ -868,14 +905,26 @@ def start( # type: ignore[override] |
868 | 905 | child_ends = (stdout_w, stderr_w, status_w) |
869 | 906 | parent_ends = (comm_listener, logs_listener, stdout_r, stderr_r, status_r) |
870 | 907 |
|
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: |
873 | 922 | cls._run_child( |
874 | 923 | coordinator, |
875 | 924 | path=Path(path), |
876 | 925 | 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, |
879 | 928 | parent_ends=parent_ends, |
880 | 929 | stdout=stdout_w, |
881 | 930 | stderr=stderr_w, |
@@ -961,6 +1010,48 @@ def run( |
961 | 1010 | proc.close() |
962 | 1011 | return cast("DagFileParsingResult", proc.parsing_result) |
963 | 1012 |
|
| 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 | + |
964 | 1055 | @staticmethod |
965 | 1056 | def _run_child( |
966 | 1057 | coordinator: SubprocessCoordinator, |
|
0 commit comments