Skip to content

Commit 041f40d

Browse files
authored
Merge pull request #310 from Vulthil/fix/outbox-relay-restart-race
fix: prevent restartable outbox relay cancellation from faulting the host on .NET 10
2 parents 46a695c + cdc2fbc commit 041f40d

8 files changed

Lines changed: 284 additions & 20 deletions

File tree

src/Vulthil.Extensions.Hosting/IRestartableHostedService.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ namespace Vulthil.Extensions.Hosting;
77
/// may pause it for the duration of an operation that must not run concurrently with it — for example a test harness
88
/// resetting the database between tests, where a live database-polling relay would otherwise contend with the reset.
99
/// Implement this only on services whose <see cref="IHostedService.StartAsync"/> / <see cref="IHostedService.StopAsync"/>
10-
/// are idempotent across repeated cycles (a <see cref="BackgroundService"/> whose <c>ExecuteAsync</c> re-runs cleanly).
10+
/// are idempotent across repeated cycles.
1111
/// </summary>
12+
/// <remarks>
13+
/// Prefer implementing <see cref="IHostedService"/> directly over inheriting <see cref="BackgroundService"/>: the
14+
/// host observes only the execute task created by a <see cref="BackgroundService"/>'s first start, and it treats a
15+
/// canceled execute task as a fault that stops the host whenever the application is not already shutting down. On
16+
/// .NET 10 that task is scheduled with a <c>Task.Run</c> gated on the stopping token, so a stop that races service
17+
/// startup can cancel the task before <c>ExecuteAsync</c> has run at all — no graceful handling inside
18+
/// <c>ExecuteAsync</c> can prevent the resulting host shutdown.
19+
/// </remarks>
1220
public interface IRestartableHostedService : IHostedService;

