Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/ResilienceStatusPublisherHostedServiceTests.cs
Joseph Doherty 016122841b Phase 6.1 Stream E.2 partial — ResilienceStatusPublisherHostedService persists tracker snapshots to DB
Closes the HostedService half of Phase 6.1 Stream E.2 flagged as a follow-up
when the DriverResilienceStatusTracker shipped in PR #82. The Admin /hosts
column refresh + SignalR push + red-badge visual (Stream E.3) remain
deferred to the visual-compliance pass — this PR owns the persistence
story alone.

Server.Hosting:
- ResilienceStatusPublisherHostedService : BackgroundService. Samples the
  DriverResilienceStatusTracker every TickInterval (default 5 s) and upserts
  each (DriverInstanceId, HostName) counter pair into
  DriverInstanceResilienceStatus via EF. New rows on first sight; in-place
  updates on subsequent ticks.
- PersistOnceAsync extracted public so tests drive one tick directly —
  matches the ScheduledRecycleHostedService pattern for deterministic
  timing.
- Best-effort persistence: a DB outage logs a warning + continues; the next
  tick retries. Never crashes the app on sample failure. Cancellation
  propagates through cleanly.
- Tracks the bulkhead depth / recycle / footprint columns the entity was
  designed for. CurrentBulkheadDepth currently persisted as 0 — the tracker
  doesn't yet expose live bulkhead depth; a narrower follow-up wires the
  Polly bulkhead-depth observer into the tracker.

Tests (6 new in ResilienceStatusPublisherHostedServiceTests):
- Empty tracker → tick is a no-op, zero rows written.
- Single-host counters → upsert a new row with ConsecutiveFailures + breaker
  timestamp + sampled timestamp.
- Second tick updates the existing row in place (not a second insert).
- Multi-host pairs persist independently.
- Footprint counters (Baseline + Current) round-trip.
- TickCount advances on every PersistOnceAsync call.

Full solution dotnet test: 1225 passing (was 1219, +6). Pre-existing
Client.CLI Subscribe flake unchanged.

Production wiring (Program.cs) example:
  builder.Services.AddSingleton<DriverResilienceStatusTracker>();
  builder.Services.AddHostedService<ResilienceStatusPublisherHostedService>();
  // Tracker gets wired into CapabilityInvoker via OtOpcUaServer resolution
  // + the existing Phase 6.1 layer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 14:36:00 -04:00

