Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,7 @@ public WorkflowOrchestrationContext(string name, string instanceId, DateTime cur
/// <inheritdoc />
public override bool IsPatched(string patchName)
{
var hasPatchHistory = _versionTracker.AggregatedPatchesOrdered.Count > 0;
return _versionTracker.RequestPatch(patchName, isReplaying: this.IsReplaying && hasPatchHistory);
return _versionTracker.RequestPatch(patchName, isReplaying: this.IsReplaying);
}

/// <summary>
Expand Down
16 changes: 12 additions & 4 deletions src/Dapr.Workflow/Worker/WorkflowWorker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -632,16 +632,24 @@ public override async Task StopAsync(CancellationToken cancellationToken)
return null;

var traceState = request.ParentTraceContext?.TraceState;
if (ActivityContext.TryParse(traceParent, traceState, out var parentCtx))
if (ActivityContext.TryParse(traceParent, traceState, isRemote: true, out var parentCtx))
{
return WorkflowActivitySource.StartActivity(
// Prefer ActivitySource so registered telemetry listeners (e.g. OpenTelemetry) receive the span.
var started = WorkflowActivitySource.StartActivity(
name: $"WorkflowActivity {request.Name}",
kind: ActivityKind.Internal,
parentContext: parentCtx,
[]);
if (started != null)
return started;

// ActivitySource.StartActivity returns null when no listener is registered for "Dapr.Workflow".
// Fall through to ensure Activity.Current is always non-null inside user activity code,
// regardless of whether OpenTelemetry or another telemetry listener is configured.
}

// Fall back to raw parent ID if parsing fails

// Always create an Activity directly (not via ActivitySource) so that Activity.Current is
// non-null in user activity code even when no telemetry listener is registered.
var act = new Activity($"WorkflowActivity {request.Name}");
act.SetParentId(traceParent);
if (!string.IsNullOrEmpty(traceState))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
// ------------------------------------------------------------------------
// Copyright 2026 The Dapr Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ------------------------------------------------------------------------

using Dapr.Testcontainers.Common;
using Dapr.Testcontainers.Common.Testing;
using Dapr.Testcontainers.Harnesses;
using Dapr.Testcontainers.Xunit.Attributes;
using Dapr.Workflow;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace Dapr.IntegrationTest.Workflow.Versioning.Patches;

/// <summary>
/// Regression test: adding an <c>IsPatched</c> guard to a workflow that was originally deployed
/// without any <c>IsPatched</c> calls must not corrupt the task-sequence during replay.
///
/// Failure mode (before fix): the runtime holds no version / patch history for the in-flight
/// instance. When <c>IsPatched</c> is called during replay, <c>WorkflowOrchestrationContext</c>
/// incorrectly passes <c>isReplaying: false</c> to <c>WorkflowVersionTracker.RequestPatch</c>
/// (because <c>hasPatchHistory == false</c>). <c>RequestPatch</c> therefore returns <c>true</c>,
/// causing the new patched-path activity to consume task-slot 0 (the original email-activity
/// slot). The original email activity is then assigned slot 1, and the <c>TimerFired</c> event
/// (also on slot 1) resolves that TCS. <c>HandleHistoryMatch</c> throws
/// <c>InvalidOperationException: "Unexpected history event type for task ID 1"</c>, leaving the
/// workflow in the <c>Failed</c> state.
///
/// Fix: pass <c>isReplaying: this.IsReplaying</c> unconditionally (without <c>&amp;&amp; hasPatchHistory</c>).
/// </summary>
public sealed class PatchAddedToWorkflowWithNoPriorHistoryTests
{
// Environment variable shared between the two "deployments" within a single test run.
// Controlled inside the test; set before starting each app instance.
private const string ModeEnvVar = "DAPR_PATCH_NO_HISTORY_TEST_MODE";

/// <summary>
/// Timer duration used in the V1 workflow. Long enough that we can stop the app before it
/// fires, short enough that the test completes in a reasonable time.
/// </summary>
private static readonly TimeSpan TimerDuration = TimeSpan.FromSeconds(10);

[MinimumDaprRuntimeFact("1.17.0")]
public async Task Workflow_PatchAddedAfterDeploy_NoPriorPatchHistory_CompletesSuccessfully()
{
var componentsDir = TestDirectoryManager.CreateTestDirectory("patch-no-prior-history");
var instanceId = Guid.NewGuid().ToString();

await using var environment = await DaprTestEnvironment.CreateWithPooledNetworkAsync(needsActorState: true);
await environment.StartAsync();

var harness = new DaprHarnessBuilder(componentsDir)
.WithEnvironment(environment)
.BuildWorkflow();

// ── Phase 1: deploy V1 (no IsPatched) ────────────────────────────────────────────────
// The workflow calls EmailActivity and then waits on a durable timer.
// We stop the app while the timer is still in-flight so the Dapr sidecar owns the
// timer state. When V2 starts, the sidecar delivers the TimerFired new-event, which
// is what triggers the bug.
Environment.SetEnvironmentVariable(ModeEnvVar, "v1");

await using (var appV1 = await BuildWorkflowAppAsync(harness))
{
using var scope = appV1.CreateScope();
var client = scope.ServiceProvider.GetRequiredService<DaprWorkflowClient>();

await client.ScheduleNewWorkflowAsync(nameof(NotifyWorkflow), instanceId, "user@example.com");

// Wait long enough for the EmailActivity to complete and for the timer to be
// recorded in the workflow history, but short enough that the timer has NOT yet
// fired (timer duration is 10 s, we wait 3 s).
await Task.Delay(TimeSpan.FromSeconds(3));
}

// ── Phase 2: deploy V2 (IsPatched added before EmailActivity) ────────────────────────
// The patched path would call SmsActivity first. For a correctly replaying workflow
// IsPatched("sms") must return false (patch not in prior history), so only EmailActivity
// runs, preserving task-slot 0 for the history's TaskCompleted(EmailActivity) event and
// task-slot 1 for the TimerFired event.
Environment.SetEnvironmentVariable(ModeEnvVar, "v2");

await using (var appV2 = await BuildWorkflowAppAsync(harness))
{
using var scope = appV2.CreateScope();
var client = scope.ServiceProvider.GetRequiredService<DaprWorkflowClient>();

using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2));
WorkflowState result;
try
{
result = await client.WaitForWorkflowCompletionAsync(instanceId, cancellation: cts.Token);
}
catch (OperationCanceledException)
{
var state = await client.GetWorkflowStateAsync(instanceId, getInputsAndOutputs: true);
Assert.Fail(
$"Timed out waiting for workflow '{instanceId}' to complete. " +
$"Current status: {state?.RuntimeStatus}.");
return;
}

Assert.Equal(WorkflowRuntimeStatus.Completed, result.RuntimeStatus);

// The in-flight workflow was started with V1 code (no IsPatched). On replay,
// IsPatched("sms") must evaluate to false, so only EmailActivity runs.
// The expected output is "email" – not "sms+email" which would indicate the
// patched path was incorrectly entered during replay.
var output = result.ReadOutputAs<string>();
Assert.Equal("email", output);
}
}

private static Task<DaprTestApplication> BuildWorkflowAppAsync(BaseHarness harness)
{
return DaprHarnessBuilder.ForHarness(harness)
.ConfigureServices(builder =>
{
builder.Services.AddDaprWorkflowBuilder(
configureRuntime: opt =>
{
opt.RegisterWorkflow<NotifyWorkflow>();
opt.RegisterActivity<EmailActivity>();
opt.RegisterActivity<SmsActivity>();
},
configureClient: (sp, clientBuilder) =>
{
var config = sp.GetRequiredService<IConfiguration>();
var grpcEndpoint = config["DAPR_GRPC_ENDPOINT"];
if (!string.IsNullOrEmpty(grpcEndpoint))
clientBuilder.UseGrpcEndpoint(grpcEndpoint);
});
})
.BuildAndStartAsync();
}

/// <summary>
/// Workflow under test.
///
/// V1 behaviour (mode = "v1"): EmailActivity → timer → return "email"
/// V2 behaviour (mode = "v2"): if IsPatched("sms") { SmsActivity } → EmailActivity → timer → return
///
/// When replaying an in-flight V1 instance under V2 code, <c>IsPatched("sms")</c> must
/// return <c>false</c> so the code path matches the original V1 execution.
/// </summary>
private sealed class NotifyWorkflow : Workflow<string, string>
{
public override async Task<string> RunAsync(WorkflowContext context, string input)
{
var mode = Environment.GetEnvironmentVariable(ModeEnvVar) ?? "v1";
var result = string.Empty;

if (mode == "v2" && context.IsPatched("sms"))
{
result += await context.CallActivityAsync<string>(nameof(SmsActivity), input);
}

result += await context.CallActivityAsync<string>(nameof(EmailActivity), input);

await context.CreateTimer(TimerDuration);

return result;
}
}

private sealed class EmailActivity : WorkflowActivity<string, string>
{
public override Task<string> RunAsync(WorkflowActivityContext context, string input)
=> Task.FromResult("email");
}

private sealed class SmsActivity : WorkflowActivity<string, string>
{
public override Task<string> RunAsync(WorkflowActivityContext context, string input)
=> Task.FromResult("sms+");
}
}
68 changes: 67 additions & 1 deletion test/Dapr.Workflow.Test/Worker/WorkflowWorkerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,73 @@
Assert.Null(response.FailureDetails);
Assert.Equal(malformedParentId, observedParentId);
}


