Skip to content

Commit 4d47366

Browse files
committed
TS SDK: cover native TypeScript Dags end to end
1 parent ced7278 commit 4d47366

10 files changed

Lines changed: 628 additions & 2 deletions

File tree

‎airflow-core/docs/authoring-and-scheduling/language-sdks/typescript.rst‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,10 @@ no TypeScript marker and inherits no queue from the SDK, so the deployment must
262262
provider installed and a Python worker able to pick it up. Templated arguments pass through untouched:
263263
``{{ ds }}`` in a ``conf`` value is rendered server-side, where rendering already happens.
264264

265+
``ts-sdk/example/src/native.ts`` is a worked example that uses every one of these constructs in one
266+
Dag, registered on the same bundle as the mixed-language handlers beside it, so a single artifact
267+
serves both authoring modes.
268+
265269
``new Dag`` and ``dag.task`` both take a trailing spec carrying the options Python would otherwise own —
266270
``{ schedule: "@daily", tags: ["etl"] }`` for the Dag, ``{ retries: 2, retryDelay: 30 }`` for a task. Both
267271
are generated from Airflow's own Dag serialization schema, so the field names are the schema's keys in

‎airflow-e2e-tests/tests/airflow_e2e_tests/e2e_test_utils/clients.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import time
2020
from datetime import datetime, timezone
2121
from functools import cached_property
22+
from http import HTTPStatus
2223

2324
import boto3
2425
import requests
@@ -163,6 +164,21 @@ def get_variable(self, key: str):
163164
"""Get an Airflow Variable via API."""
164165
return self._make_request(method="GET", endpoint=f"variables/{key}")
165166

