Skip to content

Perf: materialize streamed orchestration history incrementally#783

Open
berndverst wants to merge 2 commits into
mainfrom
berndverst-perf-stream-history-materialize
Open

Perf: materialize streamed orchestration history incrementally#783
berndverst wants to merge 2 commits into
mainfrom
berndverst-perf-stream-history-materialize

Conversation

@berndverst

Copy link
Copy Markdown
Member

Summary

Replaces the lazy Concat/Select history accumulation in BuildRuntimeStateAsync (src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs) with direct List<HistoryEvent> accumulation.

Previously, each streamed history chunk extended a lazily-evaluated IEnumerable<HistoryEvent> via Concat. Because Concat chains are re-walked (and re-converted) from scratch every time they are enumerated or chained onto again, reconstructing history for a streamed orchestration was effectively quadratic in the number of chunks — a real problem for long-running orchestrations with large or heavily-chunked history.

Change

  • Both the streaming (RequiresHistoryStreaming) and non-streaming (PastEvents) branches now build a single List<HistoryEvent> directly, appending converted events as they arrive (with a capacity hint sized per chunk in the streaming path) instead of chaining lazy Concat/Select operations.
  • NewEvents are now added to the runtime state via a direct foreach + runtimeState.AddEvent(...) instead of a lazy Select projection.
  • Event conversion (converter/ProtoUtils.ConvertHistoryEvent / entity conversion), event ordering, new-vs-past runtime state classification, cancellation propagation, and the public API surface are all unchanged.

Tests

Added two focused regression tests to test/Worker/Grpc.Tests/GrpcDurableTaskWorkerTests.cs, following existing test conventions in the file:

  • BuildRuntimeStateAsync_MultiChunkHistoryStream_MaterializesEventsInOrderAndClassifiesNewVsPast — streams history across three chunks and verifies events are materialized in order into PastEvents, NewEvents classification is preserved, and ExecutionStartedEvent is populated correctly.
  • BuildRuntimeStateAsync_HistoryStreamCanceledMidStream_PropagatesCancellation — verifies cancellation observed mid-stream propagates as OperationCanceledException rather than being swallowed.

Targeted run:

dotnet test test\Worker\Grpc.Tests\Worker.Grpc.Tests.csproj --filter "FullyQualifiedName~BuildRuntimeStateAsync"

→ 2/2 passed.

Full project suite:

dotnet test test\Worker\Grpc.Tests\Worker.Grpc.Tests.csproj

→ 139/139 passed, 0 failures.

Fixes #772

Replace the lazy Concat/Select chain in BuildRuntimeStateAsync with
direct List<HistoryEvent> accumulation. Previously, each streamed
history chunk extended a lazily-evaluated IEnumerable via Concat,
which is re-walked and re-converted from scratch every time it is
enumerated or chained onto again, making history reconstruction
quadratic in the number of chunks for long-running orchestrations.

Now each chunk's events are converted and appended directly into a
single accumulating list (with a capacity hint sized per chunk), and
new events are added to the runtime state via a direct foreach instead
of a lazy Select. Event conversion, ordering, new-vs-past runtime
state classification, cancellation propagation, and the public API
surface are all unchanged.

Added regression tests covering multi-chunk streamed history
materialization (ordering + new/past classification) and cancellation
propagation mid-stream.

Fixes #772

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1fdfdae0-4987-4043-b522-7229e63810d2
Copilot AI review requested due to automatic review settings July 24, 2026 22:06

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 worker-side orchestration replay performance by replacing lazy IEnumerable accumulation (Concat/Select) in BuildRuntimeStateAsync with eager, incremental materialization into a single List<HistoryEvent>, preventing quadratic behavior when history is streamed in many chunks.

Changes:

  • Materialize streamed and non-streamed PastEvents into a single List<HistoryEvent> as events arrive.
  • Add NewEvents to OrchestrationRuntimeState via direct iteration and conversion (no lazy projections).
  • Add regression tests covering multi-chunk streaming order/classification and cancellation propagation.

Reviewed changes

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

File Description
src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs Reworks history accumulation to eagerly append converted events instead of chaining lazy Concat/Select.
test/Worker/Grpc.Tests/GrpcDurableTaskWorkerTests.cs Adds targeted tests for multi-chunk history materialization correctness and cancellation behavior.

Comment on lines +280 to +284
pastEvents.Capacity = Math.Max(pastEvents.Capacity, pastEvents.Count + chunk.Events.Count);
foreach (P.HistoryEvent protoEvent in chunk.Events)
{
pastEvents.Add(converter(protoEvent));
}
Address review feedback on PR #783 (issue #772): setting
List<HistoryEvent>.Capacity to the exact running total on every
streamed chunk reallocates and copies the whole list whenever
capacity needs to grow, since the Capacity setter allocates exactly
the requested size rather than growing geometrically. With many
single-event chunks this made history materialization quadratic again
- the same class of bug the original fix addressed, reintroduced via
a different mechanism.

Remove the explicit Capacity assignment and rely on List<T>.Add's own
amortized (doubling) growth strategy, which gives O(n) total
allocation across n appends regardless of how many chunks they arrive
in.

Added a regression test with 5,000 single-event chunks that asserts on
bytes allocated (via GC.GetAllocatedBytesForCurrentThread) rather than
wall-clock time, to avoid timing-based flakiness while still reliably
distinguishing O(n) amortized growth from O(n^2) per-chunk
reallocation. Verified the test fails against the prior buggy code
(~100 MB allocated for n=5,000, chunk copied) and passes against the
fix (well under the 50 MB threshold).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1fdfdae0-4987-4043-b522-7229e63810d2
Copilot AI review requested due to automatic review settings July 24, 2026 23:18
// Warm up once (JIT/type initialization) before measuring allocations for the real run.
await RunAsync();

GC.Collect();

GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();

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.

Comments suppressed due to low confidence (1)

src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs:287

  • In the streaming path, pastEvents starts at the default capacity and grows via repeated Add calls. For large streamed chunks, this can still trigger multiple intermediate reallocations/copies within a single chunk. Since chunk.Events.Count is available, consider calling List<T>.EnsureCapacity(pastEvents.Count + chunk.Events.Count) per chunk to reduce reallocations while still preserving the intended geometric growth behavior (and it would also align with the PR description’s mention of a per-chunk capacity hint).
                pastEvents = new List<HistoryEvent>();
                await foreach (P.HistoryChunk chunk in streamResponse.ResponseStream.ReadAllAsync(cancellation))
                {
                    foreach (P.HistoryEvent protoEvent in chunk.Events)
                    {

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: materialize streamed orchestration history incrementally

2 participants