[Fact]
public async Task HandleActivityResponseAsync_ShouldPopulateActivityCurrent_WithoutRegisteredListener()
{
// Regression test for issue #1749: Activity.Current must be non-null inside user activity
// code even when NO ActivityListener is registered for "Dapr.Workflow". The original patch
// in #1795 used WorkflowActivitySource.StartActivity(), which returns null when there is
// no listener — leaving Activity.Current null for users who haven't wired up OpenTelemetry.
// Explicitly do NOT register any ActivityListener here to reproduce the real-world scenario.
var sp = new ServiceCollection().BuildServiceProvider();
var serializer = new JsonDaprSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web));
var options = new WorkflowRuntimeOptions();

const string expectedTraceId = "0af7651916cd43dd8448eb211c80319c";
const string parentSpanId = "b7ad6b7169203331";
const string traceParent = $"00-{expectedTraceId}-{parentSpanId}-01";
const string traceState = "vendor=value";

Activity? observedCurrent = null;
string? observedTraceId = null;
string? observedParentSpanId = null;
string? observedTraceState = null;

var factory = new StubWorkflowsFactory();
factory.AddActivity("act", new InlineActivity(
inputType: typeof(int),
run: (_, _) =>
{
observedCurrent = Activity.Current;
observedTraceId = Activity.Current?.TraceId.ToHexString();
observedParentSpanId = Activity.Current?.ParentSpanId.ToHexString();
observedTraceState = Activity.Current?.TraceStateString;
return Task.FromResult<object?>(null);
}));