162 lines
6.0 KiB
C#

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
using ZB.MOM.WW.OtOpcUa.Server.Hosting;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
[Trait("Category", "Unit")]
public sealed class ResilienceStatusPublisherHostedServiceTests : IDisposable
{
private static readonly DateTime T0 = new(2026, 4, 19, 12, 0, 0, DateTimeKind.Utc);
private sealed class FakeClock : TimeProvider
{
public DateTime Utc { get; set; } = T0;
public override DateTimeOffset GetUtcNow() => new(Utc, TimeSpan.Zero);
}
private sealed class InMemoryDbContextFactory : IDbContextFactory<OtOpcUaConfigDbContext>
{
private readonly DbContextOptions<OtOpcUaConfigDbContext> _options;
public InMemoryDbContextFactory(string dbName)
{
_options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseInMemoryDatabase(dbName)
.Options;
}
public OtOpcUaConfigDbContext CreateDbContext() => new(_options);
}
private readonly string _dbName = $"resilience-pub-{Guid.NewGuid():N}";
private readonly InMemoryDbContextFactory _factory;
private readonly OtOpcUaConfigDbContext _readCtx;
public ResilienceStatusPublisherHostedServiceTests()
{
_factory = new InMemoryDbContextFactory(_dbName);
_readCtx = _factory.CreateDbContext();
}
public void Dispose() => _readCtx.Dispose();
[Fact]
public async Task EmptyTracker_Tick_NoOp_NoRowsWritten()
{
var tracker = new DriverResilienceStatusTracker();
var host = new ResilienceStatusPublisherHostedService(
tracker, _factory, NullLogger<ResilienceStatusPublisherHostedService>.Instance);
await host.PersistOnceAsync(CancellationToken.None);
host.TickCount.ShouldBe(1);
(await _readCtx.DriverInstanceResilienceStatuses.CountAsync()).ShouldBe(0);
}
[Fact]
public async Task SingleHost_OnePairWithCounters_UpsertsNewRow()
{
var clock = new FakeClock();
var tracker = new DriverResilienceStatusTracker();
tracker.RecordFailure("drv-1", "plc-a", T0);
tracker.RecordFailure("drv-1", "plc-a", T0);
tracker.RecordBreakerOpen("drv-1", "plc-a", T0.AddSeconds(1));
var host = new ResilienceStatusPublisherHostedService(
tracker, _factory, NullLogger<ResilienceStatusPublisherHostedService>.Instance,
timeProvider: clock);
clock.Utc = T0.AddSeconds(2);
await host.PersistOnceAsync(CancellationToken.None);
var row = await _readCtx.DriverInstanceResilienceStatuses.SingleAsync();
row.DriverInstanceId.ShouldBe("drv-1");
row.HostName.ShouldBe("plc-a");
row.ConsecutiveFailures.ShouldBe(2);
row.LastCircuitBreakerOpenUtc.ShouldBe(T0.AddSeconds(1));
row.LastSampledUtc.ShouldBe(T0.AddSeconds(2));
}
[Fact]
public async Task SecondTick_UpdatesExistingRow_InPlace()
{
var clock = new FakeClock();
var tracker = new DriverResilienceStatusTracker();
tracker.RecordFailure("drv-1", "plc-a", T0);
var host = new ResilienceStatusPublisherHostedService(
tracker, _factory, NullLogger<ResilienceStatusPublisherHostedService>.Instance,
timeProvider: clock);
clock.Utc = T0.AddSeconds(5);
await host.PersistOnceAsync(CancellationToken.None);
// Second tick: success resets the counter.
tracker.RecordSuccess("drv-1", "plc-a", T0.AddSeconds(6));
clock.Utc = T0.AddSeconds(10);
await host.PersistOnceAsync(CancellationToken.None);
(await _readCtx.DriverInstanceResilienceStatuses.CountAsync()).ShouldBe(1, "one row, updated in place");
var row = await _readCtx.DriverInstanceResilienceStatuses.SingleAsync();
row.ConsecutiveFailures.ShouldBe(0);
row.LastSampledUtc.ShouldBe(T0.AddSeconds(10));
}
[Fact]
public async Task MultipleHosts_BothPersist_Independently()
{
var tracker = new DriverResilienceStatusTracker();
tracker.RecordFailure("drv-1", "plc-a", T0);
tracker.RecordFailure("drv-1", "plc-a", T0);
tracker.RecordFailure("drv-1", "plc-b", T0);
var host = new ResilienceStatusPublisherHostedService(
tracker, _factory, NullLogger<ResilienceStatusPublisherHostedService>.Instance);
await host.PersistOnceAsync(CancellationToken.None);
var rows = await _readCtx.DriverInstanceResilienceStatuses
.OrderBy(r => r.HostName)
.ToListAsync();
rows.Count.ShouldBe(2);
rows[0].HostName.ShouldBe("plc-a");
rows[0].ConsecutiveFailures.ShouldBe(2);
rows[1].HostName.ShouldBe("plc-b");
rows[1].ConsecutiveFailures.ShouldBe(1);
}
[Fact]
public async Task FootprintCounters_Persist()
{
var tracker = new DriverResilienceStatusTracker();
tracker.RecordFootprint("drv-1", "plc-a",
baselineBytes: 100_000_000, currentBytes: 150_000_000, T0);
var host = new ResilienceStatusPublisherHostedService(
tracker, _factory, NullLogger<ResilienceStatusPublisherHostedService>.Instance);
await host.PersistOnceAsync(CancellationToken.None);
var row = await _readCtx.DriverInstanceResilienceStatuses.SingleAsync();
row.BaselineFootprintBytes.ShouldBe(100_000_000);
row.CurrentFootprintBytes.ShouldBe(150_000_000);
}
[Fact]
public async Task TickCount_Advances_OnEveryCall()
{
var tracker = new DriverResilienceStatusTracker();
var host = new ResilienceStatusPublisherHostedService(
tracker, _factory, NullLogger<ResilienceStatusPublisherHostedService>.Instance);
await host.PersistOnceAsync(CancellationToken.None);
await host.PersistOnceAsync(CancellationToken.None);
await host.PersistOnceAsync(CancellationToken.None);
host.TickCount.ShouldBe(3);
}
}