Skip to content

Commit d58b330

Browse files
committed
Align the latest coordinator interface for executable coordinator
1 parent 7d57137 commit d58b330

2 files changed

Lines changed: 550 additions & 227 deletions

File tree

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

Lines changed: 165 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -15,24 +15,74 @@
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

2020
from __future__ import annotations
2121

2222
import os
2323
import 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

2630
import attrs
27-
from structlog import get_logger
31+
import psutil
32+
import structlog
2833

2934
from airflow.sdk.coordinators.executable.bundle_scanner import BundleScanner, read_source_code
3035
from airflow.sdk.execution_time.coordinator import BaseCoordinator
36+
from airflow.sdk.execution_time.supervisor import ActivitySubprocess
3137

3238
if 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

3888
def _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)
45171
class 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

Comments
 (0)