Skip to content

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

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).

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