Skip to content

Commit 011f03b

Browse files
Add integration tests for DistributedWorkflowClient lock resilience (#7165)
* Initial plan * Fix compilation error: use correct parameter name transientExceptionDetector Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com> * Add integration tests for DistributedWorkflowClient lock resilience - Add SimpleWorkflow for testing distributed lock scenarios - Add tests exercising RunInstanceAsync with transient lock failures - Verify retry logic works correctly with actual workflow execution - Test both acquisition and release failure scenarios - Decorate IDistributedLockProvider to use TestDistributedLockProvider Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com> * Address code review feedback - Add explanatory comment for TestDistributedLockProvider cast - Remove unnecessary blank line for consistent formatting Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com> Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>
1 parent 95e8dde commit 011f03b

3 files changed

Lines changed: 137 additions & 1 deletion

File tree

test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
152152
.AddWorkflowsProvider<TestWorkflowProvider>()
153153
.AddNotificationHandlersFrom<WorkflowEventHandlers>()
154154
.Decorate<IChangeTokenSignaler, EventPublishingChangeTokenSignaler>()
155+
.Decorate<IDistributedLockProvider, TestDistributedLockProvider>()
155156
;
156157
});
157158
}

test/component/Elsa.Workflows.ComponentTests/Scenarios/DistributedLockResilience/DistributedLockResilienceTests.cs

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
using Elsa.Common.DistributedHosting;
2+
using Elsa.Common.Models;
23
using Elsa.Resilience;
34
using Elsa.Workflows.ComponentTests.Abstractions;
45
using Elsa.Workflows.ComponentTests.Fixtures;
56
using Elsa.Workflows.ComponentTests.Scenarios.DistributedLockResilience.Mocks;
7+
using Elsa.Workflows.ComponentTests.Scenarios.DistributedLockResilience.Workflows;
8+
using Elsa.Workflows.Management;
9+
using Elsa.Workflows.Models;
10+
using Elsa.Workflows.Runtime;
11+
using Elsa.Workflows.Runtime.Messages;
612
using Medallion.Threading;
713
using Microsoft.Extensions.DependencyInjection;
814
using Microsoft.Extensions.Logging;
@@ -15,7 +21,9 @@ public class DistributedLockResilienceTests(App app) : AppComponentTest(app)
1521
{
1622
private const int MaxRetryAttempts = 3;
1723

18-
private TestDistributedLockProvider MockProvider => Scope.ServiceProvider.GetRequiredService<TestDistributedLockProvider>();
24+
// The IDistributedLockProvider is decorated with TestDistributedLockProvider in WorkflowServer.ConfigureTestServices
25+
// This cast is safe because the decorator pattern ensures TestDistributedLockProvider wraps the actual provider
26+
private TestDistributedLockProvider MockProvider => (TestDistributedLockProvider)Scope.ServiceProvider.GetRequiredService<IDistributedLockProvider>();
1927
private ITransientExceptionDetector TransientExceptionDetector => Scope.ServiceProvider.GetRequiredService<ITransientExceptionDetector>();
2028
private ILogger<DistributedLockResilienceTests> Logger => Scope.ServiceProvider.GetRequiredService<ILogger<DistributedLockResilienceTests>>();
2129
private DistributedLockingOptions LockOptions => Scope.ServiceProvider.GetRequiredService<IOptions<DistributedLockingOptions>>().Value;
@@ -59,6 +67,107 @@ public async Task AcquireLockWithRetry_TransientFailureOnRelease_ShouldLogButNot
5967
Assert.Equal(1, MockProvider.ReleaseAttemptCount);
6068
}
6169

