Skip to content

Add stateful-history delta work items to the workflow worker - #1862

Open
JoshVanL wants to merge 5 commits into
dapr:masterfrom
JoshVanL:stateful-history
Open

Add stateful-history delta work items to the workflow worker#1862
JoshVanL wants to merge 5 commits into
dapr:masterfrom
JoshVanL:stateful-history

Conversation

@JoshVanL

@JoshVanL JoshVanL commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

The sidecar re-sends a workflow instance's entire committed history to the worker on every turn. This adds the worker half of the "stateful history" optimization so that, once a worker is warm for an instance on a work-item stream, the sidecar sends only the new committed events (the delta) and the worker reconstructs the full history from its own cache. It mirrors the Go (durabletask-go) and Python SDK implementations and is on by default.

Protocol (src/Dapr.Workflow.Grpc/orchestrator_service.proto, compiled by Grpc.Tools at build time):

  • Add the CachedHistory message and WorkflowRequest.cachedHistory.
  • Add GetWorkItemsRequest.capabilities and WORKER_CAPABILITY_STATEFUL_HISTORY (reserving the never-implemented WORKER_CAPABILITY_HISTORY_STREAMING).

Worker (src/Dapr.Workflow/Worker/Grpc):

  • WorkflowHistoryCache: a per-stream cache of each instance's committed history, bounded by a sliding TTL, an instance-count cap, and a byte budget with LRU eviction. Injectable clock for deterministic tests.
  • GrpcProtocolHandler: advertise the capability; before each turn resolve the full committed history (cached prefix + delta on a hit, or a GetInstanceHistory fetch on a miss) and normalize request.PastEvents so the handler stays oblivious to the delta protocol; after each turn cache the committed history, or drop it on a CompleteWorkflow action (completed, failed, terminated, or continued-as-new). Reset the cache on reconnect and reclaim idle entries with a PeriodicTimer janitor.

Correctness never depends on the cache: any miss (cold stream, eviction, desync) self-heals via the existing GetInstanceHistory fallback, so this only changes per-turn bandwidth, not results.

@JoshVanL
JoshVanL requested review from a team as code owners July 1, 2026 12:27
@JoshVanL
JoshVanL marked this pull request as draft July 1, 2026 12:28
@WhitWaldo

Copy link
Copy Markdown
Contributor

@JoshVanL This is intended for 1.19, right? I'll defer merging this until we have an RC to run integration tests against.

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 implements the worker side of the “stateful history” optimization for Dapr Workflow’s work-item stream: when a worker is warm for an instance, the sidecar can send only the newly committed history events (delta) and the worker reconstructs the full committed history using a per-stream cache, falling back to GetInstanceHistory on misses.

Changes:

  • Extends the gRPC protocol with CachedHistory, WorkflowRequest.cachedHistory, and GetWorkItemsRequest.capabilities plus WORKER_CAPABILITY_STATEFUL_HISTORY.
  • Adds a per-stream WorkflowHistoryCache with TTL/LRU eviction and integrates it into GrpcProtocolHandler (advertise capability, resolve deltas, cache after turns, reset on reconnect, periodic sweep).
  • Exposes runtime knobs via WorkflowRuntimeOptions and wires them through WorkflowWorker; adds tests for cache bounds and end-to-end delta behavior.

Reviewed changes

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

Show a summary per file
File Description
test/Dapr.Workflow.Test/Worker/Grpc/WorkflowHistoryCacheTests.cs Adds unit tests for TTL/LRU/count/byte-bound behavior of the history cache.
test/Dapr.Workflow.Test/Worker/Grpc/GrpcProtocolHandlerStatefulHistoryTests.cs Adds flow tests for capability advertisement and delta reconstruction behavior through the real streaming loop.
src/Dapr.Workflow/Worker/WorkflowWorker.cs Wires new runtime options into GrpcProtocolHandler construction.
src/Dapr.Workflow/Worker/Grpc/WorkflowHistoryCache.cs Introduces the per-stream committed-history cache with eviction and byte accounting.
src/Dapr.Workflow/Worker/Grpc/GrpcProtocolHandler.cs Adds capability advertisement, delta resolution, cache maintenance, and janitor sweep logic.
src/Dapr.Workflow.Grpc/orchestrator_service.proto Defines the stateful-history protocol surface (CachedHistory, capabilities, enum updates).
src/Dapr.Workflow.Abstractions/WorkflowRuntimeOptions.cs Adds configuration properties to control stateful-history and cache bounds.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

