Skip to content

Performance [P2]: Cache type-discovery signature structure #189

Description

@berndverst

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    performance / optimizationUsed for issues or PRs purely focused on performance and optimizations, not bugs.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions