Skip to content

fix(utilities): make hash_objects type-preserving so tuple/list inputs do not collide - #23819

Closed
Shahzeb-DataEngineer wants to merge 6 commits into
PrefectHQ:mainfrom
Shahzeb-DataEngineer:fix/hash-objects-type-preserving
Closed

Shahzeb-DataEngineer wants to merge 6 commits into
PrefectHQ:mainfrom
Shahzeb-DataEngineer:fix/hash-objects-type-preserving

Conversation

@Shahzeb-DataEngineer

Copy link
Copy Markdown

closes #23817

This PR makes the JSON path of hash_objects type-preserving, so task inputs that differ only by container or dict-key type no longer hash identically and serve stale cached results.

Problem

hash_objects tries JSON first and only falls back to cloudpickle when JSON raises. JSON erases two type distinctions: tuples serialize exactly like lists, and int dict keys are coerced to strings. Two different task inputs therefore hashed identically, and the second call was served the first call’s cached result. Reproduced on main: hash_objects({"x": (1, 2, 3)}) == hash_objects({"x": [1, 2, 3]}), and a task returning type(x).__name__ with cache_key_fn=task_input_hash returned tuple (Cached) for a later [1, 2, 3] call. Affected paths are the opt-in caching paths that hash inputs with hash_objects (task_input_hash, the Inputs cache policy); a plain @task with no cache args is unaffected.

Approach

Values are encoded into a type-tagged JSON structure before serializing: tuples hash differently from lists, int dict keys differently from str keys, and set/frozenset distinctly from lists, while equal inputs keep equal hashes and dict/set ordering still does not affect the hash. Values the encoding cannot represent (bytes, datetimes, arbitrary objects) pass through to the existing JSON object encoder (or the cloudpickle fallback) exactly as before. Alternative considered: hashing with cloudpickle first. That would change the hash of every input and give up the JSON path’s cross-process stability, so the minimal type-tagging change was preferred.

Tradeoff: this changes the hash values themselves, invalidating cache keys computed by earlier versions. Cache keys are not a stable API, but it is worth calling out.

The related JSONSerializer type coercion (int keys to str keys, tuples to lists for opt-in result_serializer="json" users) is mentioned in the issue as the same root cause but is not changed here, since its docstring documents stdlib-JSON-compatible inputs.

Test plan
  • New unit tests in tests/utilities/test_hashing.py: tuple vs list (incl. nested), int vs str dict keys, dict-key-order insensitivity, set-order insensitivity, equal-inputs equality. The two collision tests fail on unmodified main (identical hashes) and pass with this change.
  • New task-level regression test in tests/test_tasks.py: task_input_hash task called with a tuple then a list returns tuple then list, both Completed.
  • pytest tests/utilities/test_hashing.py tests/test_cache_policies.py: 78 passed. The 3 neighboring task_input_hash tests in tests/test_tasks.py pass.
  • ruff check on the touched files: identical findings to unmodified main (76 pre-existing, 0 new). ruff format --check clean.

…s do not collide

hash_objects tried JSON first and only fell back to cloudpickle on
failure. JSON erases the tuple/list distinction and coerces int dict
keys to strings, so different task inputs hashed identically and the
second call was served the first call's cached result (stale results
via task_input_hash and the Inputs cache policy).

Encode values into a type-tagged JSON structure before serializing:
tuples hash differently from lists, int dict keys differently from
str keys, and set/frozenset distinctly from lists, while equal inputs
keep equal hashes and dict/set ordering still does not affect the
hash. Values the encoding cannot represent are passed through to the
existing JSON object encoder (or the cloudpickle fallback) as before.

Note: this changes the hash values themselves, invalidating cache
keys computed by earlier versions.

Closes PrefectHQ#23817

