Skip to content

Implement timer origins and backwards-compatible optional timers#1790

Draft
Copilot wants to merge 8 commits intomasterfrom
copilot/1787-implement-timer-origins
Draft

Implement timer origins and backwards-compatible optional timers#1790
Copilot wants to merge 8 commits intomasterfrom
copilot/1787-implement-timer-origins

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 16, 2026

Description

Implements the timer origin field on CreateTimerAction and TimerCreatedEvent, making the cause of every timer explicit (explicit create, external event timeout, activity retry delay, child workflow retry delay). Also implements synthetic "optional timers" for indefinite WaitForExternalEvent calls with a replay-tolerant drop-and-shift algorithm for backwards compatibility with pre-patch histories.

This feature targets Dapr runtime 1.18 and all tests are gated with MinimumDaprRuntimeFact("1.18").

Protobuf

  • Four new origin messages: TimerOriginCreateTimer, TimerOriginExternalEvent{name}, TimerOriginActivityRetry{taskExecutionId}, TimerOriginChildWorkflowRetry{instanceId}
  • origin oneof added to both CreateTimerAction and TimerCreatedEvent

Origin assignment

  • CreateTimer()TimerOriginCreateTimer{}
  • WaitForExternalEvent(name, timeout > 0)TimerOriginExternalEvent{name}
  • WaitForExternalEvent(name, timeout < 0)TimerOriginExternalEvent{name} + sentinel fireAt = 9999-12-31T23:59:59.999999999Z
  • WaitForExternalEvent(name, timeout == 0)TaskCanceledException, no timer emitted
  • Activity retry delay → TimerOriginActivityRetry{taskExecutionId} (stable across all retry attempts)
  • Child workflow retry delay → TimerOriginChildWorkflowRetry{instanceId} (first-child rule)

Replay tolerance

  • Sentinel constant, recognition predicates (IsOptionalExternalEventTimerAction, IsOptionalExternalEventTimerCreatedEvent), and DropOptionalExternalEventTimerAt drop-and-shift primitive housed in TimerOriginHelpers static class
  • Three call sites in ProcessEvents: OnTaskScheduled, OnSubOrchestrationCreated, OnTimerCreated (including asymmetric TimerCreated case where pending is optional but incoming is not)

TimerOriginHelpers

  • Dedicated TimerOriginHelpers internal static class in Worker/Internal/ keeps protobuf-specific timer origin concerns (sentinel constant, SetTimerOrigin, optional-timer recognition predicates) separate from WorkflowOrchestrationContext, avoiding muddying the context type with third-party types

RetryInterceptor

  • Added optional retryTimerFactory delegate so callers can supply origin-aware timer creation without changing the public API

CreateTimerInternal

  • Single private CreateTimerInternal(Timestamp, IMessage, CancellationToken) method handles all timer creation — callers (CreateTimer, WaitForExternalEventAsync, CreateActivityRetryTimer, CreateChildWorkflowRetryTimer) convert to Timestamp at the call site

Test gating

  • Added Dapr.Testcontainers.Xunit project reference to Dapr.Workflow.Test
  • All 13 unit tests in TimerOriginTests use [MinimumDaprRuntimeFact("1.18")] to skip when running against Dapr runtime < 1.18

Integration tests

  • Added TimerOriginIntegrationTests in Dapr.IntegrationTest.Workflow with four end-to-end tests against a real Dapr sidecar, all gated with [MinimumDaprRuntimeFact("1.18")]:
    1. CreateTimer_ShouldCompleteWorkflow_AfterTimerFires — verifies CreateTimer with TimerOriginCreateTimer completes end-to-end
    2. WaitForExternalEvent_FiniteTimeout_ShouldComplete_WhenEventArrivesBeforeTimeout — event arrives before the finite-timeout timer fires
    3. WaitForExternalEvent_FiniteTimeout_ShouldTimeout_WhenNoEventArrives — no event raised, the finite-timeout timer fires and the workflow catches TaskCanceledException
    4. WaitForExternalEvent_Indefinite_ShouldComplete_WhenEventArrives — indefinite wait (synthetic optional timer with sentinel fireAt) completes when the event is raised

