1515# KIND, either express or implied. See the License for the
1616# specific language governing permissions and limitations
1717# under the License.
18- """Native executable coordinator for DAG file processing and task execution."""
18+ """Native executable coordinator that launches a binary subprocess for Dag file processing and task execution."""
1919
2020from __future__ import annotations
2121
2222import os
2323import pathlib
24- from typing import TYPE_CHECKING
24+ import selectors
25+ import socket
26+ import subprocess
27+ import time
28+ from typing import TYPE_CHECKING , cast
2529
2630import attrs
27- from structlog import get_logger
31+ import psutil
32+ import structlog
2833
2934from airflow .sdk .coordinators .executable .bundle_scanner import BundleScanner , read_source_code
3035from airflow .sdk .execution_time .coordinator import BaseCoordinator
36+ from airflow .sdk .execution_time .supervisor import ActivitySubprocess
3137
3238if TYPE_CHECKING :
33- from airflow .sdk .api .datamodels ._generated import BundleInfo , TaskInstance
34-
35- log = get_logger (__name__ )
39+ from structlog .typing import FilteringBoundLogger
40+ from typing_extensions import Self
41+
42+ from airflow .sdk .api .client import Client
43+ from airflow .sdk .api .datamodels ._generated import BundleInfo
44+ from airflow .sdk .execution_time .workloads .task import TaskInstanceDTO
45+
46+ log : FilteringBoundLogger = structlog .get_logger (logger_name = "coordinators.executable" )
47+
48+
49+ # TODO: extract the duplicated `_start_server`, `_accept_connections`, and the
50+ # ``ActivitySubprocess`` subclass below into a shared ``IPCActivitySubprocess``
51+ # base in ``airflow.sdk.execution_time.supervisor`` so JavaCoordinator and
52+ # ExecutableCoordinator (and future coordinator subclasses) can share the
53+ # same comm/logs server bootstrap and socket cleanup.
54+ def _start_server () -> socket .socket :
55+ server = socket .socket ()
56+ server .bind (("" , 0 ))
57+ server .setblocking (True )
58+ server .listen (1 ) # Just need to listen to the child process.
59+ return server
60+
61+
62+ def _accept_connections (
63+ servers : dict [str , socket .socket ],
64+ proc : subprocess .Popen ,
65+ * ,
66+ max_wait : float = 10.0 ,
67+ ) -> dict [str , socket .socket ]:
68+ """Block until the executable process connects to servers."""
69+ accepted : dict [str , socket .socket ] = {}
70+ with selectors .DefaultSelector () as sel :
71+ for key , soc in servers .items ():
72+ sel .register (soc , selectors .EVENT_READ , data = key )
73+ deadline = time .monotonic () + max_wait
74+ while len (accepted ) < len (servers ):
75+ remaining = deadline - time .monotonic ()
76+ if remaining <= 0 :
77+ raise TimeoutError ("process did not connect within timeout" )
78+ if proc .poll () is not None :
79+ raise RuntimeError (f"process exited with { proc .returncode } before connecting" )
80+ for event , _ in sel .select (timeout = min (remaining , 1.0 )):
81+ log .debug ("Accepting child process connection" , key = (key := event .data ))
82+ conn , _ = cast ("socket.socket" , event .fileobj ).accept ()
83+ sel .unregister (servers [key ])
84+ accepted [key ] = conn
85+ return accepted
3686
3787
3888def _optional_path (value : str | os .PathLike [str ] | None ) -> pathlib .Path | None :
@@ -41,6 +91,82 @@ def _optional_path(value: str | os.PathLike[str] | None) -> pathlib.Path | None:
4191 return pathlib .Path (value )
4292
4393
94+ @attrs .define (kw_only = True )
95+ class _ExecutableActivitySubprocess (ActivitySubprocess ):
96+ """Native executable task runner process."""
97+
98+ _comm_server : socket .socket
99+ _logs_server : socket .socket
100+ _child_process : subprocess .Popen
101+
102+ # Keep track of channels used to pipe subprocess stdout and stderr so we can
103+ # close them on exit. The "read" side is handled by _register_pipe_readers
104+ # callbacks so we don't need to worry about them.
105+ _stdout_w : socket .socket
106+ _stderr_w : socket .socket
107+
108+ @classmethod
109+ def start ( # type: ignore[override]
110+ cls ,
111+ * ,
112+ what : TaskInstanceDTO ,
113+ dag_rel_path : str | os .PathLike [str ],
114+ bundle_info ,
115+ logger : FilteringBoundLogger | None = None ,
116+ sentry_integration : str = "" ,
117+ executable_path : str ,
118+ ** kwargs ,
119+ ) -> Self :
120+ comm_server = _start_server ()
121+ logs_server = _start_server ()
122+
123+ stdout_r , stdout_w = socket .socketpair ()
124+ stderr_r , stderr_w = socket .socketpair ()
125+
126+ comm_host , comm_port = comm_server .getsockname ()
127+ logs_host , logs_port = logs_server .getsockname ()
128+
129+ proc = subprocess .Popen (
130+ [
131+ executable_path ,
132+ f"--comm={ comm_host } :{ comm_port } " ,
133+ f"--logs={ logs_host } :{ logs_port } " ,
134+ ],
135+ stdout = stdout_w .makefile ("wb" , buffering = 0 ).fileno (),
136+ stderr = stderr_w .makefile ("wb" , buffering = 0 ).fileno (),
137+ )
138+ log .info ("Starting subprocess" , pid = proc .pid , executable = executable_path )
139+ socks = _accept_connections ({"comm" : comm_server , "logs" : logs_server }, proc )
140+
141+ self = cls (
142+ id = what .id ,
143+ pid = proc .pid ,
144+ process = psutil .Process (proc .pid ),
145+ process_log = logger or structlog .get_logger (logger_name = "task" ).bind (),
146+ start_time = time .monotonic (),
147+ stdin = socks ["comm" ],
148+ child_process = proc ,
149+ comm_server = comm_server ,
150+ logs_server = logs_server ,
151+ stdout_w = stdout_w ,
152+ stderr_w = stderr_w ,
153+ ** kwargs ,
154+ )
155+ self ._register_pipe_readers (stdout_r , stderr_r , socks ["comm" ], socks ["logs" ])
156+ self ._on_child_started (
157+ ti = what ,
158+ dag_rel_path = dag_rel_path ,
159+ bundle_info = bundle_info ,
160+ sentry_integration = sentry_integration ,
161+ )
162+ return self
163+
164+ def wait (self ) -> int :
165+ code = super ().wait ()
166+ self ._close_unused_sockets (self ._comm_server , self ._logs_server , self ._stdout_w , self ._stderr_w )
167+ return code
168+
169+
44170@attrs .define (kw_only = True )
45171class ExecutableCoordinator (BaseCoordinator ):
46172 """
@@ -87,71 +213,42 @@ def get_code_from_file(cls, fileloc: str) -> str:
87213 raise FileNotFoundError (f"No source code found for executable: { fileloc } " )
88214 return code
89215
90- @classmethod
91- def dag_parsing_cmd (
92- cls ,
93- * ,
94- dag_file_path : str ,
95- bundle_name : str ,
96- bundle_path : str ,
97- comm_addr : str ,
98- logs_addr : str ,
99- ) -> list [str ]:
100- """Build the subprocess command for parsing a native executable bundle."""
101- return [
102- dag_file_path ,
103- f"--comm={ comm_addr } " ,
104- f"--logs={ logs_addr } " ,
105- ]
106-
107- def task_execution_cmd (
216+ def _resolve_executable (self , * , what : TaskInstanceDTO ) -> str :
217+ """
218+ Resolve the executable path for *what*.
219+
220+ Looks up the bundle whose embedded manifest declares ``what.dag_id``
221+ in the configured ``bundles_folder``.
222+ """
223+ if self .bundles_folder is None :
224+ raise ValueError (
225+ "The bundles_folder kwarg must be set on the ExecutableCoordinator "
226+ "to resolve the executable for task execution."
227+ )
228+ return BundleScanner (self .bundles_folder ).resolve (dag_id = what .dag_id )
229+
230+ def execute_task (
108231 self ,
109232 * ,
110- what : TaskInstance ,
111- dag_file_path : str ,
112- bundle_path : str ,
233+ what : TaskInstanceDTO ,
234+ dag_rel_path : str | os .PathLike [str ],
113235 bundle_info : BundleInfo ,
114- comm_addr : str ,
115- logs_addr : str ,
116- ) -> list [str ]:
117- """Build the subprocess command for executing a task in a native executable bundle."""
118- # Case 1: Pure executable DAG — dag_file_path is itself a bundle.
119- # A ``.py`` extension always indicates a Python stub, so short-circuit
120- # there before validating the AFBNDL01 trailer. Both checks are needed:
121- # ``os.access(_, X_OK)`` alone is not reliable because bind-mounted
122- # Python stubs can satisfy X_OK (e.g. when running as root inside
123- # Breeze) yet are not real bundles.
124- log .debug (
125- "Resolving executable for task execution" ,
236+ client : Client ,
237+ logger : FilteringBoundLogger | None = None ,
238+ sentry_integration : str = "" ,
239+ subprocess_logs_to_stdout : bool ,
240+ ** kwargs ,
241+ ) -> BaseCoordinator .ExecutionResult :
242+ executable_path = self ._resolve_executable (what = what )
243+ process = _ExecutableActivitySubprocess .start (
126244 what = what ,
127- dag_file_path = dag_file_path ,
128- bundle_path = bundle_path ,
245+ dag_rel_path = dag_rel_path ,
129246 bundle_info = bundle_info ,
130- comm_addr = comm_addr ,
131- logs_addr = logs_addr ,
247+ client = client ,
248+ logger = logger ,
249+ subprocess_logs_to_stdout = subprocess_logs_to_stdout ,
250+ sentry_integration = sentry_integration ,
251+ executable_path = executable_path ,
132252 )
133- if (
134- not dag_file_path .endswith (".py" )
135- and (resolved := BundleScanner .resolve_executable (pathlib .Path (dag_file_path ))) is not None
136- ):
137- return [
138- resolved ,
139- f"--comm={ comm_addr } " ,
140- f"--logs={ logs_addr } " ,
141- ]
142-
143- # Case 2: Python Stub DAG — the dag_file_path is a Python file but
144- # the task delegates to a native runtime. The actual binary lives
145- # in the coordinator's configured ``bundles_folder`` kwarg.
146- if self .bundles_folder is None :
147- raise ValueError (
148- "The bundles_folder kwarg must be set on the ExecutableCoordinator "
149- "for Python stub DAGs that delegate to native executable task execution."
150- )
151-
152- executable_path = BundleScanner (self .bundles_folder ).resolve (dag_id = what .dag_id )
153- return [
154- executable_path ,
155- f"--comm={ comm_addr } " ,
156- f"--logs={ logs_addr } " ,
157- ]
253+ exit_code = process .wait ()
254+ return self .ExecutionResult (exit_code , process .final_state )
0 commit comments