|
6 | 6 |
|
7 | 7 | namespace Vulthil.SharedKernel.Outbox; |
8 | 8 |
|
| 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> |
9 | 25 | internal sealed class OutboxBackgroundService( |
10 | 26 | ILogger<OutboxBackgroundService> logger, |
11 | 27 | IServiceScopeFactory serviceScopeFactory, |
12 | 28 | IOutboxSignal signal, |
13 | 29 | IEnumerable<IOutboxRelayGate> relayGates, |
14 | | - IOptions<OutboxProcessingOptions> options) : BackgroundService, IRestartableHostedService |
| 30 | + IOptions<OutboxProcessingOptions> options, |
| 31 | + IHostApplicationLifetime applicationLifetime) : IRestartableHostedService, IDisposable |
15 | 32 | { |
| 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 | + |
16 | 70 | /// <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) |
18 | 100 | { |
19 | 101 | if (!await TryWaitForRelayGatesAsync(stoppingToken)) |
20 | 102 | { |
|
0 commit comments