contrib.pydantic builds a fresh TypeAdapter per payload — construction dominates validation up to 521x for union hints · Issue #1695 · temporalio/sdk-python · GitHub
Skip to content

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

Closed

Description

@ZacharyHampton

What happens

temporalio.contrib.pydantic.PydanticJSONPlainPayloadConverter.from_payload constructs a fresh TypeAdapter for every payload:

return TypeAdapter(_type_hint).validate_json(payload.data)

For hints that are BaseModel subclasses this is masked by pydantic's class-level core-schema cache. But activity arg/result hints are frequently not classes — Annotated discriminated unions, list[Union[...]], generics — and for those TypeAdapter.__init__ rebuilds the core schema on every single payload. Construction dominates validation by orders of magnitude.

Measured

Toy 3-member discriminated union (runnable below), pydantic 2.x, Python 3.12:

build+validate per payload: 0.134 ms
validate only:              0.0023 ms
ratio:                      57x

Production shape (pydantic-ai's CallToolResult, a wide Annotated discriminated union — PydanticAIPlugin inherits this converter): 2.818 ms build vs 0.005 ms validate, 521×. In a wedged activation resolving 792 concurrent activity completions, 11.935 s of the 12.089 s activation was TypeAdapter.__init__ — enough on its own to breach a Lambda worker's deadlock-detection ceiling. It also makes replay and continue-as-new rebuilds scale with history size × union width.

Repro

import time
from typing import Annotated, Literal, Union
from pydantic import BaseModel, Field, TypeAdapter

class TextPart(BaseModel):
    kind: Literal["text"] = "text"
    text: str

class DataPart(BaseModel):
    kind: Literal["data"] = "data"
    payload: dict[str, str]

class ErrorPart(BaseModel):
    kind: Literal["error"] = "error"
    message: str
    code: int

Part = Annotated[Union[TextPart, DataPart, ErrorPart], Field(discriminator="kind")]
ListPart = list[Part]

doc = '[{"kind":"text","text":"' + "x" * 200 + '"},{"kind":"data","payload":{"a":"b"}},{"kind":"error","message":"m","code":1}]'
N = 200

t0 = time.perf_counter()
for _ in range(N):
    TypeAdapter(ListPart).validate_json(doc)
per_payload = (time.perf_counter() - t0) / N * 1000

adapter = TypeAdapter(ListPart)
t0 = time.perf_counter()
for _ in range(N):
    adapter.validate_json(doc)
validate_only = (time.perf_counter() - t0) / N * 1000

print(f"build+validate per payload: {per_payload:.3f} ms")
print(f"validate only:              {validate_only:.4f} ms")
print(f"ratio:                      {per_payload / validate_only:.0f}x")

Suggested fix

Memoize the adapter on the type hint. We run exactly that in production as a converter subclass — an LRU keyed on the hint (falling back to the un-memoized path for unhashable hints), which took our peak activation from 3.900 s → 0.200 s with no behavior change: the memo returns exactly what a fresh adapter returns for the same inputs, so histories replay identically. Happy to turn that into a PR against contrib/pydantic.py if you'd take it — the only design question is cache scope/eviction (process-global lru_cache(maxsize=...) on a module function was enough for us).

Activity

  1. tconley1428 commented on Jul 29, 2026

    @tconley1428
    Contributor

    Given those measurements, it definitely seems like a good thing to solve. I don't see any good reason a small LRU cache by default would be a breaking change for anyone, but it would be good to consider if there is any drawback to enabling it by default.
    I think probably it would be safest to start with this as experimental opt-in behavior. We can keep the cache behavior simple and see if there is any additional need.

  2. added a commit that references this issue on Aug 3, 2026
    a87e74f
  3. PranavMishra28 commented on Aug 5, 2026

    @PranavMishra28

    opened #1724 for this.

    @tconley1428 re: your question about drawbacks of enabling it by default, I found two and handled both in the PR rather than leaving them implicit:

    the cache holds hints strongly, so it's bounded at 256 rather than unbounded — a worker sees a small fixed set of signatures, and that stops a pathological caller retaining types forever. and not every hint is hashable, so Annotated[list[X], SomeUnhashableMetadata()] raises TypeError out of lru_cache; that falls back to per-call construction instead of turning a working conversion into an error. the second one is the case I'd most want a second pair of eyes on.

    @ZacharyHampton reproduced your numbers before changing anything — 92x here vs your 57x, so machine-dependent multiple but the same effect:

    build+validate per payload: 0.1375 ms
    validate only:              0.0015 ms
    

    after, measured through the converter itself rather than the raw adapter: 0.0058 ms per call, 199 cache hits to 1 miss.

    the test I'd point at is the black-box one: it counts TypeAdapter constructions across two conversions of a single hint and reports built 2 TypeAdapters for one hint against the unfixed converter, so it pins the behavior instead of the presence of a cache. to_payload is untouched — its SchemaSerializer is already hoisted into __init__, so it never had this problem.

  4. added a commit that references this issue on Aug 6, 2026
    069bf54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions