Fix cache_key collapse of non-pydantic private/__main__ objects - #259
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #259 +/- ##
==========================================
+ Coverage 93.48% 93.53% +0.05%
==========================================
Files 176 176
Lines 20327 20460 +133
Branches 1350 1352 +2
==========================================
+ Hits 19002 19137 +135
Misses 1052 1052
+ Partials 273 271 -2 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
026456a to
d0d241b
Compare
| if (obj_module.startswith("_") or "._" in obj_module) and obj_module != "__main__": | ||
| return ("__internal__", obj_module, type(obj).__qualname__) |
There was a problem hiding this comment.
This treats __main__ as user code and any other underscore as non-user code, but its reasonable we might have something like company._models, etc. We could consider an explicit list of known used interpreter internals, as opposued to the heuristic of _ prefix.
There was a problem hiding this comment.
Good call — replaced the _-prefix heuristic with two explicit curated allowlists, and dug into what actually belongs on them.
I probed the common unpicklable objects. The old failure-path heuristic was already inconsistent: _thread.lock and _abc._abc_data got name-collapsed, but threading.Event, socket.socket, sqlite3.Connection, generators, and weakrefs all fail loud today — purely because their module names don't start with _. So real resources mostly already raise; only two things slipped into name-only.
New behavior:
- Private framework internals (matched by top-level package
pydantic/pydantic_core+ any_-prefixed path component) stay name-only. This is picklable-but-volatile compiled state — validators/serializers. Note this version of pydantic puts the validator inpydantic.plugin._schema_validator, which my first narrow list missed; the package+private-component match now covers it,pydantic._internal.*, andpydantic_core._pydantic_corewhile leaving public modules likepydantic.fieldsalone. - Unpicklable interpreter internals
_abc(the 3.14_abc_datacase) and_thread(lock/RLock — primitives with no semantic identity; collapsing them is correct and avoids a regression for any hashed closure that captures a lock) degrade to a stable name-only token. - Everything else that can't serialize — DB connections, sockets, threads, and crucially a user class in
company._models— now raisesTypeErrorinstead of silently colliding.
Added tests covering each bucket, including the real __pydantic_validator__/__pydantic_serializer__ objects and a company._models case. Full suite green on 3.11.
normalize_token's fallback name-only-tokenized any object whose type module started with "_" or contained "._", silently dropping instance state. Distinct values (e.g. callables authored in __main__ or a private module) therefore collapsed onto one cache key, causing false cache hits and stale results toward the dangerous direction. Make serializability the primary signal instead of the module name: any object cloudpickle can serialize now folds its state into the token, so genuinely-different values stay distinct. The module name is consulted only via two small curated allowlists: - Private submodules of the pydantic framework (e.g. pydantic._internal, pydantic_core._pydantic_core, pydantic.plugin._schema_validator) expose compiled objects that are picklable but carry volatile runtime state; they are keyed by module + qualname only, matching prior behavior. - Unpicklable interpreter internals (_abc._abc_data on Python 3.14, and _thread lock/RLock primitives) degrade to a stable name-only token so behavior hashing does not crash. Every other object that fails to serialize -- including anything in __main__ or a private user package such as company._models -- now raises loudly rather than silently sharing a key. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Pascal Tomecek <pascal.tomecek@cubistsystematic.com>
d0d241b to
4f36d52
Compare
Two different objects could produce the same
cache_key, so the cache could return a result computed for a different input. This fixes that.Cause
The generic fallback in
normalize_token(ccflow/utils/tokenize.py) tokenized any object whose type module started with_or contained._by module and qualname alone, discarding the instance state. This was meant to give a stable token to interpreter internals encountered during behavior hashing.The condition is too broad.
"__main__".startswith("_")isTrue, and private submodules such aspkg._internalcontain._. As a result, any non-pydantic object defined in a notebook, REPL, or private module was reduced to its module and qualname, and distinct values produced identical tokens:pydantic models, functions, methods, and partials have dedicated handlers and were unaffected; only the fallback path was involved.
Change
Decide based on whether the object can be serialized rather than on its module name. If cloudpickle can serialize the object, its state is folded into the token, and the two
Addinstances above tokenize distinctly.The module name is consulted only when serialization fails, to distinguish two cases:
_abc._abc_data(surfaced in ABC class closures on Python 3.14 and not picklable), receive a stable name-only token so behavior hashing does not fail.__main__, or any other object that cannot be serialized, raisesTypeErrorrather than returning a colliding key.A short allowlist keeps pydantic's compiled validators name-only. They are picklable but carry volatile runtime state that should not enter the token.
Because the decision is based on serializability rather than a fixed list of internal modules, it holds across Python 3.11 through 3.14 without further maintenance.
Rationale
I instrumented the original branch and ran the full suite to see what reached it. The only objects were the library's own internal helpers, one of which was a stateful frozen dataclass being name-collapsed in the same way (a latent instance of this bug, now also fixed). The
_abcand pydantic-internal cases are specific to Python 3.14. This is why an allowlist is the wrong approach: it would require ongoing maintenance and would not have covered the library's own modules.Tests and validation
Added
TestPrivateModuleNoStateCollapse, covering:_secret,pkg._internal, and__main__tokenize distinctly and deterministically;__main__raisesTypeError;The full suite passes on Python 3.11 (1336 passed, 2 skipped), the core
normalize_tokenlogic was re-checked on Python 3.12, and ruff passes.