9110a4eb01
The host does not guarantee IHostedService.StopAsync is driven before the DI container is disposed — WebApplicationFactory's teardown reaches Dispose first — so cancelling the internal CTS from StopAsync threw ObjectDisposedException and aborted the host's whole shutdown sequence. Four services shared the same copy-pasted lifecycle and the same two races: StopAsync cancelling an already- disposed CTS, and StartAsync reading _cts.Token lazily inside the Task.Run lambda, which faults the loop task the host awaits when Dispose wins that race. Each service now captures the token on the caller's thread, tolerates a disposed CTS, and cancels-before-disposing so the loop is always signalled and its pending Task.Delay sees a cancelled token rather than a dead source. SiteAuditBacklogReporter also gains the outer OperationCanceledException guard its sibling SiteAuditRetentionService already carried (arch-review 04 R2, R7), without which a shutdown landing mid-probe threw TaskCanceledException out of Host.StopAsync. Surfaced while verifying the Gitea #15 test-harness fix: in Host.Tests the aborted teardown skipped the fixture's env-var restore, contaminating every later test in the run. Refs: Gitea #15
191 lines
7.4 KiB
C#
191 lines
7.4 KiB
C#
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
|
|
using Xunit;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
|
|
|
/// <summary>
|
|
/// Bundle D (#23 M6-T5) tests for <see cref="AuditLogPartitionMaintenanceService"/>.
|
|
/// All tests use an in-memory <see cref="IPartitionMaintenance"/> stub —
|
|
/// the real EF/MSSQL implementation is exercised by the
|
|
/// <c>AuditLogPartitionMaintenanceTests</c> integration suite in
|
|
/// <c>ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests</c>. This file is purely
|
|
/// about the hosted service's policy decisions (start/stop, exception
|
|
/// containment).
|
|
/// </summary>
|
|
public class AuditLogPartitionMaintenanceServiceTests
|
|
{
|
|
/// <summary>
|
|
/// Recording stub — counts EnsureLookaheadAsync invocations and lets the
|
|
/// test inject an exception per invocation to drive the catch-all path.
|
|
/// </summary>
|
|
private sealed class RecordingMaintenance : IPartitionMaintenance
|
|
{
|
|
public int EnsureCallCount;
|
|
public Exception? ThrowOnce;
|
|
|
|
public Task<IReadOnlyList<DateTime>> EnsureLookaheadAsync(int lookaheadMonths, CancellationToken ct = default)
|
|
{
|
|
Interlocked.Increment(ref EnsureCallCount);
|
|
if (ThrowOnce is { } ex)
|
|
{
|
|
ThrowOnce = null;
|
|
throw ex;
|
|
}
|
|
return Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>());
|
|
}
|
|
|
|
public Task<DateTime?> GetMaxBoundaryAsync(CancellationToken ct = default) =>
|
|
Task.FromResult<DateTime?>(DateTime.UtcNow.AddMonths(6));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Captures logged exceptions so the catch-all assertion can prove
|
|
/// the exception was actually logged (not silently swallowed) and was
|
|
/// the exact instance the stub threw.
|
|
/// </summary>
|
|
private sealed class CapturingLogger : ILogger<AuditLogPartitionMaintenanceService>
|
|
{
|
|
public List<(LogLevel Level, Exception? Exception, string Message)> Entries { get; } = new();
|
|
|
|
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
|
|
|
public bool IsEnabled(LogLevel logLevel) => true;
|
|
|
|
public void Log<TState>(
|
|
LogLevel logLevel,
|
|
EventId eventId,
|
|
TState state,
|
|
Exception? exception,
|
|
Func<TState, Exception?, string> formatter)
|
|
{
|
|
Entries.Add((logLevel, exception, formatter(state, exception)));
|
|
}
|
|
}
|
|
|
|
private static IServiceProvider BuildProvider(IPartitionMaintenance maintenance)
|
|
{
|
|
var services = new ServiceCollection();
|
|
// IPartitionMaintenance is registered as scoped by AddConfigurationDatabase;
|
|
// we mirror that here so the hosted service's CreateAsyncScope +
|
|
// GetRequiredService resolves the stub the test injected.
|
|
services.AddScoped(_ => maintenance);
|
|
return services.BuildServiceProvider();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StartStop_NoExceptions()
|
|
{
|
|
// Long interval so only the eager startup tick fires inside the test
|
|
// window — keeps assertions deterministic without relying on
|
|
// multiple cadence loops.
|
|
var opts = Options.Create(new AuditLogPartitionMaintenanceOptions
|
|
{
|
|
IntervalSeconds = 60,
|
|
LookaheadMonths = 1,
|
|
});
|
|
var maintenance = new RecordingMaintenance();
|
|
var sp = BuildProvider(maintenance);
|
|
|
|
var svc = new AuditLogPartitionMaintenanceService(
|
|
sp.GetRequiredService<IServiceScopeFactory>(),
|
|
opts,
|
|
NullLogger<AuditLogPartitionMaintenanceService>.Instance);
|
|
|
|
await svc.StartAsync(CancellationToken.None);
|
|
|
|
// Spin briefly until the startup tick has fired — the loop's first
|
|
// SafeMaintainAsync runs on a background Task.Run continuation, so
|
|
// we can't synchronously rely on its completion.
|
|
var deadline = DateTime.UtcNow.AddSeconds(3);
|
|
while (Volatile.Read(ref maintenance.EnsureCallCount) < 1 && DateTime.UtcNow < deadline)
|
|
{
|
|
await Task.Delay(20);
|
|
}
|
|
|
|
await svc.StopAsync(CancellationToken.None);
|
|
svc.Dispose();
|
|
|
|
Assert.True(maintenance.EnsureCallCount >= 1, $"expected at least 1 ensure call, got {maintenance.EnsureCallCount}");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SafeMaintain_ExceptionLogged_NotPropagated()
|
|
{
|
|
var opts = Options.Create(new AuditLogPartitionMaintenanceOptions
|
|
{
|
|
IntervalSeconds = 60,
|
|
LookaheadMonths = 1,
|
|
});
|
|
// The injected exception fires on the FIRST EnsureLookaheadAsync call
|
|
// (the startup tick) — the hosted service must contain it and
|
|
// continue running.
|
|
var boom = new InvalidOperationException("simulated maintenance failure");
|
|
var maintenance = new RecordingMaintenance { ThrowOnce = boom };
|
|
var sp = BuildProvider(maintenance);
|
|
var logger = new CapturingLogger();
|
|
|
|
var svc = new AuditLogPartitionMaintenanceService(
|
|
sp.GetRequiredService<IServiceScopeFactory>(),
|
|
opts,
|
|
logger);
|
|
|
|
// StartAsync must not throw even though the very first tick will fail.
|
|
await svc.StartAsync(CancellationToken.None);
|
|
|
|
// Wait for the error to surface in the logger.
|
|
var deadline = DateTime.UtcNow.AddSeconds(3);
|
|
while (!logger.Entries.Any(e => e.Exception == boom) && DateTime.UtcNow < deadline)
|
|
{
|
|
await Task.Delay(20);
|
|
}
|
|
|
|
await svc.StopAsync(CancellationToken.None);
|
|
svc.Dispose();
|
|
|
|
var errorEntry = Assert.Single(logger.Entries, e => e.Exception == boom);
|
|
Assert.Equal(LogLevel.Error, errorEntry.Level);
|
|
Assert.Equal(1, maintenance.EnsureCallCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StopAsync_AfterDispose_DoesNotThrow()
|
|
{
|
|
// Regression (Gitea #15 follow-up): Dispose tears down the CTS that
|
|
// StopAsync cancels, and the host does not guarantee that every
|
|
// IHostedService.StopAsync is driven before the DI container is
|
|
// disposed — WebApplicationFactory's teardown reaches Dispose first and
|
|
// then calls StopAsync. Cancel() on a disposed CTS throws
|
|
// ObjectDisposedException, and letting it escape aborts the host's whole
|
|
// shutdown sequence (in Host.Tests it stranded the fixture's env-var
|
|
// restore, contaminating every later test in the run). Stop-after-Dispose
|
|
// must therefore be a no-op, not a throw.
|
|
var opts = Options.Create(new AuditLogPartitionMaintenanceOptions
|
|
{
|
|
IntervalSeconds = 60,
|
|
LookaheadMonths = 1,
|
|
});
|
|
var maintenance = new RecordingMaintenance();
|
|
var sp = BuildProvider(maintenance);
|
|
|
|
var svc = new AuditLogPartitionMaintenanceService(
|
|
sp.GetRequiredService<IServiceScopeFactory>(),
|
|
opts,
|
|
NullLogger<AuditLogPartitionMaintenanceService>.Instance);
|
|
|
|
await svc.StartAsync(CancellationToken.None);
|
|
|
|
svc.Dispose();
|
|
|
|
// The assertion is the absence of ObjectDisposedException.
|
|
await svc.StopAsync(CancellationToken.None);
|
|
|
|
// Dispose is also idempotent — the host may reach it twice on the same path.
|
|
svc.Dispose();
|
|
}
|
|
}
|