What is the issue?
Type discovery caches resolved type hints but still performs callable signature inspection and positional parameter filtering on every handler invocation.
What is the impact?
Small, high-frequency activities, entity operations, and orchestration replays repeatedly pay reflection costs that can become measurable CPU overhead at high throughput.
Details about the issue including code reference
Relevant code:
|
@functools.lru_cache(maxsize=2048) |
|
def _resolved_hints(fn: Callable[..., Any]) -> dict[str, Any] | None: |
|
"""Resolve a function's type hints, honoring postponed annotations. |
|
|
|
Results are memoized per function because discovery runs on every |
|
orchestrator/activity/entity execution (including replay). |
|
""" |
|
try: |
|
return typing.get_type_hints(fn) |
|
except Exception: |
|
return None |
|
|
|
|
|
def _input_annotation(fn: Callable[..., Any], position: int, |
|
converter: DataConverter | None = None) -> Any | None: |
|
"""Return the resolved annotation of the positional parameter at ``position``. |
|
|
|
``position`` is the zero-based index among positional parameters (so the |
|
``input`` parameter of a ``(ctx, input)`` function is at position 1, and the |
|
``input`` parameter of an unbound ``(self, input)`` entity method is also at |
|
position 1). Returns ``None`` when the parameter is absent, unannotated, or |
|
its annotation is not reconstructable by ``converter``. |
|
""" |
|
try: |
|
sig = inspect.signature(fn) |
|
except (TypeError, ValueError): |
|
return None |
|
|
|
positional = [ |
|
p for p in sig.parameters.values() |
|
if p.kind in (inspect.Parameter.POSITIONAL_ONLY, |
|
inspect.Parameter.POSITIONAL_OR_KEYWORD) |
|
] |
|
if position >= len(positional): |
|
return None |
|
param = positional[position] |
|
|
|
annotation: Any = param.annotation |
|
hints = _resolved_hints(fn) |
|
if hints is not None and param.name in hints: |
|
annotation = hints[param.name] |
|
elif isinstance(annotation, str): |
|
# Could not resolve a postponed (string) annotation -- give up. |
|
return None |
|
|
|
if annotation is inspect.Parameter.empty or annotation is Any: |
|
return None |
|
return annotation if _resolve_converter(converter).can_reconstruct(annotation) else None |
|
def activity_output_type(fn: Any, converter: DataConverter | None = None) -> Any | None: |
|
"""Discover the return type of an activity function. |
|
|
|
Returns the resolved return annotation when ``converter`` reports it as |
|
reconstructable (the default converter recognizes a dataclass or a |
|
``from_json()``-capable type, optionally wrapped in ``Optional`` / ``list``). |
|
Returns ``None`` for plain callables that are not annotated with such a type, |
|
for string activity names, or when the annotation cannot be resolved. |
|
""" |
|
if not callable(fn): |
|
return None |
|
try: |
|
sig = inspect.signature(fn) |
|
except (TypeError, ValueError): |
|
return None |
|
|
|
annotation: Any = sig.return_annotation |
|
hints = _resolved_hints(fn) |
|
if hints is not None and "return" in hints: |
|
annotation = hints["return"] |
|
elif isinstance(annotation, str): |
|
# Could not resolve a postponed (string) annotation -- give up. |
|
return None |
|
|
|
if annotation is inspect.Signature.empty or annotation is Any or annotation is None: |
|
return None |
|
return annotation if _resolve_converter(converter).can_reconstruct(annotation) else None |
|
|
|
|
|
def entity_input_type(fn: Any, operation: str, |
|
converter: DataConverter | None = None) -> Any | None: |
|
"""Discover the input type of an entity operation. |
|
|
|
For class-based entities (a ``DurableEntity`` subclass) the operation is a |
|
method; its input is the first parameter after ``self``. For function-based |
|
entities the signature is ``(ctx, input)``. Returns ``None`` when no |
|
reconstructable input annotation is found. |
|
""" |
|
if isinstance(fn, type): |
|
method = getattr(fn, operation, None) |
|
if method is None or not callable(method): |
|
return None |
|
# Unbound method includes ``self`` at position 0, so ``input`` is at 1. |
|
return _input_annotation(method, 1, converter) |
|
return _input_annotation(fn, 1, converter) |
|
fn = self._registry.get_activity(name) |
|
if not fn: |
|
raise ActivityNotRegisteredError( |
|
f"Activity function named '{name}' was not registered!" |
|
) |
|
|
|
input_type = type_discovery.activity_input_type(fn, self._data_converter) if encoded_input else None |
|
activity_input = self._data_converter.deserialize(encoded_input, input_type) |
_resolved_hints() is cached, but _input_annotation() and activity_output_type() still call inspect.signature() and inspect parameters. Worker execution invokes these helpers for activity execution, entity operations, and orchestration replay.
A potential or proposed solution
Cache callable signature structure and resolved raw annotations in a bounded cache. Continue to apply DataConverter.can_reconstruct() at call time so converter-specific or stateful behavior remains correct. Preserve the existing conservative fallback to raw payloads when annotation resolution fails.
What is the issue?
Type discovery caches resolved type hints but still performs callable signature inspection and positional parameter filtering on every handler invocation.
What is the impact?
Small, high-frequency activities, entity operations, and orchestration replays repeatedly pay reflection costs that can become measurable CPU overhead at high throughput.
Details about the issue including code reference
Relevant code:
durabletask-python/durabletask/internal/type_discovery.py
Lines 44 to 91 in 55d8e0b
durabletask-python/durabletask/internal/type_discovery.py
Lines 106 to 150 in 55d8e0b
durabletask-python/durabletask/worker.py
Lines 3083 to 3090 in 55d8e0b
_resolved_hints()is cached, but_input_annotation()andactivity_output_type()still callinspect.signature()and inspect parameters. Worker execution invokes these helpers for activity execution, entity operations, and orchestration replay.A potential or proposed solution
Cache callable signature structure and resolved raw annotations in a bounded cache. Continue to apply
DataConverter.can_reconstruct()at call time so converter-specific or stateful behavior remains correct. Preserve the existing conservative fallback to raw payloads when annotation resolution fails.