Skip to content

Commit 3437206

Browse files
berndverstBernd VerstCopilot
authored
Performance [P2]: Cache type-discovery signature structure (#207)
* Cache type-discovery signature structure `_input_annotation()` and `activity_output_type()` called `inspect.signature()` and re-filtered positional parameters on every invocation. Discovery runs once per work item (activity execution, entity operation, and every orchestration replay of `call_activity`), so small high-frequency handlers repeatedly paid reflection costs. Signature shape and resolved annotations depend only on the callable, so they are now computed once and memoized in a bounded LRU cache alongside the existing resolved-hints cache. `DataConverter.can_reconstruct()` is still evaluated on every call, so a different (or stateful) converter continues to produce its own per-call decision. No public API or observable behavior change. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c994b736-b9fc-4f5b-b0ab-1f9061da952e * Address review feedback on type-discovery caching Two follow-ups from PR review, both accepted: 1. The `_NO_ANNOTATION` comment claimed a literal `None` annotation "still reaches the converter exactly as before". That is only true for parameter annotations. `activity_output_type()` has always short-circuited on `annotation is None`, and `_build_signature_info()` preserves that by normalizing a `-> None` return annotation to the sentinel, so on the return path `None` never reaches the converter. Reworded the comment to scope the claim per path and documented the normalization at the site itself. 2. Catching `TypeError` in `_signature_info()` is an observable behavior change, not a pure internal refactor. Verified it is reachable through the public API: a handler registered via `add_named_activity()` needs no `__name__`, and a `@dataclass` with `__call__` is unhashable because dataclasses default to `eq=True` (which sets `__hash__ = None`). Against the pre-change code such a handler raised `TypeError: unhashable type` from the memoized hint lookup; it now resolves its annotations normally. Added a CHANGELOG entry under Unreleased/FIXED and a test pinning that exact pattern end to end through the registration API. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c994b736-b9fc-4f5b-b0ab-1f9061da952e --------- Co-authored-by: Bernd Verst <beverst@microsoft.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c994b736-b9fc-4f5b-b0ab-1f9061da952e
1 parent ec28103 commit 3437206

3 files changed

Lines changed: 283 additions & 36 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import paths, `__all__`, `dir()`, and star-imports behave exactly as before.
3030
FIXED
3131

3232
- Fixed the worker allocating one `asyncio` task per queued work item before applying the concurrency limit, which made memory use and event-loop scheduling overhead grow with the queue backlog during bursts. In-flight work item tasks are now bounded by the configured `ConcurrencyOptions` limits.
33+
- Fixed input/output type discovery raising `TypeError: unhashable type` for handlers that are unhashable callables. A callable object registered through `add_named_activity()`, `add_named_orchestrator()`, or `add_named_entity()` is unhashable whenever its class defines `__eq__` without `__hash__` — most commonly a `@dataclass` with a `__call__` method, since dataclasses default to `eq=True`. Annotations on such handlers are now discovered normally instead of failing.
3334

3435
## v1.8.0
3536

durabletask/internal/type_discovery.py

Lines changed: 106 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
import functools
2828
import inspect
2929
import typing
30-
from typing import Any, Callable
30+
from typing import Any, Callable, NamedTuple
3131

3232
from durabletask.serialization import DEFAULT_DATA_CONVERTER, DataConverter
3333

@@ -37,22 +37,116 @@ def _resolve_converter(converter: DataConverter | None) -> DataConverter:
3737
return converter if converter is not None else DEFAULT_DATA_CONVERTER
3838

3939

40+
def _resolve_hints(fn: Callable[..., Any]) -> dict[str, Any] | None:
41+
"""Resolve a function's type hints, honoring postponed annotations."""
42+
try:
43+
return typing.get_type_hints(fn)
44+
except Exception:
45+
return None
46+
47+
4048
# Bounded so a worker that registers dynamically-created functions or closures
4149
# cannot accumulate cache entries unboundedly over the process lifetime. The
4250
# common case (a fixed set of module-level orchestrators/activities) fits well
4351
# within this bound.
4452
@functools.lru_cache(maxsize=2048)
4553
def _resolved_hints(fn: Callable[..., Any]) -> dict[str, Any] | None:
46-
"""Resolve a function's type hints, honoring postponed annotations.
54+
"""Memoized :func:`_resolve_hints`.
4755
4856
Results are memoized per function because discovery runs on every
4957
orchestrator/activity/entity execution (including replay).
5058
"""
59+
return _resolve_hints(fn)
60+
61+
62+
# Sentinel for "there is no annotation here worth asking the converter about"
63+
# (parameter absent, unannotated, ``Any``, or an unresolvable string
64+
# annotation). It is deliberately distinct from ``None`` because the two
65+
# annotation paths treat a literal ``None`` differently, and both behaviours
66+
# predate this cache:
67+
# * parameters -- a ``None`` annotation is a real annotation and is still
68+
# passed to the converter, so it must not collapse into the sentinel;
69+
# * return values -- ``activity_output_type()`` has always short-circuited on
70+
# ``annotation is None``, so ``_build_signature_info()`` normalizes a
71+
# ``-> None`` return annotation to this sentinel and it never reaches the
72+
# converter.
73+
_NO_ANNOTATION: Any = object()
74+
75+
76+
class _SignatureInfo(NamedTuple):
77+
"""The converter-independent shape of a callable's signature.
78+
79+
``positional`` holds the resolved annotation of each positional parameter in
80+
declaration order, and ``return_annotation`` the resolved return annotation.
81+
Entries are :data:`_NO_ANNOTATION` when there is nothing to reconstruct.
82+
83+
This depends only on the callable, so it is safe to memoize. The
84+
converter-dependent decision (:meth:`DataConverter.can_reconstruct`) is
85+
deliberately *not* part of it and stays at call time, so the same function
86+
discovered through two different converters still gets two answers.
87+
"""
88+
89+
positional: tuple[Any, ...]
90+
return_annotation: Any
91+
92+
93+
def _resolve_annotation(raw: Any, name: str, hints: dict[str, Any] | None) -> Any:
94+
"""Resolve one raw annotation, or return :data:`_NO_ANNOTATION`."""
95+
annotation: Any = raw
96+
if hints is not None and name in hints:
97+
annotation = hints[name]
98+
elif isinstance(annotation, str):
99+
# Could not resolve a postponed (string) annotation -- give up.
100+
return _NO_ANNOTATION
101+
102+
if annotation is inspect.Parameter.empty or annotation is Any:
103+
return _NO_ANNOTATION
104+
return annotation
105+
106+
107+
def _build_signature_info(fn: Any, *, memoized: bool) -> _SignatureInfo | None:
108+
"""Inspect ``fn`` and resolve its annotations, or return ``None``."""
51109
try:
52-
return typing.get_type_hints(fn)
53-
except Exception:
110+
sig = inspect.signature(fn)
111+
except (TypeError, ValueError):
54112
return None
55113

114+
hints = _resolved_hints(fn) if memoized else _resolve_hints(fn)
115+
positional = tuple(
116+
_resolve_annotation(p.annotation, p.name, hints)
117+
for p in sig.parameters.values()
118+
if p.kind in (inspect.Parameter.POSITIONAL_ONLY,
119+
inspect.Parameter.POSITIONAL_OR_KEYWORD)
120+
)
121+
return_annotation = _resolve_annotation(sig.return_annotation, "return", hints)
122+
if return_annotation is None:
123+
# ``activity_output_type()`` has always treated an explicit ``-> None``
124+
# as "nothing to reconstruct". Fold it into the sentinel here so the
125+
# special case stays out of the per-call path.
126+
return_annotation = _NO_ANNOTATION
127+
return _SignatureInfo(positional, return_annotation)
128+
129+
130+
@functools.lru_cache(maxsize=2048)
131+
def _memoized_signature_info(fn: Any) -> _SignatureInfo | None:
132+
return _build_signature_info(fn, memoized=True)
133+
134+
135+
def _signature_info(fn: Any) -> _SignatureInfo | None:
136+
"""Return ``fn``'s resolved signature shape, or ``None`` when unavailable.
137+
138+
``inspect.signature()`` and annotation resolution are comparatively
139+
expensive and their result depends only on the callable, so the result is
140+
memoized: discovery runs once per work item, and a worker executes the same
141+
registered functions over and over.
142+
"""
143+
try:
144+
return _memoized_signature_info(fn)
145+
except TypeError:
146+
# Unhashable callable, so it cannot be used as a cache key. Fall back to
147+
# computing the shape on every call rather than failing.
148+
return _build_signature_info(fn, memoized=False)
149+
56150

57151
def _input_annotation(fn: Callable[..., Any], position: int,
58152
converter: DataConverter | None = None) -> Any | None:
@@ -64,29 +158,12 @@ def _input_annotation(fn: Callable[..., Any], position: int,
64158
position 1). Returns ``None`` when the parameter is absent, unannotated, or
65159
its annotation is not reconstructable by ``converter``.
66160
"""
67-
try:
68-
sig = inspect.signature(fn)
69-
except (TypeError, ValueError):
70-
return None
71-
72-
positional = [
73-
p for p in sig.parameters.values()
74-
if p.kind in (inspect.Parameter.POSITIONAL_ONLY,
75-
inspect.Parameter.POSITIONAL_OR_KEYWORD)
76-
]
77-
if position >= len(positional):
78-
return None
79-
param = positional[position]
80-
81-
annotation: Any = param.annotation
82-
hints = _resolved_hints(fn)
83-
if hints is not None and param.name in hints:
84-
annotation = hints[param.name]
85-
elif isinstance(annotation, str):
86-
# Could not resolve a postponed (string) annotation -- give up.
161+
info = _signature_info(fn)
162+
if info is None or position >= len(info.positional):
87163
return None
88164

89-
if annotation is inspect.Parameter.empty or annotation is Any:
165+
annotation = info.positional[position]
166+
if annotation is _NO_ANNOTATION:
90167
return None
91168
return annotation if _resolve_converter(converter).can_reconstruct(annotation) else None
92169

@@ -114,20 +191,13 @@ def activity_output_type(fn: Any, converter: DataConverter | None = None) -> Any
114191
"""
115192
if not callable(fn):
116193
return None
117-
try:
118-
sig = inspect.signature(fn)
119-
except (TypeError, ValueError):
120-
return None
121194

122-
annotation: Any = sig.return_annotation
123-
hints = _resolved_hints(fn)
124-
if hints is not None and "return" in hints:
125-
annotation = hints["return"]
126-
elif isinstance(annotation, str):
127-
# Could not resolve a postponed (string) annotation -- give up.
195+
info = _signature_info(fn)
196+
if info is None:
128197
return None
129198

130-
if annotation is inspect.Signature.empty or annotation is Any or annotation is None:
199+
annotation = info.return_annotation
200+
if annotation is _NO_ANNOTATION:
131201
return None
132202
return annotation if _resolve_converter(converter).can_reconstruct(annotation) else None
133203

tests/durabletask/test_type_discovery.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,14 @@
33

44
"""Tests for annotation-based input type discovery and inbound coercion."""
55

6+
import inspect
67
import json
78
import logging
89
from dataclasses import dataclass
910
from typing import Any, Optional
11+
from unittest.mock import patch
12+
13+
import pytest
1014

1115
from durabletask import entities, task, worker
1216
from durabletask.internal import type_discovery
@@ -199,6 +203,178 @@ def test_string_name_returns_none(self):
199203
assert type_discovery.activity_output_type("some_activity_name") is None
200204

201205

206+
# ----- signature-structure caching -----
207+
208+
209+
class _CountingConverter(JsonDataConverter):
210+
"""Records every ``can_reconstruct`` call so per-call evaluation is visible."""
211+
212+
def __init__(self):
213+
self.seen: list[Any] = []
214+
215+
def can_reconstruct(self, target_type: Any) -> bool:
216+
self.seen.append(target_type)
217+
return super().can_reconstruct(target_type)
218+
219+
220+
class TestSignatureCaching:
221+
"""The signature *structure* is cached per callable, but the converter's
222+
reconstructability decision is still made on every call."""
223+
224+
def test_signature_inspected_once_per_function(self):
225+
def act(ctx, order: Order) -> Money:
226+
...
227+
228+
real_signature = inspect.signature
229+
calls: list[Any] = []
230+
231+
def counting_signature(obj, *args, **kwargs):
232+
calls.append(obj)
233+
return real_signature(obj, *args, **kwargs)
234+
235+
with patch.object(inspect, "signature", counting_signature):
236+
assert type_discovery.activity_input_type(act) is Order
237+
after_first = len(calls)
238+
for _ in range(5):
239+
assert type_discovery.activity_input_type(act) is Order
240+
# The input and output helpers share one cached structure, so the
241+
# return annotation costs no additional inspection either.
242+
assert type_discovery.activity_output_type(act) is Money
243+
assert type_discovery.orchestrator_input_type(act) is Order
244+
245+
assert after_first >= 1
246+
assert len(calls) == after_first, "signature() was re-inspected for a cached function"
247+
248+
def test_entity_operation_signature_inspected_once(self):
249+
class Store(entities.DurableEntity):
250+
def add(self, order: Order):
251+
...
252+
253+
real_signature = inspect.signature
254+
calls: list[Any] = []
255+
256+
def counting_signature(obj, *args, **kwargs):
257+
calls.append(obj)
258+
return real_signature(obj, *args, **kwargs)
259+
260+
with patch.object(inspect, "signature", counting_signature):
261+
assert type_discovery.entity_input_type(Store, "add") is Order
262+
after_first = len(calls)
263+
for _ in range(5):
264+
assert type_discovery.entity_input_type(Store, "add") is Order
265+
266+
assert after_first >= 1
267+
assert len(calls) == after_first
268+
269+
def test_converter_decision_is_made_per_call(self):
270+
class Widget:
271+
pass
272+
273+
class WidgetConverter(JsonDataConverter):
274+
def can_reconstruct(self, target_type: Any) -> bool:
275+
if isinstance(target_type, type) and issubclass(target_type, Widget):
276+
return True
277+
return super().can_reconstruct(target_type)
278+
279+
def act(ctx, w: Widget) -> Widget:
280+
...
281+
282+
# Prime the cache with the converter that *does* recognize Widget, then
283+
# switch back: the cached structure must not bake in the first answer.
284+
assert type_discovery.activity_input_type(act, WidgetConverter()) is Widget
285+
assert type_discovery.activity_input_type(act) is None
286+
assert type_discovery.activity_input_type(act, WidgetConverter()) is Widget
287+
288+
assert type_discovery.activity_output_type(act, WidgetConverter()) is Widget
289+
assert type_discovery.activity_output_type(act) is None
290+
assert type_discovery.activity_output_type(act, WidgetConverter()) is Widget
291+
292+
def test_stateful_converter_answer_is_not_cached(self):
293+
class Widget:
294+
pass
295+
296+
class ToggleConverter(JsonDataConverter):
297+
def __init__(self):
298+
self.enabled = False
299+
300+
def can_reconstruct(self, target_type: Any) -> bool:
301+
if self.enabled and target_type is Widget:
302+
return True
303+
return super().can_reconstruct(target_type)
304+
305+
def act(ctx, w: Widget):
306+
...
307+
308+
converter = ToggleConverter()
309+
assert type_discovery.activity_input_type(act, converter) is None
310+
converter.enabled = True
311+
assert type_discovery.activity_input_type(act, converter) is Widget
312+
converter.enabled = False
313+
assert type_discovery.activity_input_type(act, converter) is None
314+
315+
def test_converter_is_consulted_on_every_call(self):
316+
def act(ctx, order: Order) -> Order:
317+
...
318+
319+
converter = _CountingConverter()
320+
for _ in range(3):
321+
assert type_discovery.activity_input_type(act, converter) is Order
322+
assert converter.seen == [Order, Order, Order]
323+
324+
converter.seen.clear()
325+
for _ in range(3):
326+
assert type_discovery.activity_output_type(act, converter) is Order
327+
assert converter.seen == [Order, Order, Order]
328+
329+
def test_unannotated_parameters_are_not_offered_to_the_converter(self):
330+
def act(ctx, value, *args, keyword_only: Order = None, **kwargs):
331+
...
332+
333+
converter = _CountingConverter()
334+
assert type_discovery.activity_input_type(act, converter) is None
335+
# *args/**kwargs and keyword-only parameters are not positional inputs,
336+
# and an unannotated parameter never reaches the converter at all.
337+
assert converter.seen == []
338+
339+
def test_unhashable_callable_still_resolves(self):
340+
class Callable_:
341+
# Defining __eq__ without __hash__ makes instances unhashable, so
342+
# they cannot be used as a cache key.
343+
def __eq__(self, other):
344+
return self is other
345+
346+
def __call__(self, ctx, order: Order) -> Order:
347+
...
348+
349+
act = Callable_()
350+
assert type_discovery.activity_input_type(act) is Order
351+
assert type_discovery.activity_input_type(act) is Order
352+
assert type_discovery.activity_output_type(act) is Order
353+
354+
def test_unhashable_dataclass_handler_registered_by_name_resolves(self):
355+
# A ``@dataclass`` with ``__call__`` is the realistic shape of this:
356+
# dataclasses default to ``eq=True``, which sets ``__hash__ = None``.
357+
# Such a handler has no ``__name__``, so it reaches the worker through
358+
# the explicit-name registration API rather than ``add_activity()``.
359+
@dataclass
360+
class ConfiguredActivity:
361+
retries: int = 3
362+
363+
def __call__(self, ctx, order: Order) -> Order:
364+
...
365+
366+
handler = ConfiguredActivity()
367+
with pytest.raises(TypeError):
368+
hash(handler)
369+
370+
registry = worker._Registry()
371+
registry.add_named_activity("process_order", handler)
372+
registered = registry.get_activity("process_order")
373+
374+
assert type_discovery.activity_input_type(registered) is Order
375+
assert type_discovery.activity_output_type(registered) is Order
376+
377+
202378
# ----- activity executor inbound coercion -----
203379

204380

0 commit comments

Comments
 (0)