Issue reference

#1787

Checklist

  • Code compiles correctly
  • Created/updated tests
  • Extended the documentation

Copilot AI and others added 3 commits April 16, 2026 09:29
…vent

Add timer origin protobuf messages (TimerOriginCreateTimer,
TimerOriginExternalEvent, TimerOriginActivityRetry,
TimerOriginChildWorkflowRetry) and origin oneof fields to
CreateTimerAction and TimerCreatedEvent.

Implement origin assignment in WorkflowOrchestrationContext:
- CreateTimer sets TimerOriginCreateTimer
- WaitForExternalEvent sets TimerOriginExternalEvent (with sentinel for indefinite waits)
- Activity retry timers set TimerOriginActivityRetry with stable taskExecutionId
- Child workflow retry timers set TimerOriginChildWorkflowRetry with first-child instanceId

Add drop-and-shift replay tolerance for optional external event timers
when replaying pre-patch histories that lack the synthetic timer.

Implements #1787

Agent-Logs-Url: https://github.com/dapr/dotnet-sdk/sessions/d91a8228-5499-4c7f-bf21-c9bd4fd6147a

Co-authored-by: WhitWaldo <2238529+WhitWaldo@users.noreply.github.com>
… compatibility

Agent-Logs-Url: https://github.com/dapr/dotnet-sdk/sessions/d91a8228-5499-4c7f-bf21-c9bd4fd6147a

Co-authored-by: WhitWaldo <2238529+WhitWaldo@users.noreply.github.com>
… in replay handlers

Agent-Logs-Url: https://github.com/dapr/dotnet-sdk/sessions/d91a8228-5499-4c7f-bf21-c9bd4fd6147a

Co-authored-by: WhitWaldo <2238529+WhitWaldo@users.noreply.github.com>
@codecov
Copy link
Copy Markdown

codecov bot commented Apr 16, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 62.37%. Comparing base (a5f2008) to head (1a4c96e).

Additional details and impacted files
@@           Coverage Diff           @@
##           master    #1790   +/-   ##
=======================================
  Coverage   62.37%   62.37%           
=======================================
  Files         285      285           
  Lines        8421     8421           
  Branches      980      980           
=======================================
  Hits         5253     5253           
  Misses       2935     2935           
  Partials      233      233           
Flag Coverage Δ
net10.0 62.36% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@WhitWaldo
Copy link
Copy Markdown
Contributor

@copilot This should target the feature-1.18.0 branch instead of master as it relates to future runtime work and isn't relevant to 1.17.

Further, please ensure that any unit or integration tests are similarly gated with the MinimumDaprRuntimeFact attribute to 1.18.

Add Dapr.Testcontainers.Xunit project reference to test project and
replace [Fact] with [MinimumDaprRuntimeFact("1.18")] on all 13 timer
origin tests so they are skipped when running against Dapr < 1.18.

Agent-Logs-Url: https://github.com/dapr/dotnet-sdk/sessions/b1380761-16ac-42d3-9368-f77c2bd1948d

Co-authored-by: WhitWaldo <2238529+WhitWaldo@users.noreply.github.com>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented Apr 16, 2026

@copilot This should target the feature-1.18.0 branch instead of master as it relates to future runtime work and isn't relevant to 1.17.

Further, please ensure that any unit or integration tests ar...

Done in f288677. All 13 timer origin tests now use [MinimumDaprRuntimeFact("1.18")] instead of [Fact], and the test project references Dapr.Testcontainers.Xunit.

The branch is already based on the feature-1.18.0 tip (1f1fe91), but the PR base branch needs to be manually changed to feature-1.18.0 in the GitHub UI — I'm unable to do that programmatically.

@WhitWaldo
Copy link
Copy Markdown
Contributor

@copilot I don't see that you added any integration tests as part of this work. Please do so.

Add four end-to-end integration tests against a real Dapr sidecar:
1. CreateTimer completes after timer fires
2. WaitForExternalEvent with finite timeout completes when event arrives
3. WaitForExternalEvent with finite timeout returns timed-out when no event
4. WaitForExternalEvent indefinite completes when event arrives

