@@ -13,131 +13,82 @@ namespace Elsa.Workflows.ComponentTests.Scenarios.DistributedLockResilience;
1313
1414public class DistributedLockResilienceTests ( App app ) : AppComponentTest ( app )
1515{
16- [ Fact ]
17- public async Task AcquireLockWithRetry_TransientFailureOnAcquisition_ShouldRetryAndSucceed ( )
16+ private TestDistributedLockProvider MockProvider => Scope . ServiceProvider . GetRequiredService < TestDistributedLockProvider > ( ) ;
17+ private IDistributedLockProvider LockProvider => Scope . ServiceProvider . GetRequiredService < IDistributedLockProvider > ( ) ;
18+ private ITransientExceptionDetectionService TransientDetectionService => Scope . ServiceProvider . GetRequiredService < ITransientExceptionDetectionService > ( ) ;
19+ private ILogger < DistributedLockResilienceTests > Logger => Scope . ServiceProvider . GetRequiredService < ILogger < DistributedLockResilienceTests > > ( ) ;
20+ private DistributedLockingOptions LockOptions => Scope . ServiceProvider . GetRequiredService < IOptions < DistributedLockingOptions > > ( ) . Value ;
21+ private ResiliencePipeline RetryPipeline => CreateRetryPipeline ( TransientDetectionService , Logger ) ;
22+
23+ [ Theory ]
24+ [ InlineData ( 1 , 2 , false ) ] // Single failure, succeeds on retry
25+ [ InlineData ( 2 , 3 , false ) ] // Two failures, succeeds on third attempt
26+ [ InlineData ( 4 , 4 , true ) ] // Four failures, exhausts retries (MaxRetryAttempts = 3)
27+ public async Task AcquireLockWithRetry_AcquisitionFailures_BehavesAsExpected ( int failureCount , int expectedAttemptCount , bool shouldThrow )
1828 {
1929 // Arrange
20- var mockProvider = Scope . ServiceProvider . GetRequiredService < TestDistributedLockProvider > ( ) ;
21- mockProvider . Reset ( ) ;
22- mockProvider . FailAcquisitionOnce ( ) ;
23-
24- var lockProvider = Scope . ServiceProvider . GetRequiredService < IDistributedLockProvider > ( ) ;
25- var transientDetectionService = Scope . ServiceProvider . GetRequiredService < ITransientExceptionDetectionService > ( ) ;
26- var logger = Scope . ServiceProvider . GetRequiredService < ILogger < DistributedLockResilienceTests > > ( ) ;
27- var lockOptions = Scope . ServiceProvider . GetRequiredService < IOptions < DistributedLockingOptions > > ( ) ;
28-
29- var retryPipeline = CreateRetryPipeline ( transientDetectionService , logger ) ;
30-
31- // Act - should succeed after retry
32- var handle = await retryPipeline . ExecuteAsync ( async ct =>
33- await lockProvider . AcquireLockAsync ( "test-lock-1" , lockOptions . Value . LockAcquisitionTimeout , ct ) ,
34- CancellationToken . None ) ;
30+ MockProvider . Reset ( ) ;
31+ MockProvider . FailAcquisitionTimes ( failureCount ) ;
3532
36- // Assert
37- Assert . NotNull ( handle ) ;
38- Assert . Equal ( 2 , mockProvider . AcquisitionAttemptCount ) ; // First attempt fails, second succeeds
39-
40- await handle . DisposeAsync ( ) ;
41- }
42-
43- [ Fact ]
44- public async Task AcquireLockWithRetry_TransientFailureOnRelease_ShouldLogButNotThrow ( )
45- {
46- // Arrange
47- var mockProvider = Scope . ServiceProvider . GetRequiredService < TestDistributedLockProvider > ( ) ;
48- mockProvider . Reset ( ) ;
49- mockProvider . FailReleaseOnce ( ) ;
50-
51- var lockProvider = Scope . ServiceProvider . GetRequiredService < IDistributedLockProvider > ( ) ;
52- var lockOptions = Scope . ServiceProvider . GetRequiredService < IOptions < DistributedLockingOptions > > ( ) ;
53- var logger = Scope . ServiceProvider . GetRequiredService < ILogger < DistributedLockResilienceTests > > ( ) ;
54-
55- var handle = await lockProvider . AcquireLockAsync ( "test-lock-2" , lockOptions . Value . LockAcquisitionTimeout ) ;
56-
57- // Act & Assert - should not throw despite release failure (mimics production try-catch behavior)
58- try
33+ // Act & Assert
34+ if ( shouldThrow )
5935 {
60- await handle . DisposeAsync ( ) ;
36+ await Assert . ThrowsAsync < TimeoutException > ( async ( ) => await AcquireLockWithRetryAsync ( $ "test-lock- { failureCount } " ) ) ;
6137 }
62- catch ( Exception ex )
38+ else
6339 {
64- logger . LogWarning ( ex , "Failed to release distributed lock (expected test behavior)" ) ;
40+ await using var handle = await AcquireLockWithRetryAsync ( $ "test-lock-{ failureCount } ") ;
41+ Assert . NotNull ( handle ) ;
6542 }
6643
67- Assert . Equal ( 1 , mockProvider . ReleaseAttemptCount ) ;
44+ Assert . Equal ( expectedAttemptCount , MockProvider . AcquisitionAttemptCount ) ;
6845 }
6946
7047 [ Fact ]
71- public async Task AcquireLockWithRetry_MultipleTransientFailures_ShouldRetryAndSucceed ( )
48+ public async Task AcquireLockWithRetry_TransientFailureOnRelease_ShouldLogButNotThrow ( )
7249 {
7350 // Arrange
74- var mockProvider = Scope . ServiceProvider . GetRequiredService < TestDistributedLockProvider > ( ) ;
75- mockProvider . Reset ( ) ;
76- mockProvider . FailAcquisitionTimes ( 2 ) ; // Fail twice, succeed on third attempt
51+ MockProvider . Reset ( ) ;
52+ MockProvider . FailReleaseOnce ( ) ;
7753
78- var lockProvider = Scope . ServiceProvider . GetRequiredService < IDistributedLockProvider > ( ) ;
79- var transientDetectionService = Scope . ServiceProvider . GetRequiredService < ITransientExceptionDetectionService > ( ) ;
80- var logger = Scope . ServiceProvider . GetRequiredService < ILogger < DistributedLockResilienceTests > > ( ) ;
81- var lockOptions = Scope . ServiceProvider . GetRequiredService < IOptions < DistributedLockingOptions > > ( ) ;
54+ // Act & Assert - mimics production try-catch behavior where release exceptions are logged but not thrown
55+ await using var handle = await LockProvider . AcquireLockAsync ( "test-lock-release" , LockOptions . LockAcquisitionTimeout ) ;
56+ await SafeDisposeAsync ( handle ) ;
8257
83- var retryPipeline = CreateRetryPipeline ( transientDetectionService , logger ) ;
58+ Assert . Equal ( 1 , MockProvider . ReleaseAttemptCount ) ;
59+ }
8460
85- // Act - should succeed after multiple retries
86- var handle = await retryPipeline . ExecuteAsync ( async ct =>
87- await lockProvider . AcquireLockAsync ( "test-lock-3" , lockOptions . Value . LockAcquisitionTimeout , ct ) ,
61+ private async Task < IDistributedSynchronizationHandle ? > AcquireLockWithRetryAsync ( string lockName ) =>
62+ await RetryPipeline . ExecuteAsync ( async ct =>
63+ await LockProvider . AcquireLockAsync ( lockName , LockOptions . LockAcquisitionTimeout , ct ) ,
8864 CancellationToken . None ) ;
8965
90- // Assert
91- Assert . NotNull ( handle ) ;
92- Assert . Equal ( 3 , mockProvider . AcquisitionAttemptCount ) ; // Two attempts fail, third succeeds
93-
94- await handle . DisposeAsync ( ) ;
95- }
96-
97- [ Fact ]
98- public async Task AcquireLockWithRetry_ExhaustedRetries_ShouldThrowException ( )
66+ private async Task SafeDisposeAsync ( IDistributedSynchronizationHandle handle )
9967 {
100- // Arrange
101- var mockProvider = Scope . ServiceProvider . GetRequiredService < TestDistributedLockProvider > ( ) ;
102- mockProvider . Reset ( ) ;
103- mockProvider . FailAcquisitionTimes ( 4 ) ; // Fail 4 times (exceeds max retry attempts of 3)
104-
105- var lockProvider = Scope . ServiceProvider . GetRequiredService < IDistributedLockProvider > ( ) ;
106- var transientDetectionService = Scope . ServiceProvider . GetRequiredService < ITransientExceptionDetectionService > ( ) ;
107- var logger = Scope . ServiceProvider . GetRequiredService < ILogger < DistributedLockResilienceTests > > ( ) ;
108- var lockOptions = Scope . ServiceProvider . GetRequiredService < IOptions < DistributedLockingOptions > > ( ) ;
109-
110- var retryPipeline = CreateRetryPipeline ( transientDetectionService , logger ) ;
111-
112- // Act & Assert - should throw after exhausting retries
113- await Assert . ThrowsAsync < TimeoutException > ( async ( ) =>
68+ try
11469 {
115- await retryPipeline . ExecuteAsync ( async ct =>
116- await lockProvider . AcquireLockAsync ( "test-lock-4" , lockOptions . Value . LockAcquisitionTimeout , ct ) ,
117- CancellationToken . None ) ;
118- } ) ;
119-
120- Assert . Equal ( 4 , mockProvider . AcquisitionAttemptCount ) ;
70+ await handle . DisposeAsync ( ) ;
71+ }
72+ catch ( Exception ex )
73+ {
74+ Logger . LogWarning ( ex , "Failed to release distributed lock (expected test behavior)" ) ;
75+ }
12176 }
12277
123- private static ResiliencePipeline CreateRetryPipeline (
124- ITransientExceptionDetectionService transientExceptionDetectionService ,
125- ILogger logger )
126- {
127- return new ResiliencePipelineBuilder ( )
78+ private static ResiliencePipeline CreateRetryPipeline ( ITransientExceptionDetectionService transientExceptionDetectionService , ILogger logger ) =>
79+ new ResiliencePipelineBuilder ( )
12880 . AddRetry ( new ( )
12981 {
13082 MaxRetryAttempts = 3 ,
131- Delay = TimeSpan . FromMilliseconds ( 10 ) , // Shorter delay for tests
83+ Delay = TimeSpan . FromMilliseconds ( 10 ) ,
13284 BackoffType = DelayBackoffType . Constant ,
13385 UseJitter = false ,
134- ShouldHandle = new PredicateBuilder ( ) . Handle < Exception > ( ex => transientExceptionDetectionService . IsTransient ( ex ) ) ,
86+ ShouldHandle = new PredicateBuilder ( ) . Handle < Exception > ( transientExceptionDetectionService . IsTransient ) ,
13587 OnRetry = args =>
13688 {
13789 logger . LogWarning ( args . Outcome . Exception , "Transient error acquiring lock. Attempt {AttemptNumber} of {MaxAttempts}." , args . AttemptNumber + 1 , 3 ) ;
13890 return ValueTask . CompletedTask ;
13991 }
14092 } )
14193 . Build ( ) ;
142- }
14394}
0 commit comments