Skip to content

perf(pydantic): reuse TypeAdapters instead of building one per payload - #1724

Closed
PranavMishra28 wants to merge 1 commit into
temporalio:mainfrom
PranavMishra28:fix/cache-pydantic-type-adapters
Closed

perf(pydantic): reuse TypeAdapters instead of building one per payload#1724
PranavMishra28 wants to merge 1 commit into
temporalio:mainfrom
PranavMishra28:fix/cache-pydantic-type-adapters

Conversation

@PranavMishra28

Copy link
Copy Markdown

Closes #1695.

@tconley1428 you said on the issue that a small LRU cache by default seemed reasonable and asked whether there's any drawback to enabling it by default. This implements that, and the two drawbacks I could find are handled below rather than left implicit.

Problem

PydanticJSONPlainPayloadConverter.from_payload called TypeAdapter(hint) on every payload. Pydantic caches the core schema on the class for BaseModel subclasses, so that's cheap for plain models — but activity and workflow hints frequently aren't classes (Annotated discriminated unions, list[Union[...]], generics), and for those TypeAdapter.__init__ rebuilds the schema every time.

Reproduced @ZacharyHampton's shape before touching anything:

build+validate per payload: 0.1375 ms
validate only:              0.0015 ms
ratio:                      92x

(Issue reported 57× on their box and 521× for pydantic-ai's CallToolResult; same effect, machine-dependent multiple.)

Measured through the converter after the change: 0.0058 ms per call, 199 cache hits to 1 miss.

The two drawbacks, and what this does about them

  1. The cache holds type hints strongly. So it's bounded — maxsize=256. A worker sees a small fixed set of activity and workflow signatures, so that covers real usage while stopping a pathological caller from retaining unbounded types. Happy to change the number or expose it if you'd rather it be configurable.

  2. Not every hint is hashable, so not every hint can be a cache key — Annotated[list[X], SomeUnhashableMetadata()] raises TypeError from lru_cache. That falls back to per-call construction instead of failing the conversion, with a test covering it. This is the case I'd most want a second opinion on, since it's the one that would turn a working conversion into an error if handled naively.

Caching is keyed on the hint alone, which is safe here because to_json_options only affects serialization, not from_payload.

How tested

Five cases in tests/contrib/pydantic/test_pydantic.py:

  • test_repeated_conversion_builds_one_type_adapter — black-box: counts TypeAdapter constructions across two conversions of one hint. Against the unfixed converter it reports built 2 TypeAdapters for one hint. This is the one that pins behavior; the others below assert on cache internals.
  • reuse across payloads (1 miss, 4 hits), distinct hints not sharing an adapter, the unhashable-hint fallback, and the type_hint is NoneAny path.

tests/contrib/pydantic/ 21 passed. ruff check, ruff format --check, and mypy clean on the changed files.

One note on scope: I did not touch to_payload. SchemaSerializer construction there is already hoisted into __init__, so it doesn't have this problem.

Developed with Claude Code; reviewed and tested by Pranav before marking ready for review.

Closes temporalio#1695.

`PydanticJSONPlainPayloadConverter.from_payload` called `TypeAdapter(hint)` on
every payload. Pydantic caches the core schema on the class for `BaseModel`
subclasses, so that is cheap for plain models, but activity and workflow hints
are frequently not classes — `Annotated` discriminated unions, `list[Union[...]]`,
generics — and for those `TypeAdapter.__init__` rebuilds the schema each time and
construction dominates validation.

Reproduced the issue's shape before changing anything (3-member discriminated
union, list-wrapped):

    build+validate per payload: 0.1375 ms
    validate only:              0.0015 ms
    ratio:                      92x

After, through the converter itself: 0.0058 ms per call, 199 cache hits to 1 miss.

Adapters now come from a bounded `functools.lru_cache`, sized at 256 since a
worker sees a small fixed set of signatures and the cache holds hints strongly.
Hints that cannot be hashed fall back to per-call construction rather than
failing conversion, which keeps `Annotated[..., <unhashable metadata>]` working.

Tests: five cases, including a black-box one that counts `TypeAdapter`
constructions across two conversions of one hint and reports "built 2
TypeAdapters for one hint" against the unfixed converter, so it pins the behavior
rather than the presence of a cache.
@PranavMishra28
PranavMishra28 requested a review from a team as a code owner August 5, 2026 04:47
@CLAassistant

CLAassistant commented Aug 5, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@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: 69582a3948

ℹ️ 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".

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

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread temporalio/contrib/pydantic.py
@tconley1428

Copy link
Copy Markdown
Contributor

There's already an open PR with ongoing discussion. #1703

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

contrib.pydantic builds a fresh TypeAdapter per payload — construction dominates validation up to 521x for union hints

3 participants