Signed-off-by: Shahzeb Ali <[email protected]>
Copilot AI balanced review requested due to automatic review settings November 9, 2026 05:21
@github-actions github-actions Bot added the bug Something isn't working label Nov 9, 2026
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 9, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-11T09:35:18.117885Z f9f9c9c New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c8b7cd34c8

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/prefect/utilities/hashing.py Outdated
serializer = JSONSerializer(dumps_kwargs={"sort_keys": True})
return stable_hash(serializer.dumps((args, kwargs)), hash_algo=hash_algo)
return stable_hash(
serializer.dumps(_encode_for_hashing((args, kwargs))),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve persistent block schema checksums

When Prefect is upgraded while an unversioned custom Block schema is unchanged, this unconditional re-encoding changes its checksum because Block._calculate_schema_checksum() calls hash_objects on the JSON-schema dictionary (src/prefect/blocks/core.py:573). The next save or registration therefore misses the persisted checksum and creates another schema (core.py:1578-1589), changing the block document's schema identity despite no schema change; preserve the legacy encoding for unambiguous inputs or decouple cache-input hashing from persistent schema checksums.

AGENTS.md reference: src/prefect/AGENTS.md:L5-L7

Useful? React with 👍 / 👎.

hash_objects is now type-preserving, so the checksums computed
from block schemas changed. Update the hardcoded expected values
to the new checksums (verified identical across all CI jobs).

Signed-off-by: Shahzeb Ali <[email protected]>
The type-preserving encoding re-hashed every input, which changed
persisted block schema checksums: on upgrade, an unchanged unversioned
custom Block schema would get a new checksum and the next save would
create a duplicate schema instead of matching the persisted one
(Codex review on PrefectHQ#23819).

Only apply the type-tagged encoding when the input actually contains
an ambiguity plain JSON would erase (tuples, sets, non-string dict
keys). Everything else hashes exactly as before, so existing block
schema checksums are unchanged and the checksum expectations in
tests/blocks/test_core.py and
tests/server/models/test_block_schemas.py revert to their original
values.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4aca565c34

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

return False
_seen.add(id(obj))
return any(_contains_ambiguous_type(item, _seen) for item in obj)
return False

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve container types nested inside Pydantic inputs

When a cached task receives a Pydantic model whose Any or union field holds a tuple in one call and a list in another, this fallback reports no ambiguity and selects the legacy JSON path. prefect_json_object_encoder serializes the model through model_dump(mode="json"), which normalizes both field values to the same JSON array, so task_input_hash still gives both calls the same cache key and the later task can return a stale result; the ambiguity check and tagged encoder need to examine serializer-supported wrappers rather than treating every non-builtin container as unambiguous.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, thanks. Fixed in 179f4b7: the ambiguity scan and the tagged encoder now look inside Pydantic models (via model_dump(mode="python")) and dataclasses (via asdict), so a tuple nested in a model field hashes differently from a list. Added regression tests in tests/utilities/test_hashing.py. Verified locally that unambiguous inputs (including block schema dicts) still hash exactly as before.

…sh inputs

Codex review on PrefectHQ#23819 noted the ambiguity scan missed tuples hidden
inside Pydantic models: the JSON serializer dumps models with
model_dump(mode="json"), which normalizes tuple and list fields to the
same array, so the collision survived inside the wrapper. Scan and encode
models (and dataclasses, which the serializer flattens the same way)
through their python-mode dump, which preserves container types.
Unambiguous inputs still take the legacy path, so persistent hashes such
as block schema checksums are unchanged.

Signed-off-by: Shahzeb Ali <[email protected]>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 179f4b7307

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/prefect/utilities/hashing.py Outdated
Comment on lines +129 to +132
dumped = obj.model_dump(mode="python")
except Exception:
return False
return _contains_ambiguous_type(dumped, _seen)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep temporary model dumps alive during the scan

On CPython, when one argument is a list or dictionary with multiple Pydantic models and only a later sibling contains the tuple/list distinction, the first sibling's dumped dictionary is freed after this recursive call while its ID remains in _seen; the next model_dump() reuses that ID and is treated as already visited. The scan then selects legacy JSON for both variants, where tuple and list fields serialize identically, so task_input_hash can still serve the stale cached result (the dataclasses.asdict branch below has the same lifetime bug). Keep the projected objects alive or use an active recursion stack.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, thanks. Fixed in 6c3e3ac: the scan now keeps every model/dataclass dump alive in a _keepalive list for the whole traversal, so a later sibling model's dump can no longer reuse a freed dump's id and get skipped as already visited. Added a regression test with the tuple in a later sibling model.

Comment thread src/prefect/utilities/hashing.py Outdated
if isinstance(obj, (set, frozenset)):
items = sorted(
(_encode_for_hashing(item) for item in obj),
key=lambda item: json.dumps(item, sort_keys=True, default=str),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Break ties with the final serialized set item

When a set contains two encoder-supported objects whose str() forms are equal but whose final JSON differs—for example, same-valued members named Color.RED from two different plain Enum classes, which also have equal hashes—these sort keys tie. On CPython, building the equal set in opposite insertion orders then preserves the opposite item order, and prefect_json_object_encoder includes each enum's qualified class, so hash_objects returns different hashes for equal sets and cache keys become insertion-order dependent. Use the same class-aware serialized form as a tiebreaker instead of default=str.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, thanks. Fixed in 6c3e3ac: set items and dict entries now sort by (json form, qualified class name), so same-named enum members from different classes no longer tie on str() and equal sets hash the same regardless of insertion order. Added a regression test.

@codspeed

codspeed Bot commented Nov 9, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

1 warning

⚠️ 4 benchmarks are dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

✅ 8 untouched benchmarks
⏩ 1 skipped benchmark1


Comparing Shahzeb-DataEngineer:fix/hash-objects-type-preserving (f9f9c9c) with main (bc0ff4b)

Open in CodSpeed

Footnotes

  1. 1 benchmark was skipped, so the baseline result was used instead. If it was deleted from the codebase, click here and archive it to remove it from the performance reports. ↩

…t ties

Codex review on PrefectHQ#23819 found two follow-ups in the type-preserving
hashing change. First, the ambiguity scan records the ids of temporary
model dumps in `_seen`, but once a dump was freed a later sibling
model's dump could reuse its id and be skipped as already visited, so
a tuple hidden in a later sibling model was missed and both inputs
hashed identically. Keep every dump alive until the top-level scan
finishes. Second, the set sort key tied for values whose `str()` form
is the same while their serialized form differs, for example members
named `Color.RED` from two different `Enum` classes, which also hash
equally; `sorted` then kept insertion order and equal sets hashed
differently. Break ties with the qualified class name.

Adds regression tests for both cases.

Signed-off-by: Shahzeb Ali <[email protected]>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6c3e3acafb

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

return {
"__prefect_type__": "pydantic",
"__class__": to_qualified_name(obj.__class__),
"data": _encode_for_hashing(obj.model_dump(mode="python")),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve JSON-only serializers when tagging models

Whenever a model contains an ambiguous value and another field relies on @field_serializer(..., when_used="json"), this Python-mode projection skips that field serializer. For example, a tuple field plus a _thread.lock field serialized to a string in JSON mode used to hash successfully through prefect_json_object_encoder, but the new payload contains the raw lock; JSON then raises and cloudpickle cannot pickle it, so hash_objects(..., raise_on_failure=True) now aborts task caching. Build the tagged projection without bypassing the model's JSON-only serializers.

AGENTS.md reference: src/prefect/AGENTS.md:L5-L7

Useful? React with 👍 / 👎.

return False
_seen.add(id(obj))
for key, value in obj.items():
if not isinstance(key, str):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Tag string-subclass dictionary keys

When a dictionary key is a StrEnum or another str subclass, this check classifies it as an ordinary string and selects the legacy payload, so hash_objects({Key.X: 1}) still collides with hash_objects({"x": 1}); a cached task that inspects the retained key type can therefore receive the stale result. Even if the scan selected tagging, _encode_for_hashing uses the same isinstance(obj, str) logic and erases the subclass, so the scan and encoder need exact built-in checks plus class-aware handling for subclasses.

Useful? React with 👍 / 👎.

@ToniAdreal ToniAdreal left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I verified this against the PR head (6c3e3ac) locally: installed the PR
build and ran the reporter's three collision cases from #23817 through the
real hash_objects — tuple vs list, int vs str dict keys, and the nested
case all hash differently now, while equal inputs still hash equally and
dict/set ordering is still ignored. The pickle fallback is untouched
(a non-JSON object hashes identically to a direct cloudpickle.dumps of
the same input).

I also checked the two things that changed since the earlier heads. First,
legacy preservation holds: plain str/dict/list/bytes hashes are identical
to main, and an unambiguous pydantic model's hash is byte-identical between
a main build and this PR build (I ran the same probe in both). The list side
of each old collision keeps the main hash and only the ambiguous side moves,
so no persisted hash churn. Second, the model/dataclass case from the Codex
review works: a pydantic model holding a tuple hashes differently from the
same model holding a list, including nested inside a dict, and the same for
dataclasses.

The two follow-ups from the second Codex review are also fixed in this
head, and I checked both locally. A tuple hidden inside a later sibling
model is now distinguished (I ran the new regression case through the real
hash_objects in both list and dict forms — on the previous head the same
probe hashed them identically, so this is a real fix, not a vacuous test).
And sets of same-named enum members from different modules no longer depend
on insertion order, while members from different enum classes still hash
differently from each other.

One CI note, for the record rather than as a blocker: the six Server Tests
jobs fail on this head, but I read the py3.14/sqlite job log and it is 3,473
passed with exactly one failure — a TimeZoneNotFoundError: time zone ID 'US/Pacific' not found in the daylight-saving schedule test, which looks
like a runner tzdata gap rather than anything this PR touches. The same six
jobs failed identically on every head of this PR.

One question: the tagged encoding now only triggers when the ambiguity scan
finds something, so a block schema or other persisted hash containing an
ambiguous input (say a tuple inside a schema default) would still move on
upgrade. The three pinned checksums from the earlier head are gone from the
diff, which matches the legacy-preservation story — but did you check whether
any persisted hash in the repo covers an ambiguous input? I grepped the test
tree and only found the stable_hash pins in test_hashing.py, which this
does not touch — a second pair of eyes on that would be good before merge.

The end-to-end task test I did not run locally (needs the full engine
harness); CI is green on it so far.

Assisted-by: Muse Spark:1.3

@MateehUllah MateehUllah left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tagged serializer calls dataclasses.asdict(obj) before hashing dataclass fields. asdict deep-copies non-dataclass leaf objects, which can fail on resources such as locks and may alter identity-sensitive values, even if the legacy JSON serializer or cloudpickle would otherwise handle the original object. Could you traverse dataclasses.fields() directly (without deep-copying arbitrary field values), retaining the desired tuple/list distinction, and test a dataclass holding a non-deepcopyable value?

@ToniAdreal ToniAdreal left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked @MateehUllah's asdict point locally against this head (6c3e3ac) — the mechanism is real, but the blast radius is narrower than it looks:

  • _encode_for_hashing does call dataclasses.asdict(obj) unguarded (hashing.py:105): on a dataclass holding a threading.Lock it raises TypeError: cannot pickle '_thread.lock' object. The scan's asdict (line 170) is already wrapped in try/except, so a lock-holding dataclass on its own just takes the legacy path.
  • It only bites when an ambiguous sibling triggers the tagged path, e.g. {"t": (1, 2), "d": <dataclass holding a lock>}. Then the encoder raises inside the JSON attempt and hash_objects falls back to cloudpickle, which also cannot pickle a lock, so the call returns None (or raises HashError with raise_on_failure=True, with both errors naming the lock).
  • Control on main: the same three inputs also return None — the legacy JSON path fails on the lock the same way — so this is not a regression vs main, and the tuple/list fix still holds for every deepcopyable input I checked.

So I read this as hardening rather than a blocker: traversing fields() directly would keep one non-deepcopyable leaf from killing the tagged encoding for its siblings. A regression test with a dataclass holding a lock next to a tuple would pin it.

Assisted-by: Muse Spark:1.3

…ash scan and encoder

dataclasses.asdict deep-copies field values, so a dataclass holding a
non-deepcopyable value (a lock or a file) raised inside the tagged
encoder when a sibling tuple triggered the tagged path, and the scan
missed the ambiguity inside such dataclasses. Read fields directly
with dataclasses.fields so values are never deep-copied; a
non-deepcopyable leaf no longer kills the tagged encoding for its
siblings. A lock still cannot be hashed (neither the JSON encoder nor
cloudpickle can represent it), matching main.

Signed-off-by: Shahzeb Ali <[email protected]>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f9f9c9c6de

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

return False
_seen.add(id(obj))
return any(_contains_ambiguous_type(item, _seen, _keepalive) for item in obj)
return False

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Traverse typed deques before selecting legacy JSON

When a cached task receives a Pydantic model with a deque[Any] field whose element is a tuple in one call and a list in another, model_dump(mode="python") leaves the deque intact, but this scan has no deque/serializer-supported-container branch and returns False; the legacy model_dump(mode="json") then normalizes both to arrays, so task_input_hash can still serve the tuple call's cached result for the list call. Fresh evidence after the claimed model fix is Pydantic's typed-deque split: Python mode returns deque([(1, 2)]) while JSON mode returns [[1, 2]], and the current code stops at this return; recurse through and tag these containers too.

Useful? React with 👍 / 👎.

Comment on lines +32 to +33
json.dumps(item, sort_keys=True, default=str),
to_qualified_name(type(item)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use the final serialized value to break sort ties

When a set or mapping contains two encoder-supported values of the same class whose str() forms match but serialized data differs (for example, two members of one Enum that overrides __str__ to a constant and __hash__ to collide), both fields of this key are identical. sorted therefore preserves opposite insertion orders, and final JSON encodes the enum values differently, so equal sets or mappings produce different cache keys. Fresh evidence beyond the earlier fixed two-class example is that the single enum class makes the new qualified-class tiebreaker tie as well; use the same final serializer output as a tiebreaker.

Useful? React with 👍 / 👎.

@Shahzeb-DataEngineer

Copy link
Copy Markdown
Author

Good catch, checked it. There are no pinned hash_objects values in the test tree: the pins in test_hashing.py are stable_hash pins, which this PR does not touch. The only persisted hash through hash_objects is the block schema checksum, and it hashes model_json_schema(), which is JSON-native by construction: pydantic serializes tuple defaults to JSON arrays, so no block schema dict contains a tuple, int key or set, and the ambiguity scan never triggers there. The two pinned checksums in tests/blocks/test_core.py are plain str/int schemas and CI passes them on this head. The other callers produce transient keys (task and flow cache keys, the server memo hash, integration caches), where a move would only invalidate a cache. The server memo hash goes through the pickle fallback for the block registry, which this PR does not touch.

@Shahzeb-DataEngineer

Copy link
Copy Markdown
Author

Thanks both. Addressed in the latest push: dataclasses are now read field by field with dataclasses.fields() in both _encode_for_hashing and _contains_ambiguous_type, instead of dataclasses.asdict(), so field values are never deep-copied. Added regression tests with a dataclass holding a non-deepcopyable value (an open file): the tuple/list distinction inside the dataclass is preserved, and a file-holding dataclass next to a tuple now hashes instead of returning None. One boundary: a dataclass holding a threading.Lock still returns None, since neither the JSON encoder nor cloudpickle can represent a lock; I verified the same inputs returned None before this change too, so that is not a regression. All 27 tests in tests/utilities/test_hashing.py pass locally.

@ToniAdreal ToniAdreal left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked the new head (f9f9c9c) and your two comments against it — this implements the suggestion exactly. Both call sites now read dataclass fields directly (_encode_for_hashing and the scan in _contains_ambiguous_type), with the docstrings updated to say why, and the three new regression tests pin the cases from the discussion. I ran the file locally on this head: all 27 tests in tests/utilities/test_hashing.py pass, and a base-vs-head control run confirms the behavior change — a dataclass holding a file next to a tuple now distinguishes tuple from list (on the base both hashed identically), while the lock-holding dataclass still returns None, same as on main, so no regression there. I also spot-checked the pinned-hash point: the literal pins in test_hashing.py are all stable_hash, and the block schema checksum hashes model_json_schema(), so that side is clear.

One thing worth a look before merge: Pre-commit checks is failing on this PR, and I traced it to the repo's no-double-backticks hook — it flags five lines in hashing.py (:22, :24, :25 from the earlier commits, plus :59 and :150, the two new dataclasses.asdict docstring literals in this commit). Single backticks there should clear it; ruff and codespell are clean on both changed files.

Nothing further from me on the hashing itself; the rest is the maintainer's call on the PR.

Assisted-by: Muse Spark:1.3

@desertaxle desertaxle left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you address the model serializer regression, the deque case, and the remaining sort ties before we merge? The failing pre-commit check also needs to be fixed.

@Shahzeb-DataEngineer

Copy link
Copy Markdown
Author

Closing this as superseded: the fix for #23817 landed in #23818, which was merged. Thanks for the reviews here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

hash_objects erases tuple/list and int/str-key distinctions, so task_input_hash serves stale cached results

5 participants