private sealed class Entry
{
public required IReadOnlyList<HistoryEvent> Events { get; init; }
public required int Bytes { get; init; }
public void Put(string instanceId, IEnumerable<HistoryEvent> events)
{
var snapshot = new List<HistoryEvent>(events);
var bytes = 0;
// carries only the events new since this worker was last warm for the instance, so we
// rebuild the full history from our per-stream cache, or fetch it on a miss. Overwriting
// request.PastEvents here keeps the workflow handler oblivious to the delta protocol.
if (!_disableStatefulHistory && request.CachedHistory is not null)
@JoshVanL

JoshVanL commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

@WhitWaldo yes exactly, we can keep in draft in the meantime

The sidecar re-sends a workflow instance's entire committed history to the
worker on every turn. This adds the worker half of the "stateful history"
optimization so that, once a worker is warm for an instance on a work-item
stream, the sidecar sends only the new committed events (the delta) and the
worker reconstructs the full history from its own cache. It mirrors the Go
(durabletask-go) and Python SDK implementations and is on by default.

Protocol (src/Dapr.Workflow.Grpc/orchestrator_service.proto, compiled by
Grpc.Tools at build time):
- Add the CachedHistory message and WorkflowRequest.cachedHistory.
- Add GetWorkItemsRequest.capabilities and
  WORKER_CAPABILITY_STATEFUL_HISTORY (reserving the never-implemented
  WORKER_CAPABILITY_HISTORY_STREAMING).

Worker (src/Dapr.Workflow/Worker/Grpc):
- WorkflowHistoryCache: a per-stream cache of each instance's committed
  history, bounded by a sliding TTL, an instance-count cap, and a byte
  budget with LRU eviction. Injectable clock for deterministic tests.
- GrpcProtocolHandler: advertise the capability; before each turn resolve
  the full committed history (cached prefix + delta on a hit, or a
  GetInstanceHistory fetch on a miss) and normalize request.PastEvents so
  the handler stays oblivious to the delta protocol; after each turn cache
  the committed history, or drop it on a CompleteWorkflow action
  (completed, failed, terminated, or continued-as-new). Reset the cache on
  reconnect and reclaim idle entries with a PeriodicTimer janitor.

Correctness never depends on the cache: any miss (cold stream, eviction,
desync) self-heals via the existing GetInstanceHistory fallback, so this
only changes per-turn bandwidth, not results.

Signed-off-by: joshvanl <me@joshvanl.dev>
Signed-off-by: joshvanl <me@joshvanl.dev>
Signed-off-by: joshvanl <me@joshvanl.dev>
@JoshVanL
JoshVanL marked this pull request as ready for review August 11, 2026 16:08
@JoshVanL
JoshVanL requested a balanced review from Copilot August 11, 2026 16:08

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 13 out of 13 changed files in this pull request and generated 2 comments.

Suppressed comments (1)

src/Dapr.Workflow/Worker/Grpc/WorkflowHistoryCache.cs:203

  • Evicting one entry at a time while rescanning the entire dictionary makes byte-budget eviction quadratic. A newly cached history that consumes most or all of the budget can evict up to 100,000 existing entries, performing roughly O(n²) comparisons while holding the global cache lock and blocking every workflow turn using the cache. Maintain LRU order incrementally (for example, dictionary plus linked list), or identify all required victims in one ordered pass.
            var victim = LeastRecentlyUsedExcept(keep);
            if (victim is null)
            {
                return;
            }

            RemoveLocked(victim);

Comment on lines +289 to +291
make build-linux BINARIES="daprd placement scheduler"
make docker-build DAPR_REGISTRY=daprio DAPR_TAG=dapr-head \
BINARIES="daprd placement scheduler"
Comment on lines +180 to +182
// The next stream starts cold: the sidecar drops this stream's warm set,
// so the cached histories from this connection are no longer in sync.
_historyCache.Reset();
Signed-off-by: joshvanl <me@joshvanl.dev>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants