Skip to content

Commit ec6f22e

Browse files
committed
Report a native Dag's task that reaches a Python worker
A Python worker handed a task from a Dag file that a coordinator's Dag importer claims now exits with an error naming the queue to route. It checks before building a Dag bag, so it never starts the runtime. As in the Dag processor, a .py, .pyc or .zip file is never native.
1 parent cb9baf8 commit ec6f22e

2 files changed

Lines changed: 121 additions & 0 deletions

File tree

‎task-sdk/src/airflow/sdk/execution_time/task_runner.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
from airflow.sdk.bases.skipmixin import XCOM_SKIPMIXIN_KEY
6161
from airflow.sdk.bases.xcom import BaseXCom
6262
from airflow.sdk.configuration import conf
63+
from airflow.sdk.coordinators._dag_importer import CoordinatorDagImporter
6364
from airflow.sdk.definitions._internal.dag_parsing_context import _airflow_parsing_context_manager
6465
from airflow.sdk.definitions._internal.types import NOTSET, ArgNotSet, is_arg_set
6566
from airflow.sdk.definitions.asset import (
@@ -149,6 +150,7 @@
149150
from airflow.sdk.execution_time.sentry import Sentry
150151
from airflow.sdk.execution_time.tracing import detail_span
151152
from airflow.sdk.execution_time.xcom import XCom
153+
from airflow.sdk.importers import get_file_suffix, get_importer_registry
152154
from airflow.sdk.listener import get_listener_manager
153155
from airflow.sdk.observability.metrics import stats_utils
154156
from airflow.sdk.serde import allow_class, iter_pydantic_models
@@ -994,6 +996,22 @@ def _register_deserialization_allowed_classes(dag, log: Logger) -> None:
994996
)
995997

996998

999+
# The Dag processor always parses these with the legacy importer, whatever importer is configured.
1000+
_LEGACY_DAG_FILE_SUFFIXES = frozenset({".py", ".pyc", ".zip"})
1001+
1002+
1003+
def _is_native_dag_file(path: str, bundle_name: str) -> bool:
1004+
"""Return whether a coordinator's Dag importer claims *path*, so that a Lang-SDK runtime parses it."""
1005+
if get_file_suffix(path) in _LEGACY_DAG_FILE_SUFFIXES:
1006+
return False
1007+
try:
1008+
importer = get_importer_registry(bundle_name).get_importer(path)
1009+
except Exception:
1010+
# Building the Dag bag reports a broken importer configuration.
1011+
return False
1012+
return isinstance(importer, CoordinatorDagImporter)
1013+
1014+
9971015
@detail_span("parse")
9981016
def parse(what: StartupDetails, log: Logger) -> RuntimeTaskInstance:
9991017
# TODO: Task-SDK:
@@ -1006,6 +1024,17 @@ def parse(what: StartupDetails, log: Logger) -> RuntimeTaskInstance:
10061024
bundle_prepare_ms = int((time.monotonic() - bundle_prepare_start) * 1000)
10071025

10081026
dag_absolute_path = os.fspath(Path(bundle_instance.path, what.dag_rel_path))
1027+
if _is_native_dag_file(dag_absolute_path, bundle_info.name):
1028+
log.error(
1029+
"A task of a native Lang-SDK Dag cannot run in Python. Route its queue to the coordinator "
1030+
"that parses the Dag, with [sdk] queue_to_coordinator",
1031+
dag_id=what.ti.dag_id,
1032+
task_id=what.ti.task_id,
1033+
queue=what.ti.queue,
1034+
path=what.dag_rel_path,
1035+
)
1036+
sys.exit(1)
1037+
10091038
dag_file_parse_start = time.monotonic()
10101039
bag = BundleDagBag(
10111040
dag_folder=dag_absolute_path,

‎task-sdk/tests/task_sdk/execution_time/test_task_runner.py‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from unittest import mock
3232
from unittest.mock import call, patch
3333

34+
import attrs
3435
import pandas as pd
3536
import pytest
3637
import structlog
@@ -78,6 +79,8 @@
7879
)
7980
from airflow.sdk.bases.operator import ExecutorSafeguard
8081
from airflow.sdk.bases.xcom import BaseXCom
82+
from airflow.sdk.coordinators._dag_importer import CoordinatorDagImporter
83+
from airflow.sdk.coordinators._subprocess import SubprocessCoordinator
8184
from airflow.sdk.definitions._internal.types import NOTSET, SET_DURING_EXECUTION, is_arg_set
8285
from airflow.sdk.definitions.asset import Asset, AssetAlias, AssetUniqueKey, AssetUriRef, Dataset, Model
8386
from airflow.sdk.definitions.param import DagParam
@@ -175,6 +178,7 @@
175178
TaskRunnerMarker,
176179
_defer_task,
177180
_execute_task,
181+
_is_native_dag_file,
178182
_make_task_span,
179183
_push_xcom_if_needed,
180184
_register_deserialization_allowed_classes,
@@ -912,6 +916,94 @@ def test_parse_dag_from_bundle_importer(tmp_path: Path, make_ti_context):
912916
assert ti.task.dag.relative_fileloc == "listed.dags"
913917

