YAshhh29 commented on code in PR #71575:
URL: https://github.com/apache/airflow/pull/71575#discussion_r4234274441


##########
providers/common/ai/src/airflow/providers/common/ai/durable/fingerprint.py:
##########
@@ -123,44 +362,86 @@ def fingerprint_model_request(
     output mode and schema, native tools, ...) so any change to what is sent
     to the model invalidates the cached response.
 
-    Returns ``None`` when the request cannot be serialized; ``None`` compares
-    equal to ``None``, so requests that cannot be fingerprinted degrade to
-    unverified positional replay rather than disabling caching.
+    Returns ``None`` when the request cannot be fingerprinted, which prevents 
the
+    step from being replayed or cached. Model settings, the tool definitions 
in the
+    request parameters and the message history are normally carried into every
+    later request, so such a value in any of them usually degrades every
+    subsequent model step of the run the same way. ``step`` is attached to the
+    warning so the log names where it began.
     """
     try:
+        # Messages and parameters are hashed from pydantic's json-mode dump, 
as they
+        # always were, so stored fingerprints still match. The python-mode 
dumps only
+        # guide ``_order_sets``, which runs where a set or a non-string key 
needs it.
+        messages_guide = ModelMessagesTypeAdapter.dump_python(messages)

Review Comment:
   You're right, that was a regression against main. Both guide dumps now catch 
`TypeError`, and when there is no guide the json-mode dump is hashed on its 
own, unordered, the way main does it. A frozenset of frozen dataclasses or 
models and a dict keyed by a frozen model all give main's digest again.
   
   I ran the tests on pydantic 2.12.0 and 2.13.4, since the dict-key case takes 
a different path on each, and the test compares against main's digest so it 
holds on both. `_render` still raises for tool args, as you said. The comment 
above the dumps is corrected too.
   



##########
providers/common/ai/docs/durable_execution.rst:
##########
@@ -107,6 +107,58 @@ cache:
    never replays responses that belong to a different conversation.
 4. After successful completion, the cached steps are deleted.
 
+Plain JSON arguments and settings are fingerprinted exactly as before. Anything
+else is fingerprinted from pydantic's JSON rendering, so ordinary types that 
are
+not JSON -- a ``datetime`` or ``Decimal`` tool argument, a dataclass in
+``tool_choice``, the bytes in a ``BinaryContent``, a dict keyed by date --
+fingerprint normally. Bytes in tool arguments and settings are rendered as 
base64,
+so binary data that is not valid UTF-8 fingerprints too; bytes inside a 
pydantic
+model follow that model's ``ser_json_bytes`` setting instead, which renders 
them as
+UTF-8 text by default. Because a tool call is fingerprinted from how its 
arguments
+render, a field excluded from serialization and a secret value, which renders
+masked, take no part in it.
+
+A step whose request cannot be fingerprinted is not cached, and on retry it 
runs
+live rather than replaying an unverified entry. That happens when pydantic 
cannot

Review Comment:
   Agreed, "pydantic cannot render" was pointing at the wrong thing. With the 
fallback in, the paragraph now says this only applies to tool arguments and 
settings, where it was never fingerprinted anyway, and that in the message 
history these hash as they always did. It mentions dataclass instances as well 
as models.
   



##########
providers/common/ai/src/airflow/providers/common/ai/durable/fingerprint.py:
##########
@@ -102,19 +159,201 @@ def _strip_volatile(messages_dump: list[dict[str, Any]]) 
-> list[dict[str, Any]]
     return stripped
 
 
+def _check_guide(guide: Any) -> bool:
+    """
+    Refuse an iterator, and report whether the JSON rendering needs 
``_order_sets``.
+
+    ``guide`` is pydantic's python-mode dump of the payload. It has the shape 
of the
+    json-mode rendering that is hashed -- the same keys, aliases, exclusions,
+    computed fields, extras and serializer output -- but keeps sets as sets 
and dict
+    keys as they are, and wraps an iterator in a ``SerializationIterator`` 
without
+    reading it. So this walk runs no user code, and nothing has been consumed 
yet.
+
+    Raises ``TypeError`` if the payload renders through an iterator anywhere.
+    Rendering it to JSON would consume it, and pydantic validates an 
``Iterable[T]``
+    tool parameter lazily into an iterator; a tool return that holds a 
generator
+    would reach the model empty. Returns ``True`` if the guide holds a set, or 
a dict
+    key that is not a string, since only those can make the JSON rendering 
depend on
+    the hash seed or lose a key.
+    """
+    needs_check = False
+    pending = [guide]
+    seen: set[int] = set()
+    while pending:
+        item = pending.pop()
+        if id(item) in seen:
+            # The dump shares this object, or left a cycle in place: walked 
already.
+            continue
+        seen.add(id(item))
+        children: Iterable[Any]
+        if isinstance(item, dict):
+            needs_check = needs_check or any(not isinstance(key, str) for key 
in item)
+            children = item.values()
+        elif isinstance(item, (list, tuple, deque)):
+            children = item
+        elif isinstance(item, (set, frozenset)):
+            needs_check = True
+            children = item
+        elif isinstance(item, Iterator):
+            raise TypeError(f"cannot fingerprint a value that renders through 
a {type(item).__name__}")
+        elif isinstance(item, enum.Enum):
+            # Python mode keeps an Enum member; JSON renders its value.
+            children = (item.value,)
+        else:
+            continue
+        pending.extend(child for child in children if type(child) not in 
_LEAF_TYPES)
+    return needs_check
+
+
+def _json_order(member: Any) -> str:
+    return json.dumps(member, sort_keys=True)
+
+
+def _template(value: Any) -> Any:
+    """
+    Say where the sets are inside one set member, or ``None`` if that cannot 
be said.
+
+    ``_LEAF`` for a member with no set inside it, ``("set", inner)`` for a set 
whose
+    members all have the template ``inner``, and ``("sequence", parts)`` for a 
tuple
+    with a set somewhere in it.
+    """
+    if isinstance(value, enum.Enum):
+        return _template(value.value)
+    if isinstance(value, (set, frozenset)):
+        inner = _member_template(value)
+        return None if inner is None else ("set", inner)
+    if isinstance(value, (list, tuple, deque)):
+        parts = tuple(_template(item) for item in value)
+        if None in parts:
+            return None
+        return _LEAF if all(part == _LEAF for part in parts) else ("sequence", 
parts)
+    if isinstance(value, dict):
+        # No set member dumps to a dict (python mode refuses a set of models), 
so
+        # nothing is known about one.
+        return None
+    return _LEAF
+
+
+def _member_template(members: Iterable[Any]) -> Any:
+    """Return the template every member of a set shares, or ``None`` if they 
differ."""
+    templates = {_template(member) for member in members}
+    if not templates:
+        return _LEAF
+    return templates.pop() if len(templates) == 1 else None
+
+
+def _apply_template(template: Any, rendered: Any) -> Any:
+    """Sort the lists that ``template`` puts a set at, in the rendering of one 
set member."""
+    if template == _LEAF or not isinstance(rendered, list):
+        return rendered
+    kind, inner = template
+    if kind == "set":
+        return sorted((_apply_template(inner, item) for item in rendered), 
key=_json_order)
+    if len(rendered) != len(inner):
+        return rendered
+    return [_apply_template(part, item) for part, item in zip(inner, rendered)]
+
+
+def _order_sets(guide: Any, rendered: Any) -> Any:
+    """
+    Return ``rendered`` with every list that pydantic rendered from a set 
sorted.
+
+    ``rendered`` is the json-mode rendering that is hashed, and ``guide`` the
+    python-mode dump of the same payload (see ``_check_guide``). They are 
walked
+    side by side: a dict or a sequence pairs up by position, because both dumps
+    keep the same order, and a list is sorted where the guide holds a set. A 
set's
+    members cannot pair up by position -- the dump builds a new set, which may
+    iterate in another order -- so the members' shared template says where any 
set
+    inside one of them sits (see ``_template``). Anything that does not line 
up is
+    kept exactly as rendered, so nothing that did not come from a set is 
reordered.
+
+    Raises ``TypeError`` where distinct dict keys render alike, such as ``1`` 
and
+    ``"1"``, or ``None`` and ``nan`` in a message dump: the guide then holds 
more keys
+    than the rendering, and hashing it would let two different payloads share a
+    digest. A dict whose keys are all strings cannot collide, so one that 
renders
+    fewer keys was reshaped by a serializer that applies only in JSON mode, 
and is
+    kept as rendered.
+    """
+    if isinstance(guide, enum.Enum):
+        # An Enum member renders as its value.
+        return _order_sets(guide.value, rendered)
+    if isinstance(guide, (set, frozenset)):
+        if not isinstance(rendered, list) or len(rendered) != len(guide):
+            return rendered
+        template = _member_template(guide)
+        if template is None:
+            return rendered
+        return sorted((_apply_template(template, item) for item in rendered), 
key=_json_order)

Review Comment:
   Took your approach. A list is now sorted only when the set, rendered on its 
own and ordered the same way, gives the same members. Your `tags`/`rows` swap 
gives different digests for `[1, 2]` and `[2, 1]` on both the tool path and the 
history, and matches main's digest.
   
   For the other direction: when a set can't be found (mixed member shapes, a 
renamed set field, a reshaped one), the step is refused if the set's order 
follows the hash seed. If it doesn't, like a set of ints, it is kept as 
rendered so it still matches main.
   
   One limit I couldn't close: a JSON-only serializer that puts a list with 
exactly the set's members in the set's place still gets sorted. I don't see a 
way to tell those apart. The docs now carry the `["z-first", "a"]` caveat.
   



##########
providers/common/ai/src/airflow/providers/common/ai/durable/fingerprint.py:
##########
@@ -102,19 +159,201 @@ def _strip_volatile(messages_dump: list[dict[str, Any]]) 
-> list[dict[str, Any]]
     return stripped
 
 
+def _check_guide(guide: Any) -> bool:
+    """
+    Refuse an iterator, and report whether the JSON rendering needs 
``_order_sets``.
+
+    ``guide`` is pydantic's python-mode dump of the payload. It has the shape 
of the
+    json-mode rendering that is hashed -- the same keys, aliases, exclusions,
+    computed fields, extras and serializer output -- but keeps sets as sets 
and dict
+    keys as they are, and wraps an iterator in a ``SerializationIterator`` 
without
+    reading it. So this walk runs no user code, and nothing has been consumed 
yet.
+
+    Raises ``TypeError`` if the payload renders through an iterator anywhere.
+    Rendering it to JSON would consume it, and pydantic validates an 
``Iterable[T]``
+    tool parameter lazily into an iterator; a tool return that holds a 
generator
+    would reach the model empty. Returns ``True`` if the guide holds a set, or 
a dict
+    key that is not a string, since only those can make the JSON rendering 
depend on
+    the hash seed or lose a key.
+    """
+    needs_check = False
+    pending = [guide]
+    seen: set[int] = set()
+    while pending:
+        item = pending.pop()
+        if id(item) in seen:
+            # The dump shares this object, or left a cycle in place: walked 
already.
+            continue
+        seen.add(id(item))
+        children: Iterable[Any]
+        if isinstance(item, dict):
+            needs_check = needs_check or any(not isinstance(key, str) for key 
in item)
+            children = item.values()
+        elif isinstance(item, (list, tuple, deque)):
+            children = item
+        elif isinstance(item, (set, frozenset)):
+            needs_check = True
+            children = item
+        elif isinstance(item, Iterator):
+            raise TypeError(f"cannot fingerprint a value that renders through 
a {type(item).__name__}")
+        elif isinstance(item, enum.Enum):
+            # Python mode keeps an Enum member; JSON renders its value.
+            children = (item.value,)
+        else:
+            continue
+        pending.extend(child for child in children if type(child) not in 
_LEAF_TYPES)
+    return needs_check
+
+
+def _json_order(member: Any) -> str:
+    return json.dumps(member, sort_keys=True)
+
+
+def _template(value: Any) -> Any:
+    """
+    Say where the sets are inside one set member, or ``None`` if that cannot 
be said.
+
+    ``_LEAF`` for a member with no set inside it, ``("set", inner)`` for a set 
whose
+    members all have the template ``inner``, and ``("sequence", parts)`` for a 
tuple
+    with a set somewhere in it.
+    """
+    if isinstance(value, enum.Enum):
+        return _template(value.value)
+    if isinstance(value, (set, frozenset)):
+        inner = _member_template(value)
+        return None if inner is None else ("set", inner)
+    if isinstance(value, (list, tuple, deque)):
+        parts = tuple(_template(item) for item in value)
+        if None in parts:
+            return None
+        return _LEAF if all(part == _LEAF for part in parts) else ("sequence", 
parts)
+    if isinstance(value, dict):
+        # No set member dumps to a dict (python mode refuses a set of models), 
so
+        # nothing is known about one.
+        return None
+    return _LEAF
+
+
+def _member_template(members: Iterable[Any]) -> Any:
+    """Return the template every member of a set shares, or ``None`` if they 
differ."""
+    templates = {_template(member) for member in members}
+    if not templates:
+        return _LEAF
+    return templates.pop() if len(templates) == 1 else None
+
+
+def _apply_template(template: Any, rendered: Any) -> Any:
+    """Sort the lists that ``template`` puts a set at, in the rendering of one 
set member."""
+    if template == _LEAF or not isinstance(rendered, list):
+        return rendered
+    kind, inner = template
+    if kind == "set":
+        return sorted((_apply_template(inner, item) for item in rendered), 
key=_json_order)
+    if len(rendered) != len(inner):
+        return rendered
+    return [_apply_template(part, item) for part, item in zip(inner, rendered)]
+
+
+def _order_sets(guide: Any, rendered: Any) -> Any:
+    """
+    Return ``rendered`` with every list that pydantic rendered from a set 
sorted.
+
+    ``rendered`` is the json-mode rendering that is hashed, and ``guide`` the
+    python-mode dump of the same payload (see ``_check_guide``). They are 
walked
+    side by side: a dict or a sequence pairs up by position, because both dumps
+    keep the same order, and a list is sorted where the guide holds a set. A 
set's
+    members cannot pair up by position -- the dump builds a new set, which may
+    iterate in another order -- so the members' shared template says where any 
set
+    inside one of them sits (see ``_template``). Anything that does not line 
up is
+    kept exactly as rendered, so nothing that did not come from a set is 
reordered.
+
+    Raises ``TypeError`` where distinct dict keys render alike, such as ``1`` 
and
+    ``"1"``, or ``None`` and ``nan`` in a message dump: the guide then holds 
more keys
+    than the rendering, and hashing it would let two different payloads share a
+    digest. A dict whose keys are all strings cannot collide, so one that 
renders
+    fewer keys was reshaped by a serializer that applies only in JSON mode, 
and is
+    kept as rendered.
+    """
+    if isinstance(guide, enum.Enum):
+        # An Enum member renders as its value.
+        return _order_sets(guide.value, rendered)
+    if isinstance(guide, (set, frozenset)):
+        if not isinstance(rendered, list) or len(rendered) != len(guide):
+            return rendered
+        template = _member_template(guide)
+        if template is None:
+            return rendered
+        return sorted((_apply_template(template, item) for item in rendered), 
key=_json_order)
+    if isinstance(guide, dict):
+        if not isinstance(rendered, dict):
+            return rendered
+        if len(rendered) != len(guide):
+            if len(rendered) < len(guide) and any(not isinstance(key, str) for 
key in guide):
+                raise TypeError("dict keys collide once rendered as JSON")

Review Comment:
   Both fixed the way you suggested. Non-string keys are rendered on their own, 
and it only counts as a collision when that gives fewer keys than the guide. 
The filtered `dict[int, int]` hashes like main again and `{1: "a", "1": "b"}` 
is still refused.
   
   The rendered keys also have to match the guide's keys in order now, so a 
reordered int-keyed dict is no longer paired with its neighbour. Your `{2: 
{"p", "q"}, 1: [...]}` pair is refused, and the same with a set of ints gives 
two different digests. I kept one extra refusal for `nan`/`inf` keys, because 
the message dump renders those as `None` and rendering the keys alone doesn't 
show it. The docstring line you quoted is rewritten.
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to