167+
def set_variable(self, key: str, value: str, description: str | None = None):
168+
"""Create or replace an Airflow Variable via API."""
169+
body = {"key": key, "value": value, "description": description}
170+
try:
171+
return self._make_request(method="POST", endpoint="variables", json=body)
172+
except requests.HTTPError as exc:
173+
# 409 == it already exists, from an earlier run of the same suite.
174+
if exc.response is None or exc.response.status_code != HTTPStatus.CONFLICT:
175+
raise
176+
return self._make_request(method="PATCH", endpoint=f"variables/{key}", json=body)
177+
178+
def get_tasks(self, dag_id: str):
179+
"""List a Dag's tasks, with the edges each one carries."""
180+
return self._make_request(method="GET", endpoint=f"dags/{dag_id}/tasks")
181+
166182
def trigger_dag_and_wait(self, dag_id: str, json=None):
167183
"""Trigger a DAG and wait for it to complete."""
168184
self.un_pause_dag(dag_id)
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
"""
18+
End-to-end test of a Dag declared entirely in TypeScript.
19+
20+
Run with::
21+
22+
E2E_TEST_MODE=ts_sdk RUN_TS_SDK_NATIVE_DAG_TESTS=true \\
23+
uv run --project airflow-e2e-tests pytest \\
24+
tests/airflow_e2e_tests/ts_sdk_tests/test_ts_sdk_native_dag.py -xvs
25+
26+
Unlike ``test_ts_sdk_dag.py``, no Python file declares this Dag: the Dag processor asks the
27+
``airflow-ts-pack`` bundle to parse itself, and the bundle answers with the serialized Dag that
28+
``ts-sdk/example/src/native.ts`` built. The graph exercises every construct of the native surface
29+
rather than a linear chain -- a task group, a named fan-in, an order-only edge, a conditional, a
30+
multi-way branch, and a ``TriggerDagRunOperator`` a Python worker runs.
31+
32+
Gated behind ``RUN_TS_SDK_NATIVE_DAG_TESTS`` because it needs a Dag processor that can dispatch a
33+
parse request to a language coordinator. Until that lands, the bundle answers a request nothing
34+
sends, and the Dag never appears.
35+
"""
36+
37+
from __future__ import annotations
38+
39+
import os
40+
from dataclasses import dataclass
41+
from datetime import datetime, timezone
42+
43+
import pytest
44+
45+
from airflow_e2e_tests.e2e_test_utils.clients import AirflowClient
46+
47+
_RUN_NATIVE = os.environ.get("RUN_TS_SDK_NATIVE_DAG_TESTS", "").lower() in ("true", "1")
48+
49+
# Parsing the bundle launches node before the first task is even scheduled, so
50+
# allow the same headroom the mixed-language suite does.
51+
_TS_TASK_TIMEOUT = 600
52+
53+
_DAG_ID = "typescript_native_example"
54+
55+
# Read by the extract tasks; see ts-sdk/example/src/native.ts.
56+
_NORTH_ROWS_VARIABLE = "typescript_native_north_rows"
57+
_SOUTH_ROWS_VARIABLE = "typescript_native_south_rows"
58+
59+
pytestmark = pytest.mark.skipif(
60+
not _RUN_NATIVE,
61+
reason="Needs a Dag processor that dispatches parse requests to a language coordinator "
62+
"(RUN_TS_SDK_NATIVE_DAG_TESTS)",
63+
)
64+
65+
66+
@dataclass
67+
class _CompletedRun:
68+
client: AirflowClient
69+
run_id: str
70+
state: str
71+
ti_states: dict[str, str]
72+
73+
def xcom(self, task_id: str, key: str = "return_value"):
74+
return self.client.get_xcom_value(dag_id=_DAG_ID, task_id=task_id, run_id=self.run_id, key=key).get(
75+
"value"
76+
)
77+
78+
79+
@pytest.fixture(scope="module")
80+
def completed_run() -> _CompletedRun:
81+
"""Trigger the native Dag once with rows in both regions; every test reads that run."""
82+
client = AirflowClient()
83+
# Both regions non-empty, so the conditional takes its `then` branch.
84+
for key, value in ((_NORTH_ROWS_VARIABLE, "3"), (_SOUTH_ROWS_VARIABLE, "2")):
85+
client.set_variable(key, value)
86+
87+
resp = client.trigger_dag(_DAG_ID, json={"logical_date": datetime.now(timezone.utc).isoformat()})
88+
run_id = resp["dag_run_id"]
89+
state = client.wait_for_dag_run(dag_id=_DAG_ID, run_id=run_id, timeout=_TS_TASK_TIMEOUT)
90+
ti_resp = client.get_task_instances(dag_id=_DAG_ID, run_id=run_id)
91+
return _CompletedRun(
92+
client=client,
93+
run_id=run_id,
94+
state=state,
95+
ti_states={ti["task_id"]: ti.get("state") for ti in ti_resp.get("task_instances", [])},
96+
)
97+
98+
99+
def test_the_dag_the_bundle_parsed_is_registered():
100+
"""The Dag exists without any Python file declaring it."""
101+
client = AirflowClient()
102+
dag = client.get_dag(_DAG_ID)
103+
104+
assert dag["dag_id"] == _DAG_ID
105+
assert dag.get("timetable_summary") == "@daily"
106+
assert set(dag.get("tags") or []) >= {"typescript", "native"}
107+
108+
109+
def test_the_graph_carries_every_construct():
110+
"""Group prefixes, the fan-in, the branches and the trigger all survive parsing."""
111+
tasks = AirflowClient().get_tasks(_DAG_ID)
112+
by_id = {task["task_id"]: task for task in tasks.get("tasks", [])}
113+
114+
# The task group prefixed its members.
115+
assert {"extract.north", "extract.south"} <= set(by_id)
116+
# The named fan-in put both extract tasks upstream of summarize.
117+
assert set(by_id["summarize"]["upstream_task_ids"]) >= {"extract.north", "extract.south"}
118+
# The conditional and the branch reach their candidates.
119+
assert set(by_id["has_rows"]["downstream_task_ids"]) >= {"load_rows", "report_empty"}
120+
assert set(by_id["pick_cadence"]["downstream_task_ids"]) >= {
121+
"publish_daily",
122+
"publish_weekly",
123+
}
124+
# The order-only edge, which carries no value.
125+
assert "trigger_downstream" in by_id["cleanup"]["downstream_task_ids"]
126+
# And the one task Python runs rather than the Node coordinator.
127+
assert by_id["trigger_downstream"]["operator_name"] == "TriggerDagRunOperator"
128+
129+
130+
def test_dag_run_succeeded(completed_run: _CompletedRun):
131+
assert completed_run.state == "success", (
132+
f"expected the run to succeed; got {completed_run.state!r}. task states: {completed_run.ti_states}"
133+
)
134+
135+
136+
def test_the_taken_branches_ran_and_the_others_skipped(completed_run: _CompletedRun):
137+
"""A branch is a run-time skip, so the states are what prove it worked."""
138+
states = completed_run.ti_states
139+
140+
# Both regions had rows, so the conditional followed `then`.
141+
assert states.get("load_rows") == "success"
142+
assert states.get("report_empty") == "skipped"
143+
144+
# Exactly one cadence ran; which one depends on the run's own date.
145+
cadences = {states.get("publish_daily"), states.get("publish_weekly")}
146+
assert cadences == {"success", "skipped"}, f"expected one of each, got {cadences}"
147+
148+
149+
def test_every_other_task_succeeded(completed_run: _CompletedRun):
150+
always_run = [
151+
"extract.north",
152+
"extract.south",
153+
"summarize",
154+
"has_rows",
155+
"pick_cadence",
156+
"cleanup",
157+
"trigger_downstream",
158+
]
159+
for task_id in always_run:
160+
assert completed_run.ti_states.get(task_id) == "success", (
161+
f"{task_id!r} did not succeed. all task states: {completed_run.ti_states}"
162+
)
163+
164+
165+
def test_xcoms_flow_between_typescript_tasks(completed_run: _CompletedRun):
166+
"""The fan-in's arguments reached the handler, and its own push came back."""
167+
assert completed_run.xcom("extract.north") == {"region": "north", "rows": 3}
168+
assert completed_run.xcom("extract.south") == {"region": "south", "rows": 2}
169+
assert completed_run.xcom("summarize") == {"total": 5, "regions": 2}
170+
# Pushed under its own key by the summarize handler, and read back by load_rows.
171+
assert completed_run.xcom("summarize", key="region_total") == 5
172+
assert completed_run.xcom("load_rows") == {"loaded": 5}
173+
174+
175+
def test_the_condition_reports_what_it_decided(completed_run: _CompletedRun):
176+
"""A condition returns its boolean, and a branch the task id it chose."""
177+
assert completed_run.xcom("has_rows") is True
178+
assert completed_run.xcom("pick_cadence") in ("publish_daily", "publish_weekly")