914918

919+
class NativeDagImporter(CoordinatorDagImporter):
920+
artifact_suffix = ".native"
921+
supported_extensions = [".native"]
922+
923+
def get_source_code(self, definition):
924+
return DagSourceCode(source_code=definition.read_text(), language="native")
925+
926+
927+
@attrs.define(kw_only=True)
928+
class NativeCoordinator(SubprocessCoordinator):
929+
"""A coordinator whose Dag importer claims ``.native`` files in every bundle."""
930+
931+
def get_dag_importer(self):
932+
return NativeDagImporter(coordinator=self)
933+
934+
935+
@patch("airflow.dag_processing.dagbag.BundleDagBag", autospec=True)
936+
def test_parse_rejects_a_task_of_a_native_dag(mock_bag, tmp_path: Path, make_ti_context):
937+
tmp_path.joinpath("dag.native").write_text("{}")
938+
what = StartupDetails(
939+
ti=TaskInstance(
940+
id=uuid7(),
941+
task_id="a",
942+
dag_id="native_dag",
943+
run_id="c",
944+
try_number=1,
945+
dag_version_id=uuid7(),
946+
queue="default",
947+
),
948+
dag_rel_path="dag.native",
949+
bundle_info=BundleInfo(name="my-bundle", version=None),
950+
ti_context=make_ti_context(),
951+
start_date=timezone.utcnow(),
952+
sentry_integration="",
953+
)
954+
bundle_config = [
955+
{
956+
"name": "my-bundle",
957+
"classpath": "airflow.dag_processing.bundles.local.LocalDagBundle",
958+
"kwargs": {"path": str(tmp_path), "refresh_interval": 1},
959+
}
960+
]
961+
coordinators = {"native": {"classpath": f"{__name__}.NativeCoordinator", "kwargs": {}}}
962+
log = mock.Mock()
963+
964+
reset_importer_registry()
965+
try:
966+
with (
967+
patch.dict(
968+
os.environ,
969+
{
970+
"AIRFLOW__DAG_PROCESSOR__DAG_BUNDLE_CONFIG_LIST": json.dumps(bundle_config),
971+
"AIRFLOW__SDK__COORDINATORS": json.dumps(coordinators),
972+
},
973+
),
974+
pytest.raises(SystemExit, match="1"),
975+
):
976+
parse(what, log)
977+
finally:
978+
reset_importer_registry()
979+
980+
mock_bag.assert_not_called()
981+
log.error.assert_called_once_with(
982+
"A task of a native Lang-SDK Dag cannot run in Python. Route its queue to the coordinator "
983+
"that parses the Dag, with [sdk] queue_to_coordinator",
984+
dag_id="native_dag",
985+
task_id="a",
986+
queue="default",
987+
path="dag.native",
988+
)
989+
990+
991+
@pytest.mark.parametrize(
992+
("file_name", "expected"),
993+
[("dag.native", True), ("dag.py", False), ("dag.pyc", False), ("dags.zip", False)],
994+
)
995+
@patch.object(NativeDagImporter, "supported_extensions", [".native", ".py", ".pyc", ".zip"])
996+
def test_a_python_dag_file_is_never_native(file_name, expected):
997+
coordinators = {"native": {"classpath": f"{__name__}.NativeCoordinator", "kwargs": {}}}
998+
999+
reset_importer_registry()
1000+
try:
1001+
with patch.dict(os.environ, {"AIRFLOW__SDK__COORDINATORS": json.dumps(coordinators)}):
1002+
assert _is_native_dag_file(f"/bundle/{file_name}", "my-bundle") is expected
1003+
finally:
1004+
reset_importer_registry()
1005+
1006+
9151007
@pytest.mark.parametrize("use_queues", [False, True])
9161008
def test_run_deferred_basic(time_machine, create_runtime_ti, mock_supervisor_comms, use_queues: bool):
9171009
"""Test that a task can transition to a deferred state."""

0 commit comments

Comments
 (0)