Performance [P2]: Make entity method signature caching effective - #202
Merged
Conversation
_execute_entity_batch() constructed a new _EntityExecutor inside its per-operation loop, so the executor's _entity_method_cache was always cold and inspect.signature() was re-run for every single entity operation. The executor is now constructed once per batch and shares a bounded, thread-safe signature cache owned by the worker, so a given (entity type, operation) pair is reflected once for the lifetime of the worker rather than once per operation. The cache is bounded (_EntityMethodSignatureCache, 1024 entries, oldest evicted first) because entity types and operation names come from user code and are unbounded in principle. Writes are serialized under a lock so concurrently processed entity work items stay safe. Behavior is unchanged: the dispatch logic in _EntityExecutor.execute() is untouched, so the no-argument versus input-argument distinction and all error messages for unknown or invalid operations are preserved. _EntityExecutor's existing three-argument constructor still works; the shared cache is an optional fourth argument. Fixes #186 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2265f7b9-f8ad-4b50-a25d-19bd6f1ac96c
Contributor
There was a problem hiding this comment.
Pull request overview
This PR improves entity execution performance in the core durabletask worker by ensuring reflected entity-method signature information is actually cached across operations and batches, avoiding repeated inspect.signature() overhead in entity-heavy workloads.
Changes:
- Hoists
_EntityExecutorconstruction out of the per-operation loop so one executor is reused per entity batch. - Introduces a worker-owned
_EntityMethodSignatureCache(bounded + synchronized on writes) and wires it into_EntityExecutor. - Adds regression tests validating signature reflection counts across repeated operations, batches, and concurrent writers.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| durabletask/worker.py | Reuses a single executor per batch and adds a worker-level bounded cache for entity method signature reflection results. |
| tests/durabletask/test_entity_executor.py | Adds tests to verify signature caching behavior across executors/batches and validates cache bounds and concurrency behavior. |
…hod-signature-cache
_EntityMethodSignatureCache clamps its bound with max(1, max_size), but nothing covered that clamp, so the PR description overstated the test coverage. Add regression tests for it. The clamp is not cosmetic. Without it a bound of 0 evicts each entry immediately after inserting it, so every lookup misses and the entity method reflection this cache exists to avoid runs on every operation again -- silently reintroducing the very problem this change fixes. A negative bound is worse: the eviction loop keeps popping past an already-empty dict and raises StopIteration. The new tests cover a zero and negative bound, that a clamped cache still behaves as a working size-one cache rather than a no-op, and that an executor handed a clamped cache still reflects only once. Verified red before green: with the clamp removed all five fail, including 'assert 3 == 1' on the reflection count. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2265f7b9-f8ad-4b50-a25d-19bd6f1ac96c
…hod-signature-cache
…hod-signature-cache
andystaples
approved these changes
Jul 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
_execute_entity_batch()constructed a new_EntityExecutorinside its per-operation loop:_EntityExecutormemoizes reflected entity method signatures in_entity_method_cache, butbecause a fresh executor was built for every operation that cache was always cold — so the
inspect.signature()call that decides whether an entity method takes an input argument was paidon every single entity operation, and the memoization never did anything.
This PR makes the caching actually effective:
for operation ...loop.
_EntityMethodSignatureCacheis created once inTaskHubGrpcWorker.__init__and passed toeach batch executor, so a given
(entity type, operation)pair is reflected once for thelifetime of the worker rather than once per batch or once per operation.
The cache is bounded (1024 entries, oldest evicted first) rather than an unbounded
dict, becauseentity type objects and operation names come from user code and are unbounded in principle —
an unbounded cache keyed on arbitrary user input would be a memory leak.
Design notes
_EntityExecutor.execute()is untouched. The new cache exposesget()and__setitem__, so the two existing call sites work verbatim. Only where the booleanis stored changed; how it is computed is byte-identical, including the fact that the signature
is taken from the bound method (using the class attribute instead would count
selfas arequired parameter and invert the dispatch decision).
Reads are a single
dict.get(atomic); every mutation, including eviction, happens under aLock.allocation-free, whereas LRU would require a write (pop/reinsert) on every cache hit.
Backwards compatibility
No public API changes.
_EntityExecutor's existing three-argument constructor still works — theshared cache is an optional fourth argument, defaulting to a private per-executor cache — so
existing callers such as
tests/durabletask/test_type_discovery.pyandtests/durabletask/scheduled/test_schedule_entity.pyare unaffected. No renames, no removedsymbols, no changed import paths. The no-argument vs. input-argument dispatch distinction and all
error messages for unknown or invalid operations are preserved.
No
CHANGELOG.mdentry: per the repo's contributor instructions this is an internal performancerefactor with no externally observable behavior change.
Tests
Added 17 tests to
tests/durabletask/test_entity_executor.pyacross three classes. They countreflection precisely by monkeypatching the
inspectname insidedurabletask.workerwith acounting shim (that module calls
inspect.signaturein exactly one place — the entity dispatch),so the counts are unambiguous:
TestEntityMethodSignatureCaching— an executor reflects once per(type, operation); distinctoperations and distinct entity types get distinct entries; an explicitly shared cache is honored.
TestEntityBatchSignatureCaching— a batch of N operations on one entity type reflects once, notN times; the cache survives across separate
_execute_entity_batchcalls; the worker's cache isactually populated; and no-arg vs. input-arg dispatch and unknown-operation errors still behave
identically.
TestEntityMethodSignatureCacheBounds— eviction at the limit, non-positivemax_sizeclamping,and concurrent writers from multiple threads.
These are genuine regression tests: reverting
_execute_entity_batchto the pre-fixexecutor-per-operation form makes four of them fail (for example
assert 4 == 2on the signature-call count).Why the
max(1, max_size)clamp is load-bearingThe clamp looks like a defensive nicety, but it is the thing standing between a mis-set bound and a
silent performance regression. With a bound of
0and no clamp, every entry is evictedimmediately after it is inserted, so every lookup misses and the exact per-operation reflection
this PR removes comes straight back — silently, with no error, while every other test stays green.
The end-to-end test pins this: without the clamp it fails
assert 3 == 1on the reflection count.A negative bound is a distinct and noisier failure: the eviction loop pops past an already-empty
dict and raises
StopIteration.Both modes are covered, parametrized over
0,-1, and-1024, and both were verified red beforegreen by temporarily removing the clamp (all five clamp tests fail without it; the four pre-existing
bounds tests still pass, so the new tests are not vacuous).
Verification
All commands run in a session-local venv with both packages installed editable from this worktree,
on the branch after merging
main.pytest tests/durabletask -q --ignore-glob="*_e2e.py"→ 701 passed, 7 skipped, 0 failed.pytest tests/durabletask-azuremanaged/test_sandboxes_extension.py tests/durabletask-azuremanaged/test_durabletask_grpc_interceptor.py -q→ 45 passed.flake8 durabletask/worker.py tests/durabletask/test_entity_executor.py→ clean.pyrightwith the repo's ownpyrightconfig.json(strict) → 0 diagnostics indurabletask/worker.py.mainwas merged in rather than rebased, since the PR is already under review. Itsworker.pychanges land entirely in the orchestration path (lazy
itertools.chainover history events, theorchestration-name lookup, and the rewind passes) and do not overlap the entity batch path or
_EntityExecutor, so the net production diff here is unchanged at +62/-3.Fixes #186