Skip to content

Commit a2b0c39

Browse files
committed
TS SDK: serialize a native Dag to Dag JSON
1 parent 0490d68 commit a2b0c39

18 files changed

Lines changed: 2616 additions & 15 deletions

File tree

‎.pre-commit-config.yaml‎

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

0 commit comments

Comments
 (0)