Skip to content

Commit 069bf54

Browse files
authored
fix(contrib/pydantic): reuse TypeAdapters across payloads (#1703)
* fix(contrib/pydantic): reuse TypeAdapters across payloads PydanticJSONPlainPayloadConverter.from_payload constructed a fresh pydantic TypeAdapter for every payload, rebuilding the core schema each time for non-class hints such as discriminated unions and generic collections. Cache adapters per converter instance, keyed on hashable type hints; unhashable hints keep constructing fresh adapters. The cache is unbounded by default and configurable via the new keyword-only max_cached_type_adapters option on PydanticJSONPlainPayloadConverter and PydanticPayloadConverter (positive bounds with LRU eviction, zero disables caching, negative raises ValueError). Fixes #1695 * fix(contrib/pydantic): default type adapter cache bound to 1024 Bound the per-converter type adapter cache to 1024 entries by default with LRU eviction, capping worst-case memory even with runtime-generated hints while never evicting for typical static hint sets. None remains available for an unbounded cache and zero still disables caching. * test(contrib/pydantic): cover re-imported class cache isolation The workflow sandbox re-imports user modules, producing distinct class objects with identical names. Verify each gets its own cache slot and validates to its own world's class, even when one converter is shared. * fix(contrib/pydantic): avoid double hash on cached decode path Address review: try the cache directly instead of pre-hashing every hint. On TypeError, hash the hint once only to distinguish an unhashable hint (bypass the cache with a fresh adapter) from a TypeError raised during adapter construction (re-raise), keeping adapter errors unsuppressed.
1 parent d5642db commit 069bf54

3 files changed

Lines changed: 234 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,17 @@ to include examples, links to docs, or any other relevant information.
2222

2323
### Changed
2424

25+
- `temporalio.contrib.pydantic` converters now reuse Pydantic type adapters
26+
for repeated type hints instead of rebuilding their schemas for every
27+
payload, greatly speeding up decode of non-model hints such as discriminated
28+
unions ([#1695](https://github.com/temporalio/sdk-python/issues/1695)). Up
29+
to 1024 type adapters are cached per converter instance by default, with
30+
least-recently-used eviction. To change the bound, pass
31+
``max_cached_type_adapters`` to ``PydanticPayloadConverter`` (or
32+
``PydanticJSONPlainPayloadConverter``) from a nullary subclass used as the
33+
``DataConverter.payload_converter_class``; ``None`` makes the cache
34+
unbounded and zero disables caching.
35+
2536
### Deprecated
2637

2738
### :boom: Breaking Changes

temporalio/contrib/pydantic.py

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
Pydantic v1 is not supported.
1414
"""
1515

16+
import functools
1617
from dataclasses import dataclass
1718
from typing import Any
1819

@@ -53,10 +54,28 @@ class PydanticJSONPlainPayloadConverter(EncodingPayloadConverter):
5354
See https://docs.pydantic.dev/latest/api/standard_library_types/
5455
"""
5556

56-
def __init__(self, to_json_options: ToJsonOptions | None = None):
57-
"""Create a new payload converter."""
57+
def __init__(
58+
self,
59+
to_json_options: ToJsonOptions | None = None,
60+
*,
61+
max_cached_type_adapters: int | None = 1024,
62+
) -> None:
63+
"""Create a new payload converter.
64+
65+
Args:
66+
to_json_options: Options for serializing values to JSON.
67+
max_cached_type_adapters: Maximum number of type adapters to
68+
cache, with least-recently-used eviction. Defaults to 1024.
69+
If ``None``, the cache is unbounded. If zero, caching is
70+
disabled.
71+
"""
72+
if max_cached_type_adapters is not None and max_cached_type_adapters < 0:
73+
raise ValueError("max_cached_type_adapters cannot be negative")
5874
self._schema_serializer = SchemaSerializer(any_schema())
5975
self._to_json_options = to_json_options
76+
self._type_adapter = functools.lru_cache(maxsize=max_cached_type_adapters)(
77+
TypeAdapter
78+
)
6079

6180
@property
6281
def encoding(self) -> str:
@@ -91,12 +110,26 @@ def from_payload(
91110
92111
Uses ``pydantic.TypeAdapter.validate_json`` to construct an
93112
instance of the type specified by ``type_hint`` from the JSON payload.
113+
Type adapters are cached per hashable type hint; see
114+
``max_cached_type_adapters`` on the constructor.
94115
95116
See
96117
https://docs.pydantic.dev/latest/api/type_adapter/#pydantic.type_adapter.TypeAdapter.validate_json.
97118
"""
98119
_type_hint = type_hint if type_hint is not None else Any
99-
return TypeAdapter(_type_hint).validate_json(payload.data)
120+
type_adapter: TypeAdapter[Any]
121+
try:
122+
type_adapter = self._type_adapter(_type_hint)
123+
except TypeError:
124+
# Distinguish an unhashable hint (bypass the cache) from a
125+
# TypeError raised while constructing the adapter (re-raise).
126+
try:
127+
hash(_type_hint)
128+
except TypeError:
129+
type_adapter = TypeAdapter(_type_hint)
130+
else:
131+
raise
132+
return type_adapter.validate_json(payload.data)
100133

101134

102135
class PydanticPayloadConverter(CompositePayloadConverter):
@@ -106,9 +139,36 @@ class PydanticPayloadConverter(CompositePayloadConverter):
106139
:py:class:`PydanticJSONPlainPayloadConverter`.
107140
"""
108141

109-
def __init__(self, to_json_options: ToJsonOptions | None = None) -> None:
110-
"""Initialize object"""
111-
json_payload_converter = PydanticJSONPlainPayloadConverter(to_json_options)
142+
def __init__(
143+
self,
144+
to_json_options: ToJsonOptions | None = None,
145+
*,
146+
max_cached_type_adapters: int | None = 1024,
147+
) -> None:
148+
"""Initialize object.
149+
150+
Args:
151+
to_json_options: Options for serializing values to JSON.
152+
max_cached_type_adapters: Maximum number of type adapters to
153+
cache, with least-recently-used eviction. Defaults to 1024.
154+
If ``None``, the cache is unbounded. If zero, caching is
155+
disabled.
156+
157+
To configure this through a :py:class:`DataConverter`, use a
158+
nullary subclass as the payload converter class::
159+
160+
class MyPayloadConverter(PydanticPayloadConverter):
161+
def __init__(self) -> None:
162+
super().__init__(max_cached_type_adapters=128)
163+
164+
my_data_converter = DataConverter(
165+
payload_converter_class=MyPayloadConverter
166+
)
167+
"""
168+
json_payload_converter = PydanticJSONPlainPayloadConverter(
169+
to_json_options,
170+
max_cached_type_adapters=max_cached_type_adapters,
171+
)
112172
super().__init__(
113173
*(
114174
c

tests/contrib/pydantic/test_pydantic.py

Lines changed: 157 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,19 @@
22
import datetime
33
import os
44
import pathlib
5+
import typing
56
import uuid
67

78
import pydantic
89
import pytest
910
from pydantic import BaseModel
1011

1112
from temporalio.client import Client
12-
from temporalio.contrib.pydantic import pydantic_data_converter
13+
from temporalio.contrib.pydantic import (
14+
PydanticJSONPlainPayloadConverter,
15+
PydanticPayloadConverter,
16+
pydantic_data_converter,
17+
)
1318
from temporalio.worker import Worker
1419
from temporalio.worker.workflow_sandbox._restrictions import (
1520
RestrictionContext,
@@ -41,6 +46,157 @@
4146
clone_objects,
4247
)
4348

49+
_MANY_TYPE_HINTS = tuple(
50+
typing.cast(type, typing.cast(object, typing.Annotated[list[int], index]))
51+
for index in range(1025)
52+
)
53+
_UNHASHABLE_TYPE_HINT = typing.cast(
54+
type, typing.cast(object, typing.Annotated[list[int], []])
55+
)
56+
57+
58+
@pytest.mark.parametrize(
59+
(
60+
"converter_kwargs",
61+
"type_hints",
62+
"expected_type_adapter_constructions",
63+
),
64+
[
65+
# Default caches repeated hints
66+
({}, (list[int], list[int]), 1),
67+
# Zero disables caching
68+
({"max_cached_type_adapters": 0}, (list[int], list[int]), 2),
69+
# Unhashable hints bypass the cache
70+
({}, (_UNHASHABLE_TYPE_HINT, _UNHASHABLE_TYPE_HINT), 2),
71+
# Default bound is 1024: 1025 distinct hints evict the first
72+
({}, _MANY_TYPE_HINTS + (_MANY_TYPE_HINTS[0],), 1026),
73+
# None is unbounded: no eviction
74+
(
75+
{"max_cached_type_adapters": None},
76+
_MANY_TYPE_HINTS + (_MANY_TYPE_HINTS[0],),
77+
1025,
78+
),
79+
# Explicit bound evicts least recently used
80+
(
81+
{"max_cached_type_adapters": 1},
82+
(list[int], _MANY_TYPE_HINTS[0], list[int]),
83+
3,
84+
),
85+
],
86+
)
87+
def test_type_adapter_reuse(
88+
monkeypatch: pytest.MonkeyPatch,
89+
converter_kwargs: dict[str, typing.Any],
90+
type_hints: tuple[type, ...],
91+
expected_type_adapter_constructions: int,
92+
):
93+
actual_type_adapter = pydantic.TypeAdapter
94+
type_adapter_constructions = 0
95+
96+
def counting_type_adapter(
97+
type_hint: typing.Any,
98+
) -> pydantic.TypeAdapter[typing.Any]:
99+
nonlocal type_adapter_constructions
100+
type_adapter_constructions += 1
101+
return actual_type_adapter(type_hint)
102+
103+
monkeypatch.setattr(
104+
"temporalio.contrib.pydantic.TypeAdapter", counting_type_adapter
105+
)
106+
converter = PydanticJSONPlainPayloadConverter(**converter_kwargs)
107+
payload = converter.to_payload([1])
108+
assert payload is not None
109+
for type_hint in type_hints:
110+
assert converter.from_payload(payload, type_hint) == [1]
111+
assert type_adapter_constructions == expected_type_adapter_constructions
112+
113+
114+
@pytest.mark.parametrize(
115+
("max_cached_type_adapters", "expected_type_adapter_constructions"),
116+
[(None, 1), (0, 2)],
117+
)
118+
def test_composite_converter_forwards_type_adapter_cache_size(
119+
monkeypatch: pytest.MonkeyPatch,
120+
max_cached_type_adapters: int | None,
121+
expected_type_adapter_constructions: int,
122+
):
123+
actual_type_adapter = pydantic.TypeAdapter
124+
type_adapter_constructions = 0
125+
126+
def counting_type_adapter(
127+
type_hint: typing.Any,
128+
) -> pydantic.TypeAdapter[typing.Any]:
129+
nonlocal type_adapter_constructions
130+
type_adapter_constructions += 1
131+
return actual_type_adapter(type_hint)
132+
133+
monkeypatch.setattr(
134+
"temporalio.contrib.pydantic.TypeAdapter", counting_type_adapter
135+
)
136+
converter = PydanticPayloadConverter(
137+
max_cached_type_adapters=max_cached_type_adapters
138+
)
139+
payloads = converter.to_payloads([[1], [2]])
140+
assert converter.from_payloads(payloads, [list[int], list[int]]) == [[1], [2]]
141+
assert type_adapter_constructions == expected_type_adapter_constructions
142+
143+
144+
def test_type_adapter_reuse_across_threads_with_deferred_build():
145+
import concurrent.futures
146+
147+
class DeferredModel(BaseModel):
148+
model_config = pydantic.ConfigDict(defer_build=True)
149+
value: int
150+
151+
converter = PydanticJSONPlainPayloadConverter()
152+
payload = converter.to_payload(DeferredModel(value=1))
153+
assert payload is not None
154+
155+
def decode() -> DeferredModel:
156+
return converter.from_payload(payload, DeferredModel)
157+
158+
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as executor:
159+
results = list(executor.map(lambda _: decode(), range(64)))
160+
assert all(result == DeferredModel(value=1) for result in results)
161+
162+
163+
def test_type_adapter_cache_distinguishes_reimported_classes():
164+
# The workflow sandbox re-imports user modules, producing distinct class
165+
# objects with identical names. Class hints hash by identity, so each
166+
# world's class must get its own cache slot and validate to itself.
167+
import types
168+
169+
source = "from pydantic import BaseModel\n\nclass Foo(BaseModel):\n name: str\n"
170+
171+
def load_module() -> types.ModuleType:
172+
module = types.ModuleType("test_reimported_models")
173+
exec(compile(source, "test_reimported_models.py", "exec"), module.__dict__)
174+
return module
175+
176+
foo_outside = load_module().Foo
177+
foo_sandbox = load_module().Foo
178+
assert foo_outside is not foo_sandbox
179+
assert hash(foo_outside) != hash(foo_sandbox)
180+
181+
# Worst case: one converter shared across both worlds (the real sandbox
182+
# creates a separate converter per workflow instance).
183+
converter = PydanticJSONPlainPayloadConverter()
184+
payload = converter.to_payload(foo_outside(name="x"))
185+
assert payload is not None
186+
decoded_outside = converter.from_payload(payload, foo_outside)
187+
decoded_sandbox = converter.from_payload(payload, foo_sandbox)
188+
assert type(decoded_outside) is foo_outside
189+
assert type(decoded_sandbox) is foo_sandbox
190+
191+
192+
@pytest.mark.parametrize(
193+
"converter_type",
194+
[PydanticJSONPlainPayloadConverter, PydanticPayloadConverter],
195+
)
196+
def test_type_adapter_cache_rejects_negative_size(converter_type: type):
197+
with pytest.raises(ValueError, match="max_cached_type_adapters cannot be negative"):
198+
converter_type(max_cached_type_adapters=-1)
199+
44200

45201
async def test_instantiation_outside_sandbox():
46202
make_list_of_pydantic_objects()

0 commit comments

Comments
 (0)