src/Vulthil.Extensions.Hosting/README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,13 @@ around the per-test database reset, so a live database-polling relay (for exampl
1616
simply by implementing the marker — no test-only code, and the testing library never depends on the outbox engine.
1717

1818
```csharp
19-
internal sealed class OutboxBackgroundService(/* ... */) : BackgroundService, IRestartableHostedService
19+
internal sealed class OutboxBackgroundService(/* ... */) : IRestartableHostedService, IDisposable
2020
{
21-
// ExecuteAsync is written so a Stop/Start cycle re-runs it cleanly.
21+
// StartAsync/StopAsync manage the execute task so a Stop/Start cycle re-runs it cleanly.
2222
}
2323
```
2424

25-
Implement it only on services whose `StartAsync`/`StopAsync` are idempotent across repeated cycles.
25+
Implement it only on services whose `StartAsync`/`StopAsync` are idempotent across repeated cycles. Prefer
26+
implementing `IHostedService` directly over inheriting `BackgroundService`: the host observes only the execute task
27+
of a `BackgroundService`'s first start and stops the whole host when that task is canceled while the application is
28+
running — and on .NET 10 a stop racing service startup can cancel the task before `ExecuteAsync` has run at all.

src/Vulthil.Messaging.Inbox/InboxRetentionBackgroundService.cs

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,21 +23,34 @@ internal sealed class InboxRetentionBackgroundService(
2323
/// <inheritdoc />
2424
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
2525
{
26-
using var timer = new PeriodicTimer(_options.SweepInterval, timeProvider);
27-
do
26+
try
27+
{
28+
using var timer = new PeriodicTimer(_options.SweepInterval, timeProvider);
29+
do
30+
{
31+
await SweepSafelyAsync(stoppingToken);
32+
}
33+
while (await timer.WaitForNextTickAsync(stoppingToken));
34+
}
35+
catch (OperationCanceledException exception) when (stoppingToken.IsCancellationRequested)
2836
{
29-
await SweepSafelyAsync(stoppingToken);
37+
logger.LogDebug(exception, "Inbox retention sweep loop stopped");
3038
}
31-
while (await timer.WaitForNextTickAsync(stoppingToken));
3239
}
3340

41+
/// <summary>
42+
/// Runs one sweep, treating every failure as transient (logged, retried on the next tick). Only a cancellation
43+
/// caused by the service stopping propagates; a foreign <see cref="OperationCanceledException"/> — for example a
44+
/// store client surfacing a timeout as a cancellation — must not escape, because the host would treat the
45+
/// canceled execute task of a still-running application as a fault and stop the host.
46+
/// </summary>
3447
private async Task SweepSafelyAsync(CancellationToken cancellationToken)
3548
{
3649
try
3750
{
3851
await SweepAsync(cancellationToken);
3952
}
40-
catch (Exception exception) when (exception is not OperationCanceledException)
53+
catch (Exception exception) when (exception is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
4154
{
4255
logger.LogError(exception, "Inbox retention sweep failed.");
4356
}

src/Vulthil.SharedKernel.Outbox/OutboxBackgroundService.cs

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,97 @@
66

77
namespace Vulthil.SharedKernel.Outbox;
88

9+
/// <summary>
10+
/// Hosts the outbox relay loop, restartable via <see cref="IRestartableHostedService"/> so infrastructure (such as a
11+
/// test harness resetting the database) can pause it around operations that must not run concurrently with it.
12+
/// </summary>
13+
/// <remarks>
14+
/// The service implements <see cref="IHostedService"/> directly instead of inheriting
15+
/// <see cref="BackgroundService"/> because <see cref="BackgroundService"/> is not restart-safe: the host observes
16+
/// only the execute task created by the first <c>StartAsync</c>, and on .NET 10 that task is scheduled with
17+
/// <c>Task.Run(..., stoppingToken)</c> — a stop that lands before the thread pool has run the delegate transitions
18+
/// the observed task straight to canceled without <c>ExecuteAsync</c> ever running, which the host (whose default
19+
/// <c>BackgroundServiceExceptionBehavior</c> is <c>StopHost</c>) treats as a fault and stops the application while
20+
/// it is still serving. Owning the lifecycle removes both hazards: the execute task always runs the relay loop
21+
/// (cancellation is observed inside it and always ends in a graceful return), and the host never awaits the task.
22+
/// A genuine fault escaping the loop still stops the application — matching the <see cref="BackgroundService"/>
23+
/// default — via <see cref="IHostApplicationLifetime.StopApplication"/>.
24+
/// </remarks>
925
internal sealed class OutboxBackgroundService(
1026
ILogger<OutboxBackgroundService> logger,
1127
IServiceScopeFactory serviceScopeFactory,
1228
IOutboxSignal signal,
1329
IEnumerable<IOutboxRelayGate> relayGates,
14-
IOptions<OutboxProcessingOptions> options) : BackgroundService, IRestartableHostedService
30+
IOptions<OutboxProcessingOptions> options,
31+
IHostApplicationLifetime applicationLifetime) : IRestartableHostedService, IDisposable
1532
{
33+
private CancellationTokenSource? _stoppingCts;
34+
35+
/// <summary>
36+
/// Gets the task running the current relay loop, or <see langword="null"/> before the first start. The task
37+
/// always completes successfully: cancellation ends it with a graceful return and a genuine fault is handled
38+
/// inside it, so no caller ever observes an exception from a stopped relay.
39+
/// </summary>
40+
internal Task? ExecuteTask { get; private set; }
41+
42+
/// <inheritdoc />
43+
public Task StartAsync(CancellationToken cancellationToken)
44+
{
45+
_stoppingCts?.Dispose();
46+
_stoppingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
47+
var stoppingToken = _stoppingCts.Token;
48+
ExecuteTask = Task.Run(() => ExecuteAsync(stoppingToken), CancellationToken.None);
49+
return Task.CompletedTask;
50+
}
51+
52+
/// <inheritdoc />
53+
public async Task StopAsync(CancellationToken cancellationToken)
54+
{
55+
if (ExecuteTask is null)
56+
{
57+
return;
58+
}
59+
60+
try
61+
{
62+
await _stoppingCts!.CancelAsync();
63+
}
64+
finally
65+
{
66+
await ExecuteTask.WaitAsync(cancellationToken).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
67+
}
68+
}
69+
1670
/// <inheritdoc />
17-
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
71+
public void Dispose()
72+
{
73+
_stoppingCts?.Cancel();
74+
_stoppingCts?.Dispose();
75+
}
76+
77+
/// <summary>
78+
/// Runs the relay loop and guarantees the execute task completes successfully: a cancellation escaping the loop
79+
/// after the service is stopped is swallowed, while any other exception is a genuine fault that stops the
80+
/// application, mirroring the host's default behavior for faulted background services.
81+
/// </summary>
82+
private async Task ExecuteAsync(CancellationToken stoppingToken)
83+
{
84+
try
85+
{
86+
await ProcessUntilStoppedAsync(stoppingToken);
87+
}
88+
catch (OperationCanceledException ex) when (stoppingToken.IsCancellationRequested)
89+
{
90+
logger.LogDebug(ex, "Outbox relay stopped before the processing loop observed the cancellation");
91+
}
92+
catch (Exception ex)
93+
{
94+
logger.LogCritical(ex, "Outbox relay faulted; stopping the application");
95+
applicationLifetime.StopApplication();
96+
}
97+
}
98+
99+
private async Task ProcessUntilStoppedAsync(CancellationToken stoppingToken)
18100
{
19101
if (!await TryWaitForRelayGatesAsync(stoppingToken))
20102
{

src/Vulthil.SharedKernel.Outbox/OutboxRetentionBackgroundService.cs

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,21 +23,34 @@ internal sealed class OutboxRetentionBackgroundService(
2323
/// <inheritdoc />
2424
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
2525
{
26-
using var timer = new PeriodicTimer(_options.SweepInterval, timeProvider);
27-
do
26+
try
27+
{
28+
using var timer = new PeriodicTimer(_options.SweepInterval, timeProvider);
29+
do
30+
{
31+
await SweepSafelyAsync(stoppingToken);
32+
}
33+
while (await timer.WaitForNextTickAsync(stoppingToken));
34+
}
35+
catch (OperationCanceledException exception) when (stoppingToken.IsCancellationRequested)
2836
{
29-
await SweepSafelyAsync(stoppingToken);
37+
logger.LogDebug(exception, "Outbox retention sweep loop stopped");
3038
}
31-
while (await timer.WaitForNextTickAsync(stoppingToken));
3239
}
3340

41+
/// <summary>
42+
/// Runs one sweep, treating every failure as transient (logged, retried on the next tick). Only a cancellation
43+
/// caused by the service stopping propagates; a foreign <see cref="OperationCanceledException"/> — for example a
44+
/// store client surfacing a timeout as a cancellation — must not escape, because the host would treat the
45+
/// canceled execute task of a still-running application as a fault and stop the host.
46+
/// </summary>
3447
private async Task SweepSafelyAsync(CancellationToken cancellationToken)
3548
{
3649
try
3750
{
3851
await SweepAsync(cancellationToken);
3952
}
40-
catch (Exception exception) when (exception is not OperationCanceledException)
53+
catch (Exception exception) when (exception is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
4154
{
4255
logger.LogError(exception, "Outbox retention sweep failed.");
4356
}

tests/Vulthil.Messaging.Inbox.Tests/InboxRetentionBackgroundServiceTests.cs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using Microsoft.Extensions.DependencyInjection;
22
using Microsoft.Extensions.Logging;
33
using Microsoft.Extensions.Options;
4+
using Vulthil.Messaging.Abstractions.Consumers;
45
using Vulthil.xUnit;
56

67
namespace Vulthil.Messaging.Inbox.Tests;
@@ -39,6 +40,39 @@ public async Task StoreWithoutRetentionSupportLogsExactlyOneWarningAcrossMultipl
3940
logger.WarningCount.ShouldBe(1);
4041
}
4142

43+
[Fact]
44+
public async Task AForeignCancellationDuringASweepDoesNotEndTheExecuteTask()
45+
{
46+
// Arrange
47+
Use<IOptions<InboxOptions>>(Options.Create(new InboxOptions()));
48+
Use<IServiceScopeFactory>(new AutoMockerServiceScopeFactory(AutoMocker));
49+
var store = new TimeoutThrowingRetentionStore();
50+
Use<IIdempotencyStore>(store);
51+
52+
// Act
53+
await Target.StartAsync(CancellationToken);
54+
await store.FirstSweepAttempted;
55+
await Target.StopAsync(CancellationToken);
56+
57+
// Assert
58+
Target.ExecuteTask!.Status.ShouldBe(TaskStatus.RanToCompletion);
59+
}
60+
61+
private sealed class TimeoutThrowingRetentionStore : IIdempotencyStore, IInboxRetentionStore
62+
{
63+
private readonly TaskCompletionSource _firstSweepAttempted = new(TaskCreationOptions.RunContinuationsAsynchronously);
64+
65+
public Task FirstSweepAttempted => _firstSweepAttempted.Task;
66+
67+
public Task<bool> ProcessAsync(string idempotencyKey, IMessageContext context, Func<CancellationToken, Task> process, CancellationToken cancellationToken) => Task.FromResult(false);
68+
69+
public Task<int> DeleteProcessedAsync(DateTimeOffset olderThanUtc, int batchSize, CancellationToken cancellationToken)
70+
{
71+
_firstSweepAttempted.TrySetResult();
72+
throw new OperationCanceledException();
73+
}
74+
}
75+
4276
private sealed class WarningCountingLogger : ILogger<InboxRetentionBackgroundService>
4377
{
4478
private readonly TaskCompletionSource _firstWarning = new(TaskCreationOptions.RunContinuationsAsynchronously);

tests/Vulthil.SharedKernel.Outbox.Tests/OutboxBackgroundServiceTests.cs

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
using System.Threading.Channels;
12
using Microsoft.Extensions.DependencyInjection;
3+
using Microsoft.Extensions.Hosting;
24
using Microsoft.Extensions.Options;
35
using Vulthil.xUnit;
46

@@ -23,7 +25,7 @@ public async Task StoppingWhileWaitingForRelayGatesCompletesGracefully()
2325
var gate = new BlockingRelayGate();
2426
Use<IEnumerable<IOutboxRelayGate>>([gate]);
2527
await Target.StartAsync(CancellationToken);
26-
await gate.Entered;
28+
await gate.WaitForEntryAsync(CancellationToken);
2729

2830
// Act
2931
await Target.StopAsync(CancellationToken);
@@ -32,6 +34,74 @@ public async Task StoppingWhileWaitingForRelayGatesCompletesGracefully()
3234
Target.ExecuteTask!.Status.ShouldBe(TaskStatus.RanToCompletion);
3335
}
3436

37+
[Fact]
38+
public async Task StoppingBeforeTheRelayLoopHasRunCompletesGracefully()
39+
{
40+
// Arrange
41+
Use<IEnumerable<IOutboxRelayGate>>([new BlockingRelayGate()]);
42+
await Target.StartAsync(CancellationToken);
43+
44+
// Act
45+
await Target.StopAsync(CancellationToken);
46+
47+
// Assert
48+
Target.ExecuteTask!.Status.ShouldBe(TaskStatus.RanToCompletion);
49+
}
50+
51+
[Fact]
52+
public async Task StartingWithAnAlreadyCanceledTokenCompletesTheExecuteTaskGracefully()
53+
{
54+
// Arrange
55+
Use<IEnumerable<IOutboxRelayGate>>([new BlockingRelayGate()]);
56+
using var canceledTokenSource = new CancellationTokenSource();
57+
await canceledTokenSource.CancelAsync();
58+
59+
// Act
60+
await Target.StartAsync(canceledTokenSource.Token);
61+
await Target.ExecuteTask!;
62+
63+
// Assert
64+
Target.ExecuteTask!.Status.ShouldBe(TaskStatus.RanToCompletion);
65+
}
66+
67+
[Fact]
68+
public async Task RestartingAfterStopRunsTheRelayAgain()
69+
{
70+
// Arrange
71+
var gate = new BlockingRelayGate();
72+
Use<IEnumerable<IOutboxRelayGate>>([gate]);
73+
await Target.StartAsync(CancellationToken);
74+
await gate.WaitForEntryAsync(CancellationToken);
75+
await Target.StopAsync(CancellationToken);
76+
var firstExecuteTask = Target.ExecuteTask;
77+
78+
// Act
79+
await Target.StartAsync(CancellationToken);
80+
await gate.WaitForEntryAsync(CancellationToken);
81+
await Target.StopAsync(CancellationToken);
82+
83+
// Assert
84+
Target.ExecuteTask.ShouldNotBeSameAs(firstExecuteTask);
85+
Target.ExecuteTask!.Status.ShouldBe(TaskStatus.RanToCompletion);
86+
}
87+
88+
[Fact]
89+
public async Task AFaultOutsideTheProcessingLoopStopsTheApplication()
90+
{
91+
// Arrange
92+
Use<IEnumerable<IOutboxRelayGate>>([]);
93+
var failingOptions = new Mock<IOptions<OutboxProcessingOptions>>();
94+
failingOptions.Setup(o => o.Value).Throws(new InvalidOperationException("Options failed"));
95+
Use(failingOptions.Object);
96+
97+
// Act
98+
await Target.StartAsync(CancellationToken);
99+
await Target.ExecuteTask!;
100+
101+
// Assert
102+
GetMock<IHostApplicationLifetime>().Verify(lifetime => lifetime.StopApplication(), Times.Once);
103+
}
104+
35105
[Fact]
36106
public async Task ABatchWithFewerSuccessesThanTheBatchSizeDelaysTheNextFetch()
37107
{
@@ -87,13 +157,13 @@ public async Task AFullySuccessfulFullBatchRefetchesImmediately()
87157

88158
private sealed class BlockingRelayGate : IOutboxRelayGate
89159
{
90-
private readonly TaskCompletionSource _entered = new(TaskCreationOptions.RunContinuationsAsynchronously);
160+
private readonly Channel<bool> _entries = Channel.CreateUnbounded<bool>();
91161

92-
public Task Entered => _entered.Task;
162+
public async Task WaitForEntryAsync(CancellationToken cancellationToken) => await _entries.Reader.ReadAsync(cancellationToken);
93163

94164
public Task WaitUntilReadyAsync(CancellationToken cancellationToken)
95165
{
96-
_entered.TrySetResult();
166+
_entries.Writer.TryWrite(true);
97167
return Task.Delay(Timeout.Infinite, cancellationToken);
98168
}
99169
}

0 commit comments

Comments
 (0)