Add stateful-history delta work items to the workflow worker - #1862
Add stateful-history delta work items to the workflow worker#1862JoshVanL wants to merge 5 commits into
Conversation
|
@JoshVanL This is intended for 1.19, right? I'll defer merging this until we have an RC to run integration tests against. |
There was a problem hiding this comment.
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, andGetWorkItemsRequest.capabilitiesplusWORKER_CAPABILITY_STATEFUL_HISTORY. - Adds a per-stream
WorkflowHistoryCachewith TTL/LRU eviction and integrates it intoGrpcProtocolHandler(advertise capability, resolve deltas, cache after turns, reset on reconnect, periodic sweep). - Exposes runtime knobs via
WorkflowRuntimeOptionsand wires them throughWorkflowWorker; 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) |
|
@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>
8c2de33 to
8cb7b2a
Compare
Signed-off-by: joshvanl <me@joshvanl.dev>
There was a problem hiding this comment.
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);
| make build-linux BINARIES="daprd placement scheduler" | ||
| make docker-build DAPR_REGISTRY=daprio DAPR_TAG=dapr-head \ | ||
| BINARIES="daprd placement scheduler" |
| // 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>
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):
Worker (src/Dapr.Workflow/Worker/Grpc):
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.