Skip to content

Commit 5c8216b

Browse files
authored
Fix retry logic and add unit tests for ExecuteTaskAsync (#429)
* Add retry logic improvements and unit tests for ExecuteTaskAsync * Refactor retry test setup to use SetupRetryTestFixture for consistency
1 parent 2e06180 commit 5c8216b

File tree

4 files changed

+130
-5
lines changed

4 files changed

+130
-5
lines changed
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
using System.Runtime.CompilerServices;
2+
3+
[assembly: InternalsVisibleTo("TickerQ.Tests")]

src/TickerQ/Src/TickerExecutionTaskHandler.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ private async Task RunContextFunctionAsync(InternalFunctionContext context, bool
172172

173173
for (var attempt = context.RetryCount; attempt <= context.Retries; attempt++)
174174
{
175-
tickerFunctionContext.RetryCount = context.RetryCount;
175+
tickerFunctionContext.RetryCount = attempt;
176176

177177
// Update activity with current attempt information
178178
jobActivity?.SetTag("tickerq.job.current_attempt", attempt + 1);
@@ -304,18 +304,18 @@ private async Task<bool> WaitForRetry(InternalFunctionContext context, Cancellat
304304
if (attempt == 0)
305305
return false;
306306

307-
if (attempt >= context.Retries)
307+
if (attempt > context.Retries)
308308
return true;
309309

310-
context.SetProperty(x => x.RetryCount, attempt + 1);
310+
context.SetProperty(x => x.RetryCount, attempt);
311311

312312
await _internalTickerManager.UpdateTickerAsync(context, cancellationToken);
313313

314314
context.ResetUpdateProps();
315315

316316
var retryInterval = (context.RetryIntervals?.Length > 0)
317-
? (attempt < context.RetryIntervals.Length
318-
? context.RetryIntervals[attempt]
317+
? (attempt - 1 < context.RetryIntervals.Length
318+
? context.RetryIntervals[attempt - 1]
319319
: context.RetryIntervals[^1])
320320
: 30;
321321

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
using FluentAssertions;
2+
using NSubstitute;
3+
using TickerQ.Utilities.Enums;
4+
using TickerQ.Utilities.Interfaces;
5+
using TickerQ.Utilities.Interfaces.Managers;
6+
using Microsoft.Extensions.DependencyInjection;
7+
using TickerQ.Utilities.Instrumentation;
8+
using TickerQ.Utilities.Models;
9+
10+
namespace TickerQ.Tests;
11+
12+
public class RetryBehaviorTests
13+
{
14+
// End-to-end unit tests that call the public ExecuteTaskAsync with a CronTickerOccurrence
15+
// so RunContextFunctionAsync + retry logic is exercised. Tests use short intervals (1..3s).
16+
17+
[Fact()]
18+
public async Task ExecuteTaskAsync_CronTickerOccurrence_AppliesRetryIntervals_AndUpdatesRetryCount()
19+
{
20+
// Arrange: cron occurrence -> RunContextFunctionAsync path
21+
// Use three distinct short intervals so we can verify mapping without overly long waits
22+
var (handler, context, _, attempts) = SetupRetryTestFixture([1, 2, 3], retries: 3);
23+
24+
// Act
25+
await handler.ExecuteTaskAsync(context, isDue: true);
26+
27+
// Assert - initial + 3 retries = 4 attempts
28+
attempts.Should().HaveCount(4);
29+
for (int i = 0; i < 4; i++)
30+
attempts[i].RetryCount.Should().Be(i);
31+
32+
// Verify mapped retry intervals produced the expected spacing between attempts
33+
var timeDiffs = new[]
34+
{
35+
(attempts[1].Timestamp - attempts[0].Timestamp).TotalSeconds,
36+
(attempts[2].Timestamp - attempts[1].Timestamp).TotalSeconds,
37+
(attempts[3].Timestamp - attempts[2].Timestamp).TotalSeconds,
38+
};
39+
40+
// allow a small tolerance for timing, but ensure each spacing reflects the configured intervals
41+
timeDiffs[0].Should().BeInRange(0.8, 1.2); // first retry uses ~1s
42+
timeDiffs[1].Should().BeInRange(1.8, 2.2); // second retry uses ~2s
43+
timeDiffs[2].Should().BeInRange(2.8, 3.2); // third retry uses ~3s
44+
}
45+
46+
[Fact]
47+
public async Task ExecuteTaskAsync_CronTickerOccurrence_UsesLastInterval_WhenRetriesExceedArrayLength()
48+
{
49+
// Use zero intervals for speed
50+
var (handler, context, _, attempts) = SetupRetryTestFixture([0, 0], retries: 4);
51+
52+
await handler.ExecuteTaskAsync(context, isDue: true);
53+
54+
// initial + 4 retries = 5 attempts
55+
attempts.Should().HaveCount(5);
56+
57+
// Ensure we captured attempts and they happened in order. Timing is intentionally tiny.
58+
attempts.Select(a => a.Timestamp).Should().BeInAscendingOrder();
59+
}
60+
61+
[Fact]
62+
public async Task ExecuteTaskAsync_CronTickerOccurrence_StopsRetrying_WhenFunctionSucceeds()
63+
{
64+
// Arrange: succeed on RetryCount==2
65+
// Use zero intervals for speed; succeed at retry=2
66+
var (handler, context, _, attempts) = SetupRetryTestFixture([0, 0, 0, 0], retries: 4, succeedOnRetryCount: 2);
67+
68+
await handler.ExecuteTaskAsync(context, isDue: true);
69+
70+
// Should stop after success on attempt with RetryCount=2 => initial + retry1 + retry2 = 3 attempts
71+
attempts.Should().HaveCount(3);
72+
attempts.Last().RetryCount.Should().Be(2);
73+
}
74+
75+
private record Attempt(DateTime Timestamp, int RetryCount);
76+
77+
// Helpers
78+
private static (TickerExecutionTaskHandler handler, InternalFunctionContext context, IInternalTickerManager manager, List<Attempt> attempts) SetupRetryTestFixture(
79+
int[] retryIntervals,
80+
int retries,
81+
int? succeedOnRetryCount = null)
82+
{
83+
var services = new ServiceCollection();
84+
var clock = Substitute.For<ITickerClock>();
85+
var internalManager = Substitute.For<IInternalTickerManager>();
86+
var instrumentation = Substitute.For<ITickerQInstrumentation>();
87+
88+
clock.UtcNow.Returns(DateTime.UtcNow);
89+
90+
services.AddSingleton(internalManager);
91+
services.AddSingleton(instrumentation);
92+
var serviceProvider = services.BuildServiceProvider();
93+
94+
var handler = new TickerExecutionTaskHandler(serviceProvider, clock, instrumentation, internalManager);
95+
96+
var attempts = new List<Attempt>();
97+
98+
var context = new InternalFunctionContext
99+
{
100+
TickerId = Guid.NewGuid(),
101+
FunctionName = "TestFunction",
102+
Type = TickerType.CronTickerOccurrence,
103+
ExecutionTime = DateTime.UtcNow,
104+
RetryIntervals = retryIntervals,
105+
Retries = retries,
106+
RetryCount = 0,
107+
Status = TickerStatus.Idle,
108+
CachedDelegate = (ct, sp, tctx) =>
109+
{
110+
attempts.Add(new Attempt(DateTime.UtcNow, tctx.RetryCount));
111+
112+
if (succeedOnRetryCount.HasValue && tctx.RetryCount >= succeedOnRetryCount.Value)
113+
return Task.CompletedTask;
114+
115+
throw new InvalidOperationException("Fail for retry test");
116+
}
117+
};
118+
119+
return (handler, context, internalManager, attempts);
120+
}
121+
}

tests/TickerQ.Tests/TickerQ.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
<ItemGroup>
2424
<ProjectReference Include="..\..\src\TickerQ.Utilities\TickerQ.Utilities.csproj" />
25+
<ProjectReference Include="..\..\src\TickerQ\TickerQ.csproj" />
2526
</ItemGroup>
2627

2728
</Project>

0 commit comments

Comments
 (0)