var worker = new WorkflowWorker(
CreateGrpcClientMock().Object,
factory,
NullLoggerFactory.Instance,
serializer,
sp,
options);

Activity.Current = null;

var request = new ActivityRequest
{
Name = "act",
TaskId = 5,
Input = string.Empty,
OrchestrationInstance = new OrchestrationInstance { InstanceId = "wf-5" },
ParentTraceContext = new TraceContext
{
TraceParent = traceParent,
TraceState = traceState
}
};

var response = await InvokeHandleActivityResponseAsync(worker, request);

Assert.Null(response.FailureDetails);
Assert.NotNull(observedCurrent);
Assert.Equal(expectedTraceId, observedTraceId);
Assert.Equal(parentSpanId, observedParentSpanId);
Assert.Equal(traceState, observedTraceState);
}

[Fact]
public void Constructor_ShouldThrowArgumentNullException_WhenGrpcClientIsNull()
{
Expand Down Expand Up @@ -2178,7 +2244,7 @@
// Version stamping via IsPatched
// -------------------------------------------------------------------------

[Fact]

Check failure on line 2247 in test/Dapr.Workflow.Test/Worker/WorkflowWorkerTests.cs

View workflow job for this annotation

GitHub Actions / Unit Tests .NET 10.0 / - test/Dapr.Workflow.Test/Dapr.Workflow.Test.csproj

Dapr.Workflow.Test.Worker.WorkflowWorkerTests.HandleOrchestratorResponseAsync_ShouldIncludeVersionInResponse_WhenWorkflowUsesIsPatched

Assert.NotNull() Failure: Value is null

Check failure on line 2247 in test/Dapr.Workflow.Test/Worker/WorkflowWorkerTests.cs

View workflow job for this annotation

GitHub Actions / Unit Tests .NET 8.0 / - test/Dapr.Workflow.Test/Dapr.Workflow.Test.csproj

Dapr.Workflow.Test.Worker.WorkflowWorkerTests.HandleOrchestratorResponseAsync_ShouldIncludeVersionInResponse_WhenWorkflowUsesIsPatched

Assert.NotNull() Failure: Value is null

Check failure on line 2247 in test/Dapr.Workflow.Test/Worker/WorkflowWorkerTests.cs

View workflow job for this annotation

GitHub Actions / Unit Tests .NET 9.0 / - test/Dapr.Workflow.Test/Dapr.Workflow.Test.csproj

Dapr.Workflow.Test.Worker.WorkflowWorkerTests.HandleOrchestratorResponseAsync_ShouldIncludeVersionInResponse_WhenWorkflowUsesIsPatched

Assert.NotNull() Failure: Value is null
public async Task HandleOrchestratorResponseAsync_ShouldIncludeVersionInResponse_WhenWorkflowUsesIsPatched()
{
// When the workflow calls ctx.IsPatched(), the version tracker sets
Expand Down
Loading