Performance [P2]: Cache type-discovery signature structure - #207
Conversation
`_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
There was a problem hiding this comment.
Pull request overview
This PR optimizes internal type-discovery in the Durable Task Python SDK by memoizing callable signature structure (positional parameter annotations + return annotation), eliminating repeated inspect.signature() and parameter filtering on hot paths like activity execution, entity operations, and orchestration replay.
Changes:
- Added a bounded
lru_cachefor resolved signature structure (_SignatureInfo) while keepingDataConverter.can_reconstruct()evaluated per call. - Refactored hint resolution into a small helper and reused it from both memoized and non-memoized paths (to support unhashable callables).
- Added targeted unit tests validating caching behavior, converter-per-call semantics, and unhashable callable support.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
durabletask/internal/type_discovery.py |
Adds memoized signature-structure inspection and annotation resolution to remove repeated reflection work while preserving converter-per-call semantics. |
tests/durabletask/test_type_discovery.py |
Adds tests ensuring signature inspection is cached, converter decisions remain per-call, and unhashable callables still work. |
…-discovery-signature
|
Thanks for the review. Declining the seven suggestions to replace The convention is split by node type, not uniform. Classifying every stub in the repo with an AST pass — counting only defs and classes whose entire body is a single
Class stubs use Within the file being flagged, This PR already applies both halves of that convention. It adds 7 function stubs, written as One caveat worth stating plainly, since it cuts against the simplest version of this argument: the raw repo-wide totals do not support a blanket " CodeQL is also not blocking here — all checks on this PR pass. Separately, this branch has been merged up to latest |
…-discovery-signature
…-discovery-signature
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
…-discovery-signature
Summary
durabletask/internal/type_discovery.pycached resolved type hints, but_input_annotation()andactivity_output_type()still calledinspect.signature()and re-filtered positional parameters on everyinvocation. 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.
The signature structure (positional parameters in declaration order plus the
return annotation) and the resolved annotations are a pure function of the
callable, so they are now computed once and memoized in a bounded
functools.lru_cache(samemaxsize=2048bound and rationale as the existingresolved-hints cache).
Correctness
DataConverter.can_reconstruct()is not part of the cached value. It isstill evaluated on every call, so a different — or stateful — converter
continues to produce its own per-call decision for the same function.
parameters,
*args/**kwargsand keyword-only parameters (still notpositional inputs),
Any, PEP 563 / string postponed annotations that cannotbe resolved, plain functions vs. unbound entity methods, and the conservative
fallback to the raw payload when resolution fails. A dedicated
_NO_ANNOTATIONsentinel is used instead ofNoneso that a literalNoneannotation still reaches the converter exactly as before.
uncached computation, so caching never narrows the set of callables that
discovery supports. Previously such callables raised
TypeErrorfrom thehints cache.
No public API change, no renames, no changed import paths.
One observable behavior change, surfaced in review: a handler that is an
unhashable callable — most realistically a
@dataclasswith__call__, sincedataclasses default to
eq=Trueand so set__hash__ = None— previouslyraised
TypeError: unhashable typeout of the memoized hint lookup during typediscovery. Such handlers reach the worker through
add_named_activity()/add_named_orchestrator()/add_named_entity(), which take an explicit nameand so do not require
__name__. They now resolve their annotations normally.This is covered by a CHANGELOG entry under Unreleased / FIXED and pinned by a
test through the public registration API.
Performance
Steady-state cost per call for
def act(ctx, order: Order) -> Order(dataclass input/output, caches warm, 100k iterations):
activity_input_typeactivity_output_typeThe residual ~0.8 us is dominated by
can_reconstruct(), which is intentionallystill per-call.
Verification
pytest tests/durabletask -q --ignore-glob="*_e2e.py"-> 688 passed, 7 skipped (baseline onmainwas 681 tests; the 7 new tests account for the difference).pytest tests/durabletask-azuremanaged/test_sandboxes_extension.py tests/durabletask-azuremanaged/test_durabletask_grpc_interceptor.py -q-> 42 passed.flake8clean on both changed files;pyright(strict, project config) reports 0 issues fordurabletask/internal/type_discovery.py.tests/durabletask/test_type_discovery.py(all 7 verified to fail or be meaningless against the pre-change implementation):inspect.signature()is invoked once per callable and never again, for both the input and output helpers and for class-based entity operations.can_reconstructanswer between calls is honored on every call.Fixes #189