Skip to content

Performance [P2]: Make entity method signature caching effective - #202

Merged
berndverst merged 5 commits into
mainfrom
berndverst-entity-method-signature-cache
Jul 27, 2026
Merged

Performance [P2]: Make entity method signature caching effective#202
berndverst merged 5 commits into
mainfrom
berndverst-entity-method-signature-cache

Conversation

@berndverst

@berndverst berndverst commented Jul 27, 2026

Copy link
Copy Markdown
Member

Summary

_execute_entity_batch() constructed a new _EntityExecutor inside its per-operation loop:

for operation in req.operations:
    start_time = datetime.now(timezone.utc)
    executor = _EntityExecutor(self._registry, self._logger, self._data_converter)

_EntityExecutor memoizes reflected entity method signatures in _entity_method_cache, but
because 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 paid
on every single entity operation, and the memoization never did anything.

This PR makes the caching actually effective:

  1. One executor per batch. Executor construction is hoisted out of the for operation ...
    loop.
  2. A worker-owned, bounded, thread-safe signature cache. A new
    _EntityMethodSignatureCache is created once in TaskHubGrpcWorker.__init__ and passed to
    each batch executor, so a given (entity type, operation) pair is reflected once for the
    lifetime 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, because
entity 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

  • The dispatch logic in _EntityExecutor.execute() is untouched. The new cache exposes
    get() and __setitem__, so the two existing call sites work verbatim. Only where the boolean
    is 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 self as a
    required parameter and invert the dispatch decision).
  • Thread safety. Entity work items are processed concurrently on the worker's thread pool.
    Reads are a single dict.get (atomic); every mutation, including eviction, happens under a
    Lock.
  • Eviction is FIFO, not LRU, deliberately: it keeps the hot read path lock-free and
    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 — the
shared cache is an optional fourth argument, defaulting to a private per-executor cache — so
existing callers such as tests/durabletask/test_type_discovery.py and
tests/durabletask/scheduled/test_schedule_entity.py are unaffected. No renames, no removed
symbols, 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.md entry: per the repo's contributor instructions this is an internal performance
refactor with no externally observable behavior change.

Tests

Added 17 tests to tests/durabletask/test_entity_executor.py across three classes. They count
reflection precisely by monkeypatching the inspect name inside durabletask.worker with a
counting shim (that module calls inspect.signature in exactly one place — the entity dispatch),
so the counts are unambiguous:

  • TestEntityMethodSignatureCaching — an executor reflects once per (type, operation); distinct
    operations 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, not
    N times; the cache survives across separate _execute_entity_batch calls; the worker's cache is
    actually populated; and no-arg vs. input-arg dispatch and unknown-operation errors still behave
    identically.
  • TestEntityMethodSignatureCacheBounds — eviction at the limit, non-positive max_size clamping,
    and concurrent writers from multiple threads.

These are genuine regression tests: reverting _execute_entity_batch to the pre-fix
executor-per-operation form makes four of them fail (for example
assert 4 == 2 on the signature-call count).

Why the max(1, max_size) clamp is load-bearing

The 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 0 and no clamp, every entry is evicted
immediately 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 == 1 on 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 before
green 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.

  • Core unit tests — pytest tests/durabletask -q --ignore-glob="*_e2e.py"701 passed, 7 skipped, 0 failed.
  • Provider unit tests — pytest tests/durabletask-azuremanaged/test_sandboxes_extension.py tests/durabletask-azuremanaged/test_durabletask_grpc_interceptor.py -q45 passed.
  • flake8 durabletask/worker.py tests/durabletask/test_entity_executor.py → clean.
  • pyright with the repo's own pyrightconfig.json (strict) → 0 diagnostics in durabletask/worker.py.

main was merged in rather than rebased, since the PR is already under review. Its worker.py
changes land entirely in the orchestration path (lazy itertools.chain over history events, the
orchestration-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

_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
Copilot AI review requested due to automatic review settings July 27, 2026 06:13

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 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 _EntityExecutor construction 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.

Comment thread tests/durabletask/test_entity_executor.py
Bernd Verst and others added 2 commits July 27, 2026 09:35
_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
Copilot AI review requested due to automatic review settings July 27, 2026 16:40

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 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:50

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.

@berndverst
berndverst merged commit ec28103 into main Jul 27, 2026
25 checks passed
@berndverst
berndverst deleted the berndverst-entity-method-signature-cache branch July 27, 2026 19:59
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]: Make entity method signature caching effective

3 participants