70+
[Theory]
71+
[InlineData(1, 2, false)] // Single failure, succeeds on retry
72+
[InlineData(2, 3, false)] // Two failures, succeeds on third attempt
73+
[InlineData(4, 4, true)] // Four failures, exhausts retries (MaxRetryAttempts = 3)
74+
public async Task RunInstanceAsync_TransientLockFailures_RetriesCorrectly(int failureCount, int expectedAttemptCount, bool shouldThrow)
75+
{
76+
// Arrange
77+
var workflowRuntime = Scope.ServiceProvider.GetRequiredService<IWorkflowRuntime>();
78+
var workflowClient = await workflowRuntime.CreateClientAsync();
79+
80+
var createRequest = new CreateAndRunWorkflowInstanceRequest
81+
{
82+
WorkflowDefinitionHandle = WorkflowDefinitionHandle.ByDefinitionId(SimpleWorkflow.DefinitionId, VersionOptions.Latest)
83+
};
84+
85+
// Reset and configure failures right before the operation to minimize interference from background workers
86+
MockProvider.Reset();
87+
MockProvider.FailAcquisitionTimes(failureCount);
88+
89+
// Act & Assert
90+
if (shouldThrow)
91+
{
92+
// Should throw after exhausting retries
93+
await Assert.ThrowsAsync<TimeoutException>(async () =>
94+
await workflowClient.CreateAndRunInstanceAsync(createRequest));
95+
}
96+
else
97+
{
98+
// Should succeed after retries
99+
var response = await workflowClient.CreateAndRunInstanceAsync(createRequest);
100+
Assert.NotNull(response);
101+
Assert.NotNull(response.WorkflowInstanceId);
102+
}
103+
104+
// Verify retries occurred (allow for some background noise, but verify minimum attempts)
105+
Assert.True(MockProvider.AcquisitionAttemptCount >= expectedAttemptCount,
106+
$"Expected at least {expectedAttemptCount} acquisition attempts, but got {MockProvider.AcquisitionAttemptCount}");
107+
}
108+
109+
[Fact]
110+
public async Task RunInstanceAsync_TransientLockFailureOnSecondRun_RetriesCorrectly()
111+
{
112+
// Arrange
113+
var workflowRuntime = Scope.ServiceProvider.GetRequiredService<IWorkflowRuntime>();
114+
115+
// First run - reset and don't inject failures
116+
MockProvider.Reset();
117+
var workflowClient = await workflowRuntime.CreateClientAsync();
118+
119+
var createRequest = new CreateAndRunWorkflowInstanceRequest
120+
{
121+
WorkflowDefinitionHandle = WorkflowDefinitionHandle.ByDefinitionId(SimpleWorkflow.DefinitionId, VersionOptions.Latest)
122+
};
123+
124+
var firstResponse = await workflowClient.CreateAndRunInstanceAsync(createRequest);
125+
Assert.NotNull(firstResponse.WorkflowInstanceId);
126+
127+
// Verify first run succeeded (at least 1 attempt, possibly more from background workers)
128+
Assert.True(MockProvider.AcquisitionAttemptCount >= 1);
129+
130+
// Setup second workflow with failure - reset right before to minimize background noise
131+
MockProvider.Reset();
132+
MockProvider.FailAcquisitionTimes(2); // Fail twice, succeed on third
133+
134+
var secondClient = await workflowRuntime.CreateClientAsync();
135+
var secondResponse = await secondClient.CreateAndRunInstanceAsync(createRequest);
136+
137+
// Act & Assert - Second run should succeed after retries
138+
Assert.NotNull(secondResponse);
139+
Assert.NotNull(secondResponse.WorkflowInstanceId);
140+
Assert.NotEqual(firstResponse.WorkflowInstanceId, secondResponse.WorkflowInstanceId);
141+
142+
// Verify retries occurred (at least 3 attempts: 2 failures + 1 success, possibly more from background)
143+
Assert.True(MockProvider.AcquisitionAttemptCount >= 3,
144+
$"Expected at least 3 acquisition attempts, but got {MockProvider.AcquisitionAttemptCount}");
145+
}
146+
147+
[Fact]
148+
public async Task RunInstanceAsync_TransientReleaseFailure_ShouldLogButNotThrow()
149+
{
150+
// Arrange
151+
MockProvider.Reset();
152+
MockProvider.FailReleaseOnce();
153+
154+
var workflowRuntime = Scope.ServiceProvider.GetRequiredService<IWorkflowRuntime>();
155+
var workflowClient = await workflowRuntime.CreateClientAsync();
156+
157+
var createRequest = new CreateAndRunWorkflowInstanceRequest
158+
{
159+
WorkflowDefinitionHandle = WorkflowDefinitionHandle.ByDefinitionId(SimpleWorkflow.DefinitionId, VersionOptions.Latest)
160+
};
161+
162+
// Act - Release failure should be caught and logged, not thrown
163+
var response = await workflowClient.CreateAndRunInstanceAsync(createRequest);
164+
165+
// Assert
166+
Assert.NotNull(response);
167+
Assert.NotNull(response.WorkflowInstanceId);
168+
Assert.Equal(1, MockProvider.ReleaseAttemptCount);
169+
}
170+
62171
private async Task<IDistributedSynchronizationHandle?> AcquireLockWithRetryAsync(string lockName) =>
63172
await RetryPipeline.ExecuteAsync(async ct =>
64173
await MockProvider.AcquireLockAsync(lockName, LockOptions.LockAcquisitionTimeout, ct),
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
using Elsa.Extensions;
2+
using Elsa.Workflows.Activities;
3+
4+
namespace Elsa.Workflows.ComponentTests.Scenarios.DistributedLockResilience.Workflows;
5+
6+
/// <summary>
7+
/// A simple workflow for testing distributed lock resilience.
8+
/// </summary>
9+
public class SimpleWorkflow : WorkflowBase
10+
{
11+
public static readonly string DefinitionId = Guid.NewGuid().ToString();
12+
13+
protected override void Build(IWorkflowBuilder builder)
14+
{
15+
builder.WithDefinitionId(DefinitionId);
16+
17+
builder.Root = new Sequence
18+
{
19+
Activities =
20+
{
21+
new WriteLine("Workflow execution started"),
22+
new WriteLine("Workflow execution completed")
23+
}
24+
};
25+
}
26+
}

0 commit comments

Comments
 (0)