Resolve all 46 low-severity architecture review findings

This commit is contained in:
Joseph Doherty
2026-09-09 22:56:36 -04:00
parent fef3e7675f
commit 9ee402bbcd
139 changed files with 2621 additions and 1082 deletions
@@ -11,6 +11,14 @@ public class KpiHistoryOptionsValidatorTests
{
private static KpiHistoryOptionsValidator NewValidator() => new();
[Fact]
public void Validate_SourceTimeout_Zero_Fails()
{
var result = NewValidator().Validate(null, new KpiHistoryOptions { SourceTimeout = TimeSpan.Zero });
Assert.True(result.Failed);
Assert.Contains("SourceTimeout", result.FailureMessage);
}
[Fact]
public void Validate_Defaults_Pass()
{
@@ -1,6 +1,7 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Kpi;
@@ -470,6 +471,7 @@ public class KpiHistoryRecorderActorTests : TestKit
IKpiHistoryRepository repository, params IKpiSampleSource[] sources)
{
var services = new ServiceCollection();
services.AddKpiHistory(new ConfigurationBuilder().Build());
// Mirror production: sources + repository are scoped, so the recorder opens a fresh
// scope per tick and resolves there.
foreach (var source in sources)
@@ -519,6 +521,80 @@ public class KpiHistoryRecorderActorTests : TestKit
}
}
private sealed class HangingState
{
public readonly TaskCompletionSource<IReadOnlyList<KpiSample>> Completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
public int Calls;
public int ActiveScopesDisposed;
}
private sealed class HangingSource(HangingState state) : IKpiSampleSource, IDisposable
{
private bool _called;
public string Source => "hung";
public Task<IReadOnlyList<KpiSample>> CollectAsync(DateTime capturedAtUtc, CancellationToken cancellationToken = default)
{
_called = true;
Interlocked.Increment(ref state.Calls);
return state.Completion.Task; // deliberately ignores cancellation
}
public void Dispose()
{
if (_called) Interlocked.Increment(ref state.ActiveScopesDisposed);
}
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void HungSource_DoesNotBlockHealthySourcesOrLoseScopeOwnership(bool replaceActor)
{
var state = new HangingState();
var repository = new RecordingRepository();
var services = new ServiceCollection();
services.AddKpiHistory(new ConfigurationBuilder().Build());
services.AddScoped<IKpiSampleSource>(_ => new HangingSource(state));
services.AddScoped<IKpiSampleSource>(_ => new HealthySource());
services.AddScoped<IKpiHistoryRepository>(_ => repository);
using var provider = services.BuildServiceProvider();
var options = new KpiHistoryOptions
{
SourceTimeout = TimeSpan.FromMilliseconds(50),
SampleInterval = TimeSpan.FromHours(1),
};
var actor = CreateActor(provider, options);
try
{
actor.Tell(KpiHistoryRecorderActor.SampleTick.Instance);
AwaitAssert(() => Assert.Equal(2, repository.Recorded.Count), TimeSpan.FromSeconds(2));
if (replaceActor)
{
Watch(actor);
Sys.Stop(actor);
ExpectTerminated(actor);
Assert.Equal(0, Volatile.Read(ref state.ActiveScopesDisposed));
actor = CreateActor(provider, options);
}
AwaitAssert(() =>
{
actor.Tell(KpiHistoryRecorderActor.SampleTick.Instance);
Assert.True(repository.Recorded.Count >= 4);
}, TimeSpan.FromSeconds(2));
Assert.Equal(1, Volatile.Read(ref state.Calls));
Assert.Equal(0, Volatile.Read(ref state.ActiveScopesDisposed));
}
finally
{
// Stop before releasing the source so no queued sampling tick can legitimately
// begin a replacement after it finishes and race the disposal assertion.
Watch(actor);
Sys.Stop(actor);
ExpectTerminated(actor);
state.Completion.TrySetResult(Array.Empty<KpiSample>());
}
AwaitAssert(() => Assert.Equal(1, Volatile.Read(ref state.ActiveScopesDisposed)), TimeSpan.FromSeconds(2));
}
[Fact]
public void SampleTick_WritesHealthySourceSamples_AndThrowingSourceDoesNotAbortTick()
{