Skip to content

Commit c5bf960

Browse files
fix serialize_template_field handling callable value in dict (#63871) (#67092)
* Fix non-deterministic serialization of non-jsonable objects in template fields * fix logic * fix logic * fix logic * fix logic (cherry picked from commit 9005156) Co-authored-by: Jeongwoo Do <48639483+wjddn279@users.noreply.github.com>
1 parent 5d7c7e7 commit c5bf960

8 files changed

Lines changed: 856 additions & 127 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix DAG version inflation caused by callable values (e.g. lambdas) in templated fields by normalizing callables to their qualname and sorting dict keys deterministically.

airflow-core/src/airflow/serialization/helpers.py

Lines changed: 66 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -19,87 +19,96 @@
1919
from __future__ import annotations
2020

2121
import contextlib
22+
import inspect
2223
from typing import TYPE_CHECKING, Any
2324

2425
from airflow._shared.module_loading import qualname
2526
from airflow._shared.secrets_masker import redact
2627
from airflow._shared.template_rendering import truncate_rendered_value
2728
from airflow.configuration import conf
28-
from airflow.settings import json
2929

3030
if TYPE_CHECKING:
3131
from airflow.partition_mappers.base import PartitionMapper
3232
from airflow.timetables.base import Timetable as CoreTimetable
3333

3434

35-
def serialize_template_field(template_field: Any, name: str) -> str | dict | list | int | float:
35+
def serialize_template_field(template_field: Any, name: str) -> str | dict | list | int | float | bool | None:
3636
"""
3737
Return a serializable representation of the templated field.
3838
39-
If ``templated_field`` is provided via a callable then
40-
return the following serialized value: ``<callable full_qualified_name>``
39+
The walk has two responsibilities:
4140
42-
If ``templated_field`` contains a class or instance that requires recursive
43-
templating, store them as strings. Otherwise simply return the field as-is.
41+
1. **Make the template_field JSON-encodable** — every container is rebuilt
42+
with primitive leaves (str/int/float/bool/None), tuples and sets are
43+
flattened to lists, and unsupported objects fall through to ``str()``
44+
so ``json.dumps`` never raises on the result.
45+
2. **Keep the output deterministic across parses** — callables are replaced
46+
with their qualified name (never the default ``<function ... at 0x...>``
47+
repr), dicts are key-sorted, and (frozen)sets are sorted by element so
48+
the same input always produces the same string.
4449
"""
4550

46-
def is_jsonable(x):
47-
try:
48-
json.dumps(x)
49-
except (TypeError, OverflowError):
50-
return False
51-
else:
52-
return True
53-
54-
def translate_tuples_to_lists(obj: Any):
55-
"""Recursively convert tuples to lists."""
56-
if isinstance(obj, tuple):
57-
return [translate_tuples_to_lists(item) for item in obj]
58-
if isinstance(obj, list):
59-
return [translate_tuples_to_lists(item) for item in obj]
60-
if isinstance(obj, dict):
61-
return {key: translate_tuples_to_lists(value) for key, value in obj.items()}
62-
return obj
51+
def normalize_dict_key(key) -> str:
52+
"""Normalize a dict key to a serialized string type."""
53+
# Serialized template_field keys must all be strings, not a mix of types, so that
54+
# downstream json.dumps(..., sort_keys=True) does not raise on mixed-type keys.
55+
return str(serialize_object(key))
56+
57+
def serialize_object(obj):
58+
"""Recursively rewrite ``obj`` into a JSON-encodable, hash-stable structure."""
59+
if obj is None or isinstance(obj, (str, int, float, bool)):
60+
return obj
6361

64-
def sort_dict_recursively(obj: Any) -> Any:
65-
"""Recursively sort dictionaries to ensure consistent ordering."""
6662
if isinstance(obj, dict):
67-
return {k: sort_dict_recursively(v) for k, v in sorted(obj.items())}
68-
if isinstance(obj, list):
69-
return [sort_dict_recursively(item) for item in obj]
70-
if isinstance(obj, tuple):
71-
return tuple(sort_dict_recursively(item) for item in obj)
72-
return obj
63+
# Serialize keys/values first so each key is a string and the output is hash-stable,
64+
# then sort by the serialized key to prevent hash inconsistencies when dict ordering varies.
65+
serialized_pairs = [(normalize_dict_key(k), serialize_object(v)) for k, v in obj.items()]
66+
return dict(sorted(serialized_pairs, key=lambda kv: kv[0]))
67+
68+
if isinstance(obj, (list, tuple)):
69+
return [serialize_object(item) for item in obj]
70+
71+
if isinstance(obj, (set, frozenset)):
72+
# JSON has no set type → flatten to a list with deterministic ordering
73+
# so hash randomization on element types cannot shift cross-process iteration order.
74+
serialized_set = [serialize_object(e) for e in obj]
75+
return sorted(serialized_set, key=lambda x: (type(x).__name__, str(x)))
76+
77+
# Use inspect.getattr_static to bypass any custom __getattr__ / metaclass magic
78+
if callable(inspect.getattr_static(obj, "serialize", None)):
79+
return serialize_object(obj.serialize())
80+
81+
# Kubernetes client objects (V1Pod, V1Container, ...) expose their content via to_dict().
82+
# Scope the branch to the kubernetes namespace so unrelated user classes that happen to
83+
# define a to_dict() method fall through to str() instead of being treated as K8s payloads.
84+
if getattr(type(obj), "__module__", "").startswith(
85+
("kubernetes.", "kubernetes_asyncio.")
86+
) and callable(inspect.getattr_static(obj, "to_dict", None)):
87+
return serialize_object(obj.to_dict())
88+
89+
if callable(obj):
90+
# Use qualified name; default repr embeds memory addresses, which would change the DAG hash on every parse
91+
return f"<callable {qualname(obj, True)}>"
92+
93+
# A custom __str__ or __repr__ is treated as an intentional textual representation
94+
# supplied by the author and used as-is.
95+
if type(obj).__str__ is not object.__str__ or type(obj).__repr__ is not object.__repr__:
96+
return str(obj)
97+
98+
# Otherwise fall back to a qualname marker. The default object repr is
99+
# `<ClassName object at 0x...>`, which embeds a memory address that flips per process
100+
# and would break DAG hash stability — use the class qualname instead.
101+
return f"<{qualname(type(obj), True)} object>"
73102

74103
max_length = conf.getint("core", "max_templated_field_length")
75104

76-
if not is_jsonable(template_field):
77-
try:
78-
serialized = template_field.serialize()
79-
except AttributeError:
80-
if callable(template_field):
81-
full_qualified_name = qualname(template_field, True)
82-
serialized = f"<callable {full_qualified_name}>"
83-
else:
84-
serialized = str(template_field)
85-
if len(serialized) > max_length:
86-
rendered = redact(serialized, name)
87-
return truncate_rendered_value(str(rendered), max_length)
88-
return serialized
89-
if not template_field and not isinstance(template_field, tuple):
90-
# Avoid unnecessary serialization steps for empty fields unless they are tuples
91-
# and need to be converted to lists
92-
return template_field
93-
template_field = translate_tuples_to_lists(template_field)
94-
# Sort dictionaries recursively to ensure consistent string representation
95-
# This prevents hash inconsistencies when dict ordering varies
96-
if isinstance(template_field, dict):
97-
template_field = sort_dict_recursively(template_field)
98-
serialized = str(template_field)
99-
if len(serialized) > max_length:
100-
rendered = redact(serialized, name)
105+
serialized = serialize_object(template_field)
106+
107+
if len(str(serialized)) > max_length:
108+
rendered = redact(str(serialized), name)
101109
return truncate_rendered_value(str(rendered), max_length)
102-
return template_field
110+
111+
return serialized
103112

104113

105114
class TimetableNotRegistered(ValueError):
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
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+
from __future__ import annotations
18+
19+
from datetime import datetime
20+
21+
from airflow.sdk import dag, task, task_group
22+
23+
24+
@dag(
25+
dag_id="TEST_DTM",
26+
dag_display_name="TEST DTM",
27+
schedule=None,
28+
default_args={"owner": "airflow", "email": ""},
29+
start_date=datetime(2024, 1, 25),
30+
)
31+
def dtm_test(
32+
exponent: int = 2,
33+
):
34+
35+
@task
36+
def get_data():
37+
return [20, 100, 200, 222, 242, 272]
38+
39+
@task
40+
def to_exp(number: int, exponent: int) -> float:
41+
return number**exponent
42+
43+
@task
44+
def trunc(number: float, digits: int) -> float:
45+
return round(number / 22, digits)
46+
47+
@task
48+
def save(number: list[float]):
49+
for n in number:
50+
print(f"Got number: {n}")
51+
52+
@task_group # type: ignore[type-var]
53+
def transform(number: int, exponent: int) -> float:
54+
a = to_exp(number, exponent)
55+
b = trunc(a, 2)
56+
return b
57+
58+
data = get_data()
59+
result = transform.partial(exponent=exponent).expand(number=data)
60+
save(result) # type: ignore[arg-type]
61+
62+
63+
instance = dtm_test()

airflow-core/tests/unit/models/test_renderedtifields.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,11 +116,11 @@ def teardown_method(self):
116116
pytest.param([], [], id="list"),
117117
pytest.param({}, {}, id="empty_dict"),
118118
pytest.param((), [], id="empty_tuple"),
119-
pytest.param(set(), "set()", id="empty_set"),
119+
pytest.param(set(), [], id="empty_set"),
120120
pytest.param("test-string", "test-string", id="string"),
121121
pytest.param({"foo": "bar"}, {"foo": "bar"}, id="dict"),
122122
pytest.param(("foo", "bar"), ["foo", "bar"], id="tuple"),
123-
pytest.param({"foo"}, "{'foo'}", id="set"),
123+
pytest.param({"foo"}, ["foo"], id="set"),
124124
(date(2018, 12, 6), "2018-12-06"),
125125
pytest.param(datetime(2018, 12, 6, 10, 55), "2018-12-06 10:55:00+00:00", id="datetime"),
126126
pytest.param(

airflow-core/tests/unit/serialization/test_dag_serialization.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@
8282
from airflow.serialization.serialized_objects import (
8383
BaseSerialization,
8484
DagSerialization,
85+
LazyDeserializedDAG,
8586
OperatorSerialization,
8687
_XComRef,
8788
)
@@ -114,6 +115,7 @@
114115
cron_timetable,
115116
delta_timetable,
116117
)
118+
from unit.models import TEST_DAGS_FOLDER
117119

118120
if TYPE_CHECKING:
119121
from airflow.sdk.definitions.context import Context
@@ -702,6 +704,43 @@ def test_deserialization_across_process(self):
702704
for dag_id in stringified_dags:
703705
self.validate_deserialized_dag(stringified_dags[dag_id], dags[dag_id])
704706

707+
@pytest.mark.db_test
708+
@conf_vars({("core", "load_examples"): "false"})
709+
def test_reserialize_should_make_equal_hash_with_dag_processor(self):
710+
dagbag1 = DagBag(TEST_DAGS_FOLDER / "test_dag_decorator_version.py")
711+
hash_result1 = LazyDeserializedDAG.from_dag(next(iter(dagbag1.dags.values()))).hash
712+
713+
dagbag2 = DagBag(TEST_DAGS_FOLDER / "test_dag_decorator_version.py")
714+
hash_result2 = LazyDeserializedDAG.from_dag(next(iter(dagbag2.dags.values()))).hash
715+
716+
assert hash_result1 == hash_result2
717+
718+
@pytest.mark.db_test
719+
@conf_vars({("core", "load_examples"): "false"})
720+
def test_hash_succeeds_for_dag_with_mixed_primitive_key_template_field(self):
721+
"""SerializedDagModel.hash() must not raise on a template field whose dict has mixed-type primitive keys.
722+
723+
Building the Dag twice via ``create_dag()`` produces independent Dag and
724+
operator instances, so the hashes must also be equal across calls —
725+
otherwise the serialization path is leaking non-deterministic state
726+
(memory addresses, dict ordering, etc.) into the hash.
727+
"""
728+
from airflow.providers.standard.operators.python import PythonOperator
729+
730+
def create_dag():
731+
with DAG(dag_id="dag_mixed_keys", schedule=None, start_date=datetime(2024, 1, 1)) as dag:
732+
PythonOperator(
733+
task_id="op",
734+
python_callable=empty_function,
735+
op_kwargs={"data": {1: "a", "b": "c", None: "z", 2: "d"}, empty_function: "t"},
736+
)
737+
return dag
738+
739+
first_hash = LazyDeserializedDAG.from_dag(create_dag()).hash
740+
second_hash = LazyDeserializedDAG.from_dag(create_dag()).hash
741+
742+
assert first_hash == second_hash
743+
705744
@skip_if_force_lowest_dependencies_marker
706745
@pytest.mark.db_test
707746
def test_roundtrip_provider_example_dags(self):

0 commit comments

Comments
 (0)