‎kubernetes-tests/tests/kubernetes_tests/test_lang_sdk_coordinator_executor.py‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,18 @@
3838
_RUN_LANG_SDK = os.environ.get("RUN_LANG_SDK_K8S_TESTS", "").lower() in ("true", "1")
3939

4040
DAG_ID = "lang_sdk_combined"
41+
# Declared entirely in TypeScript: the Dag processor asks the bundle to parse
42+
# itself, so no Python file in the bundles folder mentions it.
43+
NATIVE_TS_DAG_ID = "typescript_native_example"
44+
NATIVE_TS_TASK_IDS = [
45+
"extract.north",
46+
"extract.south",
47+
"summarize",
48+
"has_rows",
49+
"pick_cadence",
50+
"cleanup",
51+
"trigger_downstream",
52+
]
4153
TASK_IDS = [
4254
"python_task_1",
4355
"go_extract",
@@ -50,6 +62,10 @@
5062
# artifact + start a coordinator subprocess, so allow generous headroom.
5163
_TIMEOUT = 600
5264

65+
# The native Dag additionally needs a Dag processor that dispatches a parse
66+
# request to the Node coordinator; until that lands the Dag never appears.
67+
_RUN_NATIVE_TS = os.environ.get("RUN_TS_SDK_NATIVE_DAG_K8S_TESTS", "").lower() in ("true", "1")
68+
5369

5470
@pytest.mark.skipif(
5571
EXECUTOR != "KubernetesExecutor" or not _RUN_LANG_SDK,
@@ -86,3 +102,62 @@ def test_lang_sdk_combined_dag_succeeds(self):
86102
expected_final_state="success",
87103
timeout=_TIMEOUT,
88104
)
105+
106+
107+
@pytest.mark.skipif(
108+
EXECUTOR != "KubernetesExecutor" or not _RUN_NATIVE_TS,
109+
reason="Runs only on KubernetesExecutor with a Dag processor that parses Lang SDK bundles "
110+
"(RUN_TS_SDK_NATIVE_DAG_K8S_TESTS)",
111+
)
112+
class TestNativeTypeScriptDagOnKubernetes(BaseK8STest):
113+
"""The same Dag the SDK and Airflow e2e suites run, under the Node coordinator.
114+
115+
Covers the path a Dag with no Python file takes: the Dag processor parses the
116+
``airflow-ts-pack`` bundle through the coordinator, the scheduler reads the serialized Dag,
117+
and each task runs in its own pod. The graph is a real one -- a task group, a named fan-in,
118+
an order-only edge, a conditional and a multi-way branch -- so pod-per-task scheduling is
119+
exercised against branches that skip rather than a linear chain.
120+
"""
121+
122+
def _ensure_variable(self, key: str, value: str) -> None:
123+
resp = self.session.post(f"http://{self.host}/variables", json={"key": key, "value": value})
124+
assert resp.status_code in (200, 201, 409), f"Could not create variable {key}: {resp.text}"
125+
126+
@pytest.mark.execution_timeout(1200)
127+
def test_native_typescript_dag_succeeds(self):
128+
# Both regions non-empty, so the conditional takes its `then` branch and
129+
# `report_empty` is the side that skips.
130+
self._ensure_variable("typescript_native_north_rows", "3")
131+
self._ensure_variable("typescript_native_south_rows", "2")
132+
133+
dag_run_id, logical_date = self.start_job_in_kubernetes(NATIVE_TS_DAG_ID, self.host)
134+
print(f"Triggered {NATIVE_TS_DAG_ID} run {dag_run_id} (logical_date={logical_date})")
135+
136+
for task_id in NATIVE_TS_TASK_IDS:
137+
self.monitor_task(
138+
host=self.host,
139+
dag_run_id=dag_run_id,
140+
dag_id=NATIVE_TS_DAG_ID,
141+
task_id=task_id,
142+
expected_final_state="success",
143+
timeout=_TIMEOUT,
144+
)
145+
146+
# The branch that was not taken is skipped, not failed, which is what
147+
# proves the skip reached the supervisor rather than the task erroring.
148+
self.monitor_task(
149+
host=self.host,
150+
dag_run_id=dag_run_id,
151+
dag_id=NATIVE_TS_DAG_ID,
152+
task_id="report_empty",
153+
expected_final_state="skipped",
154+
timeout=_TIMEOUT,
155+
)
156+
157+
self.ensure_dag_expected_state(
158+
host=self.host,
159+
logical_date=logical_date,
160+
dag_id=NATIVE_TS_DAG_ID,
161+
expected_final_state="success",
162+
timeout=_TIMEOUT,
163+
)

‎ts-sdk/example/README.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,13 @@ This example shows the coordinator-mode shape for TypeScript task handlers:
2525
- `src/main.ts` and `src/taskflow.ts` register a `TaskHandler` per stub task and start the coordinator runtime.
2626
One bundle provides for both Dags, and both declare a task called `build_message`.
2727
A handler binds the `(dag_id, task_id)` pair, so the two are different tasks with different bodies.
28-
- `dist/bundle.min.mjs` is the generated Node.js bundle that Airflow launches.
28+
- `src/native.ts` declares a third Dag, `typescript_native_example`, **entirely in TypeScript** — no
29+
Python file declares its graph. Its schedule, task options and edges are all written on this side,
30+
and the bundle answers the Dag processor's parse request with the serialized Dag. It exercises every
31+
construct the native surface offers: a task group, a named fan-in, an order-only edge, a conditional,
32+
a multi-way branch, and a `triggerDagRun` task a Python worker executes.
33+
- `dist/bundle.min.mjs` is the generated Node.js bundle that Airflow launches. One artifact serves both
34+
authoring modes.
2935

3036
The build uses the SDK's `airflow-ts-pack` tool, which bundles the entrypoint
3137
with esbuild and embeds the Airflow metadata generated from the bundle's

‎ts-sdk/example/src/main.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
import { Bundle, getClient, getContext, TaskHandler } from "apache-airflow-ts-sdk";
2626

27+
import { dag as nativeDag } from "./native.js";
2728
import { buildSummaryMessage, report, summarize } from "./taskflow.js";
2829

2930
export async function buildMessage() {
@@ -72,8 +73,12 @@ export async function readConnection() {
7273
// One register call lists everything this bundle provides.
7374
// `build_message` appears under both Dags: two different handlers, told apart by the dag_id each
7475
// is bound to and never by the task_id alone.
76+
//
77+
// One bundle serves both authoring modes: handlers for the Dags a Python file declares, and the
78+
// natively declared Dag from `native.ts`, whose graph this side owns outright.
7579
const bundle = new Bundle();
7680
bundle.register(
81+
nativeDag,
7782
new TaskHandler("typescript_example", "build_message", buildMessage),
7883
new TaskHandler("typescript_example", "read_connection", readConnection),
7984
new TaskHandler("typescript_example", "write_and_delete_variable", writeAndDeleteVariable),

0 commit comments

Comments
 (0)