Skip to content

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

Merged
berndverst merged 6 commits into
mainfrom
berndverst-cache-type-discovery-signature
Jul 27, 2026
Merged

Performance [P2]: Cache type-discovery signature structure#207
berndverst merged 6 commits into
mainfrom
berndverst-cache-type-discovery-signature

Conversation

@berndverst

@berndverst berndverst commented Jul 27, 2026

Copy link
Copy Markdown
Member

Summary

durabletask/internal/type_discovery.py cached resolved type hints, but
_input_annotation() and activity_output_type() still 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.

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 (same maxsize=2048 bound and rationale as the existing
resolved-hints cache).

Correctness

  • DataConverter.can_reconstruct() is not part of the cached value. It is
    still evaluated on every call, so a different — or stateful — converter
    continues to produce its own per-call decision for the same function.
  • All existing resolution semantics are preserved bit-for-bit: unannotated
    parameters, *args / **kwargs and keyword-only parameters (still not
    positional inputs), Any, PEP 563 / string postponed annotations that cannot
    be resolved, plain functions vs. unbound entity methods, and the conservative
    fallback to the raw payload when resolution fails. A dedicated
    _NO_ANNOTATION sentinel is used instead of None so that a literal None
    annotation still reaches the converter exactly as before.
  • Callables that cannot be used as a cache key (unhashable) fall back to an
    uncached computation, so caching never narrows the set of callables that
    discovery supports. Previously such callables raised TypeError from the
    hints 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 @dataclass with __call__, since
dataclasses default to eq=True and so set __hash__ = None — previously
raised TypeError: unhashable type out of the memoized hint lookup during type
discovery. Such handlers reach the worker through add_named_activity() /
add_named_orchestrator() / add_named_entity(), which take an explicit name
and 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):

helper before after speedup
activity_input_type 10.190 us 0.885 us 11.5x
activity_output_type 9.119 us 0.813 us 11.2x

The residual ~0.8 us is dominated by can_reconstruct(), which is intentionally
still per-call.

Verification

  • Core unit tests: pytest tests/durabletask -q --ignore-glob="*_e2e.py" -> 688 passed, 7 skipped (baseline on main was 681 tests; the 7 new tests account for the difference).
  • Provider unit tests: pytest tests/durabletask-azuremanaged/test_sandboxes_extension.py tests/durabletask-azuremanaged/test_durabletask_grpc_interceptor.py -q -> 42 passed.
  • flake8 clean on both changed files; pyright (strict, project config) reports 0 issues for durabletask/internal/type_discovery.py.
  • New tests in 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.
    • Priming the cache with a converter that recognizes a type and then querying with the default converter returns the correct different answer (both orders, input and output).
    • A stateful converter that flips its can_reconstruct answer between calls is honored on every call.
    • The converter is consulted on every call, and never for unannotated parameters.
    • Unhashable callables still resolve.

Fixes #189

`_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
Copilot AI review requested due to automatic review settings July 27, 2026 06:13
Comment thread tests/durabletask/test_type_discovery.py Dismissed
Comment thread tests/durabletask/test_type_discovery.py Dismissed
Comment thread tests/durabletask/test_type_discovery.py Dismissed
Comment thread tests/durabletask/test_type_discovery.py Dismissed
Comment thread tests/durabletask/test_type_discovery.py Dismissed
Comment thread tests/durabletask/test_type_discovery.py Dismissed
Comment thread tests/durabletask/test_type_discovery.py Dismissed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_cache for resolved signature structure (_SignatureInfo) while keeping DataConverter.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.

Copilot AI review requested due to automatic review settings July 27, 2026 16:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

Comment thread durabletask/internal/type_discovery.py Outdated
Comment thread durabletask/internal/type_discovery.py
@berndverst

Copy link
Copy Markdown
Member Author

Thanks for the review. Declining the seven suggestions to replace ... with pass, because the repository already has a consistent convention here and this PR follows it.

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 ... or a single pass, so that unrelated uses like except: pass are not miscounted:

stub kind ... pass
function / method 103 52
class 0 22

Class stubs use pass with zero exceptions across the entire repository. Function stubs lean the other way.

Within the file being flagged, tests/durabletask/test_type_discovery.py, the function rule is absolute — it contains 23 function stubs, all 23 using ..., none using pass. The two pre-existing pass statements in that file are class Widget: pass at L78 and L97, which are class stubs, so they follow the class rule rather than contradicting the function one.

This PR already applies both halves of that convention. It adds 7 function stubs, written as ..., and 2 class stubs, written as class Widget: pass. The suggestion is therefore asking to convert function stubs to pass in a file that is 23-for-23 the other way, which would leave the file inconsistent with itself and with the other 103 function stubs in the codebase.

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 "... is the convention" claim. Repo-wide it is only about 58% ellipsis, and inside tests/ alone pass actually outnumbers ... by 45 to 18 once class stubs are included. The convention is real, but it is the node-type split above, not a global preference. Credit to the analysis on our side for catching that the cruder framing was rebuttable.

CodeQL is also not blocking here — all checks on this PR pass.

Separately, this branch has been merged up to latest main (0f316cc, which brings in #155, #199 and #204). The merge was clean with no conflicts, durabletask/serialization.py was untouched by those commits so the can_reconstruct behaviour this change depends on is unaffected, and CI is green across all 25 checks.

Copilot AI review requested due to automatic review settings July 27, 2026 17:53

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Copilot AI review requested due to automatic review settings July 27, 2026 19:51

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Bernd Verst and others added 2 commits July 27, 2026 13:01
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
Copilot AI review requested due to automatic review settings July 27, 2026 20:03
Comment thread tests/durabletask/test_type_discovery.py Dismissed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comment thread CHANGELOG.md
@berndverst
berndverst merged commit 3437206 into main Jul 27, 2026
25 checks passed
@berndverst
berndverst deleted the berndverst-cache-type-discovery-signature branch July 27, 2026 20:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Performance [P2]: Cache type-discovery signature structure

3 participants