Skip to content

Commit b196b84

Browse files
committed
TS SDK: serialize a native Dag to Dag JSON
1 parent 5e785ac commit b196b84

18 files changed

Lines changed: 2734 additions & 16 deletions

File tree

‎.pre-commit-config.yaml‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,21 @@ repos:
348348
# prek run sync-java-sdk-dag-schema --hook-stage manual
349349
stages: ['manual']
350350
files: ^java-sdk/sdk/schema/dag-schema\.json$
351+
# check-ts-sdk-serialization-conformance in ts-sdk/ covers the SDK's own files; this runs the same
352+
# check when the shared harness or Airflow's serializer changes, which that project cannot see.
353+
- id: check-ts-sdk-serialization-conformance-shared
354+
name: Check the TS SDK serializes Dags the way Airflow does, after a shared change
355+
description: "Serialize the shared test Dags with the TS SDK and with Airflow, and compare the two"
356+
entry: ./ts-sdk/scripts/ci/prek/check_serialization_conformance.py
357+
language: node
358+
additional_dependencies: ['pnpm@10.28.1']
359+
pass_filenames: false
360+
require_serial: true
361+
files: >
362+
(?x)
363+
^airflow-core/src/airflow/serialization/serialized_objects\.py$|
364+
^airflow-core/src/airflow/serialization/schema\.json$|
365+
^scripts/ci/lang_sdk_serialization/.*$
351366
- id: check-go-version-in-sync
352367
name: Check Go toolchain version is consistent across build files
353368
entry: ./scripts/ci/prek/check_go_version_in_sync.py

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,29 @@ Pass ``{ prefixGroupId: false }`` to keep the ids declared in a group as written
336336
does in Python; they then have to be unique across the Dag. A group id is made of letters, digits, dashes and
337337
underscores, and is at most 200 characters.
338338

