Skip to content

Performance [P2]: Reduce orchestration history replay allocations - #204

Merged
berndverst merged 1 commit into
mainfrom
berndverst-reduce-history-replay-allocations
Jul 27, 2026
Merged

Performance [P2]: Reduce orchestration history replay allocations#204
berndverst merged 1 commit into
mainfrom
berndverst-reduce-history-replay-allocations

Conversation

@berndverst

Copy link
Copy Markdown
Member

Summary

Long orchestration histories were copied and rescanned several times per work item, before and during replay. Each of these allocated throwaway lists sized by the full history, which is pure GC pressure and cache churn on every single work item. This PR removes those copies without changing any behavior.

Changes

1. TaskHubGrpcWorker._execute_orchestrator()

Built list(req.pastEvents) + list(req.newEvents) — three throwaway lists sized by the entire history — purely to locate the first executionStarted event and then break. Now iterates itertools.chain(req.pastEvents, req.newEvents), which allocates a constant-size iterator instead. Iteration order (past events, then new events) and the break on first match are unchanged.

2. _OrchestrationExecutor.execute()

Materialized every executionStarted event in the committed history via a list comprehension, then read only element [0]:

orchestration_started_events = [e for e in old_events if e.HasField("executionStarted")]
if len(orchestration_started_events) >= 1:
    orchestration_name = orchestration_started_events[0].executionStarted.name

Replaced with next(<generator>, "<unknown>"), which short-circuits at the first match instead of scanning and collecting the whole history. The resolved name is identical in both branches: first match when one exists, "<unknown>" otherwise.

3. _OrchestrationExecutor._build_rewind_result()

Built a combined all_events = list(old_events) + list(new_events) to feed its two passes. Both parameters are already typed Sequence[pb.HistoryEvent] (and the function already relies on that via len()/indexing), so each pass now chains them lazily with its own fresh iterator. Order is unchanged (old events first, then new events), and the first pass fully completes before the second begins — no generator-exhaustion hazard.

Deliberately not changed

The rewind detection in execute() already short-circuits correctly:

has_rewind_in_new = any(e.HasField("executionRewound") for e in new_events)
if has_rewind_in_new and any(e.HasField("executionCompleted") for e in old_events):

Both are lazy generators, and the and means old_events is only scanned when a rewind is actually in flight. Fusing the executionCompleted scan into the executionStarted scan — as the issue's proposed solution hints at — would force a full O(N) walk of the committed history on every normal work item, making the common path measurably worse. Left as-is on purpose.

Compatibility

No public API signature changes, no renames, no removed symbols, no changed import paths. Replay order, rewind handling, non-determinism detection, log messages, and exception types/messages are all preserved. This is an allocation-only change with no observable behavior difference, so per the repo's changelog policy it does not get a CHANGELOG.md entry.

Tests

Added two regression tests in tests/durabletask/test_orchestration_executor.py covering both branches of the orchestration-name lookup, which previously had no direct coverage:

  • test_orchestration_name_resolved_from_committed_history — name is taken from the first executionStarted event when it is not the first event in the committed history.
  • test_orchestration_name_unknown_without_committed_history — falls back to <unknown> when the committed history carries no executionStarted event.

Verification

All run against a session-local venv with both packages installed editable from this worktree.

Check Baseline (before) After
pytest tests/durabletask -q --ignore-glob="*_e2e.py" 680 passed, 7 skipped, 1 failed 683 passed, 7 skipped, 0 failed
pytest tests/durabletask-azuremanaged/{test_sandboxes_extension,test_durabletask_grpc_interceptor,test_azuremanaged_grpc_resiliency}.py 45 passed
flake8 durabletask/worker.py tests/durabletask/test_orchestration_executor.py clean
pyright durabletask/worker.py 0 errors 0 errors

Notes on the baseline:

  • The single baseline failure was test_client.py::test_sync_client_recreate_cooldown_prevents_immediate_repeated_recreation, a timing-sensitive test unrelated to this change; it passes after the change too.
  • tests/durabletask/test_orchestration_executor.py (171 tests incl. the executor and tracing suites) and tests/durabletask/test_build_rewind_result.py (the 13 rewind tests) pass in full — these are the primary guards for replay correctness and rewind behavior.
  • pyright reports 0 errors on durabletask/worker.py both before and after. The new test diagnostics are the same reportPrivateUsage / reportUnknown* kinds the test file already produces 433 times, matching the established convention in test_build_rewind_result.py.
  • The remaining tests/durabletask-azuremanaged files (test_dts_activity_sequence.py, test_dts_batch_actions.py, entities/test_dts_entity_failure_handling.py) require a live Durable Task Scheduler endpoint and fail identically on a clean baseline in this environment.

Fixes #185

Long orchestration histories were copied and rescanned several times per
work item before and during replay:

- `TaskHubGrpcWorker._execute_orchestrator()` built
  `list(req.pastEvents) + list(req.newEvents)` (three throwaway lists sized
  by the full history) purely to locate the first `executionStarted` event.
  It now iterates `itertools.chain(...)` and still breaks at the first match.
- `_OrchestrationExecutor.execute()` materialized every `executionStarted`
  event in the committed history but only read element `[0]`. It now uses
  `next(...)` over a generator, short-circuiting at the first match and
  falling back to `"<unknown>"` exactly as before.
- `_OrchestrationExecutor._build_rewind_result()` built a combined
  `list(old_events) + list(new_events)` for its two passes. Both inputs are
  sequences, so each pass now chains them lazily in the same order.

Replay order, rewind handling, non-determinism detection, log messages and
exception types/messages are all unchanged; this is an allocation-only
change with no observable behavior difference.

Adds regression tests covering both branches of the orchestration-name
lookup (resolved from committed history, and the "<unknown>" fallback).

Fixes #185

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d6a6454c-5bdc-4bc7-9059-a2789a65e271
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 reduces unnecessary allocations during orchestration history replay by replacing several “copy-to-list then scan” patterns with lazy iteration, while preserving event ordering and existing replay/rewind semantics.

Changes:

  • Avoids materializing combined history lists in TaskHubGrpcWorker._execute_orchestrator() by scanning pastEvents + newEvents via itertools.chain.
  • Optimizes _OrchestrationExecutor.execute() to resolve the orchestration name using next(..., "<unknown>") rather than collecting all matching executionStarted events.
  • Removes the combined all_events list in _OrchestrationExecutor._build_rewind_result() by using separate lazy chains for the two passes.
  • Adds targeted regression tests to cover orchestration-name lookup behavior.

Reviewed changes

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

File Description
durabletask/worker.py Replaces several full-history list materializations with lazy iteration to reduce replay allocations while keeping ordering/behavior intact.
tests/durabletask/test_orchestration_executor.py Adds regression tests validating orchestration-name resolution from committed history and the <unknown> fallback path.

@berndverst
berndverst merged commit 72f18e3 into main Jul 27, 2026
19 checks passed
@berndverst
berndverst deleted the berndverst-reduce-history-replay-allocations branch July 27, 2026 16:18
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]: Reduce orchestration history replay allocations

3 participants