All tests use [MinimumDaprRuntimeFact("1.18")] to skip on older runtimes.

Agent-Logs-Url: https://github.com/dapr/dotnet-sdk/sessions/fad6b6d4-43de-40b9-82ec-f4669b8a9b71

Co-authored-by: WhitWaldo <2238529+WhitWaldo@users.noreply.github.com>
…ration tests

Agent-Logs-Url: https://github.com/dapr/dotnet-sdk/sessions/fad6b6d4-43de-40b9-82ec-f4669b8a9b71

Co-authored-by: WhitWaldo <2238529+WhitWaldo@users.noreply.github.com>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented Apr 16, 2026

@copilot I don't see that you added any integration tests as part of this work. Please do so.

Added in c2bafbf (cleanup in f1b9263). Four integration tests in test/Dapr.IntegrationTest.Workflow/TimerOriginIntegrationTests.cs, all gated with [MinimumDaprRuntimeFact("1.18")]:

  1. CreateTimer_ShouldCompleteWorkflow_AfterTimerFires — verifies a CreateTimer (with TimerOriginCreateTimer) completes end-to-end
  2. WaitForExternalEvent_FiniteTimeout_ShouldComplete_WhenEventArrivesBeforeTimeout — event arrives before the finite-timeout timer fires
  3. WaitForExternalEvent_FiniteTimeout_ShouldTimeout_WhenNoEventArrives — no event raised, the finite-timeout timer fires and the workflow catches TaskCanceledException
  4. WaitForExternalEvent_Indefinite_ShouldComplete_WhenEventArrives — indefinite wait (synthetic optional timer with sentinel fireAt) completes when the event is raised

Copy link
Copy Markdown
Contributor

@WhitWaldo WhitWaldo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See if you can give this another pass and clean this up some.

/// Sentinel fireAt value for indefinite external event timers.
/// Must be exactly 9999-12-31T23:59:59.999999999Z.
/// </summary>
internal static readonly Google.Protobuf.WellKnownTypes.Timestamp ExternalEventIndefiniteFireAt =
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.

@copilot Not sure I want this in here as I feel it muddies the context type with third party types.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved the ExternalEventIndefiniteFireAt sentinel, SetTimerOrigin, and optional-timer recognition predicates to a new TimerOriginHelpers internal static class in c7c3971. The context class now uses using static TimerOriginHelpers; to keep call sites clean without exposing protobuf types as fields on the context.

/// <summary>
/// Creates a durable timer with the specified origin metadata.
/// </summary>
internal async Task CreateTimerInternal(DateTime fireAt, IMessage origin, CancellationToken cancellationToken)
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.

Do we really need a CreateTimerInternal implementation at both an internal and private visibility or can these be combined?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Combined into a single private CreateTimerInternal(Timestamp, IMessage, CancellationToken) in c7c3971. All callers (CreateTimer, WaitForExternalEventAsync, CreateActivityRetryTimer, CreateChildWorkflowRetryTimer) now convert to Timestamp at the call site before invoking the single method.

…rge CreateTimerInternal overloads

Move ExternalEventIndefiniteFireAt sentinel, SetTimerOrigin, and
optional-timer recognition predicates from WorkflowOrchestrationContext
to a dedicated TimerOriginHelpers static class. Combine the two
CreateTimerInternal overloads into a single private method that accepts
Google.Protobuf.WellKnownTypes.Timestamp.

Agent-Logs-Url: https://github.com/dapr/dotnet-sdk/sessions/8a7bbcaf-ff06-494a-81cb-3468dba4b31a

Co-authored-by: WhitWaldo <2238529+WhitWaldo@users.noreply.github.com>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented Apr 16, 2026

See if you can give this another pass and clean this up some.

Done in c7c3971. Extracted protobuf helpers (ExternalEventIndefiniteFireAt, SetTimerOrigin, IsOptionalExternalEventTimerAction, IsOptionalExternalEventTimerCreatedEvent) to a dedicated TimerOriginHelpers static class and merged the two CreateTimerInternal overloads into one private method.

Copilot AI requested a review from WhitWaldo April 16, 2026 11:00
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.

2 participants