339+
Serialization
340+
~~~~~~~~~~~~~
341+
342+
A native Dag serializes into the same Dag JSON a Python Dag produces, so the scheduler reads it
343+
without knowing which language declared it.
344+
345+
``schedule`` accepts what maps to a stock timetable: unset, ``@once``, ``@continuous``, or a cron
346+
expression. A cron preset such as ``@daily`` is recorded as the expression it stands for. Anything
347+
else names a Python object a TypeScript bundle cannot point at, and is rejected.
348+
349+
Every task of a native Dag runs on the Node coordinator, so it needs the queue the deployment routes
350+
there. Set it once on the Dag and each task inherits it:
351+
352+
.. code-block:: typescript
353+
354+
const dag = new Dag("ts_etl", { schedule: "@daily", queue: "typescript" });
355+
356+
// ...and one task that needs its own.
357+
dag.task("heavy", heavyHandler, { queue: "typescript_large" })();
358+
359+
``queue`` on a task wins over the Dag's. See :ref:`typescript-sdk/coordinator-config` for the
360+
``queue_to_coordinator`` entry that sends that queue to the coordinator.
361+
339362
``new Dag`` and ``dag.task`` both take a trailing spec of Airflow options:
340363
``{ schedule: "@daily", tags: ["etl"] }`` for the Dag, ``{ retries: 2, retryDelay: 30 }`` for a task.
341364

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
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.
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
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+
r"""
18+
Check that a language SDK serializes Dags the way Airflow does.
19+
20+
Runs the SDK's serializer and serialize_python.py over test_dags.yaml, each writing its serialization
21+
to a JSON file in a temporary directory. The Python side also takes the SDK's output as Airflow
22+
receives it, filling in the fields the SDK leaves to Airflow's config, and loads it through Airflow's
23+
deserializer. The two are then compared field by field, so this fails both when the serializers drift
24+
apart and when Airflow cannot read what the SDK writes.
25+
26+
The SDK's serializer is the command after ``--``. It runs from the repository root, with the paths of
27+
test_dags.yaml and of the JSON file to write appended, and writes each Dag as
28+
``DagSerialization.to_dict`` would, keyed by Dag id. The Python side needs ``uv``::
29+
30+
python3 scripts/ci/lang_sdk_serialization/compare.py --sdk typescript -- \
31+
pnpm --dir ts-sdk exec tsx tests/conformance/serialize_typescript.ts
32+
33+
On a failure the files are kept, and their directory is printed.
34+
"""
35+
36+
from __future__ import annotations
37+
38+
import argparse
39+
import json
40+
import shutil
41+
import subprocess
42+
import sys
43+
import tempfile
44+
from pathlib import Path
45+
from typing import Any
46+
47+
HERE = Path(__file__).resolve().parent
48+
REPO_ROOT = HERE.parents[2]
49+
TEST_DAGS = HERE / "test_dags.yaml"
50+
SCHEMA = REPO_ROOT / "airflow-core" / "src" / "airflow" / "serialization" / "schema.json"
51+
52+
# Name the file a Dag was declared in: Python's own Dag file, or the SDK's bundle.
53+
DAG_KEYS_NOT_COMPARED = frozenset({"fileloc", "relative_fileloc", "_processor_dags_folder"})
54+
55+
TASK_KEYS_NOT_COMPARED = frozenset(
56+
{
57+
# Operator identity: Python names the Python class that ran, a language SDK a fixed pair.
58+
# Neither is ever imported on the Airflow side.
59+
"task_type",
60+
"_task_module",
61+
# The SDK's marker for a task in its language, which Python has no equivalent for.
62+
"language",
63+
# Python bookkeeping for mapped tasks and retry policies, neither of which a language SDK's
64+
# Dag can declare.
65+
"_needs_expansion",
66+
"has_retry_policy",
67+
# The stub contract every language SDK task carries, which the Python operator built here
68+
# has no counterpart for. Loading the SDK's output through Airflow checks them.
69+
"is_stub",
70+
"_arg_bindings",
71+
}
72+
)
73+
74+
75+
def run(command: list[str]) -> None:
76+
if subprocess.run(command, cwd=REPO_ROOT, check=False).returncode:
77+
raise SystemExit(f"`{' '.join(command)}` failed")
78+
79+
80+
def get_task_defaults() -> dict[str, Any]:
81+
"""Map each task field the Dag schema gives a default to that default."""
82+
fields = json.loads(SCHEMA.read_text())["definitions"]["operator"]["properties"]
83+
return {key: field["default"] for key, field in fields.items() if field.get("default") is not None}
84+
85+
86+
def normalize_as_javascript(value: Any) -> Any:
87+
"""Read a JSON value as JavaScript does: one number type, and a bool that is not a number."""
88+
if isinstance(value, bool):
89+
return ("bool", value)
90+
if isinstance(value, (int, float)):
91+
return float(value)
92+
if isinstance(value, list):
93+
return [normalize_as_javascript(item) for item in value]
94+
if isinstance(value, dict):
95+
return {key: normalize_as_javascript(item) for key, item in value.items()}
96+
return value
97+
98+
99+
def is_same_json(python: Any, sdk: Any) -> bool:
100+
return normalize_as_javascript(python) == normalize_as_javascript(sdk)
101+
102+
103+
def find_differences(path: str, python: Any, sdk: Any) -> list[str]:
104+
"""List where two JSON values differ, down to the innermost key or index."""
105+
if isinstance(python, dict) and isinstance(sdk, dict):
106+
problems = []
107+
for key in sorted(python.keys() | sdk.keys()):
108+
if key not in sdk:
109+
problems.append(f"{path}.{key} is missing, Python writes {python[key]!r}")
110+
elif key not in python:
111+
problems.append(f"{path}.{key} is {sdk[key]!r}, which Python does not write")
112+
else:
113+
problems.extend(find_differences(f"{path}.{key}", python[key], sdk[key]))
114+
return problems
115+
if isinstance(python, list) and isinstance(sdk, list) and len(python) == len(sdk):
116+
return [
117+
problem
118+
for index, (python_item, sdk_item) in enumerate(zip(python, sdk))
119+
for problem in find_differences(f"{path}[{index}]", python_item, sdk_item)
120+
]
121+
if not is_same_json(python, sdk):
122+
return [f"{path} is {sdk!r}, Python writes {python!r}"]
123+
return []
124+
125+
126+
def compare_fields(
127+
python: dict[str, Any], sdk: dict[str, Any], not_compared: frozenset[str], defaults: dict[str, Any]
128+
) -> list[str]:
129+
"""
130+
Compare two serialized objects key by key.
131+
132+
A key the SDK leaves out is fine when Python wrote its schema default. Python keeps such a value
133+
when its ``client_defaults`` table disagrees with the schema, a table a language SDK is never sent,
134+
and Airflow reads a missing field as its default anyway.
135+
"""
136+
problems = []
137+
for key in sorted((python.keys() | sdk.keys()) - not_compared):
138+
if key in python and key in sdk:
139+
problems.extend(find_differences(key, python[key], sdk[key]))
140+
elif key in sdk:
141+
problems.append(f"{key} is {sdk[key]!r}, which Python does not write")
142+
elif key not in defaults or not is_same_json(python[key], defaults[key]):
143+
problems.append(f"{key} is missing, Python writes {python[key]!r}")
144+
return problems
145+
146+
147+
def compare_dag(python: dict[str, Any], sdk: dict[str, Any], defaults: dict[str, Any]) -> list[str]:
148+
problems = compare_fields(python, sdk, DAG_KEYS_NOT_COMPARED | {"tasks"}, {})
149+
python_ids = [task["__var"]["task_id"] for task in python["tasks"]]
150+
sdk_ids = [task["__var"]["task_id"] for task in sdk["tasks"]]
151+
if sdk_ids != python_ids:
152+
return [*problems, f"tasks are {sdk_ids}, Python writes {python_ids}"]
153+
for python_task, sdk_task in zip(python["tasks"], sdk["tasks"]):
154+
task_id = python_task["__var"]["task_id"]
155+
if sdk_task["__type"] != python_task["__type"]:
156+
problems.append(f"task {task_id} is a {sdk_task['__type']!r}, not a {python_task['__type']!r}")
157+
problems.extend(
158+
f"task {task_id}: {problem}"
159+
for problem in compare_fields(
160+
python_task["__var"], sdk_task["__var"], TASK_KEYS_NOT_COMPARED, defaults
161+
)
162+
)
163+
return problems
164+
165+
166+
def compare(python: dict[str, Any], sdk: dict[str, Any], task_defaults: dict[str, Any]) -> list[str]:
167+
"""List every way the SDK's serialization differs from Python's."""
168+
if sdk.keys() != python.keys():
169+
return [f"the Dags are {sorted(sdk)}, Python writes {sorted(python)}"]
170+
problems = []
171+
for dag_id, python_dag in python.items():
172+
sdk_dag = sdk[dag_id]
173+
if sdk_dag["__version"] != python_dag["__version"]:
174+
problems.append(
175+
f"{dag_id}: __version is {sdk_dag['__version']}, Python writes {python_dag['__version']}"
176+
)
177+
problems.extend(
178+
f"{dag_id}: {problem}"
179+
for problem in compare_dag(python_dag["dag"], sdk_dag["dag"], task_defaults)
180+
)
181+
return problems
182+
183+
184+
def main(argv: list[str] | None = None) -> int:
185+
parser = argparse.ArgumentParser(
186+
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
187+
)
188+
parser.add_argument("--sdk", required=True, help="the SDK's name, as in serialized_<sdk>.json")
189+
parser.add_argument("command", nargs="+", help="the SDK's serializer, after --")
190+
args = parser.parse_args(argv)
191+
192+
directory = Path(tempfile.mkdtemp(prefix=f"{args.sdk}-serialization-"))
193+
python_output = directory / "serialized_python.json"
194+
sdk_output = directory / f"serialized_{args.sdk}.json"
195+
received_output = directory / f"received_{args.sdk}.json"
196+
run([*args.command, str(TEST_DAGS), str(sdk_output)])
197+
run(
198+
[
199+
"uv",
200+
"run",
201+
"--project",
202+
"airflow-core",
203+
# airflow-core's dev group pulls in providers and extras that build native code; the
204+
# serializer only needs airflow-core itself.
205+
"--no-dev",
206+
"python",
207+
str(HERE / "serialize_python.py"),
208+
str(TEST_DAGS),
209+
str(python_output),
210+
"--receive",
211+
str(sdk_output),
212+
str(received_output),
213+
]
214+
)
215+
216+
python = json.loads(python_output.read_text())
217+
problems = compare(python, json.loads(received_output.read_text()), get_task_defaults())
218+
if problems:
219+
print(
220+
f"The {args.sdk} serialization differs from Python's in {len(problems)} place(s):",
221+
file=sys.stderr,
222+
)
223+
for problem in problems:
224+
print(f" {problem}", file=sys.stderr)
225+
print(f"The serializations are kept in {directory}", file=sys.stderr)
226+
return 1
227+
shutil.rmtree(directory)
228+
print(f"The {args.sdk} SDK serializes all {len(python)} Dags of {TEST_DAGS.name} as Python does")
229+
return 0
230+
231+
232+
if __name__ == "__main__":
233+
sys.exit(main())

0 commit comments

Comments
 (0)