Merge branch 'worktree-agent-aae48b78656e5a4e0' into arch-review-remediation
# Conflicts: # src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Repositories/SiteExternalSystemRepository.cs
This commit is contained in:
@@ -104,3 +104,78 @@ public class ManagementHttpClientTests
|
||||
Assert.Equal("TIMEOUT", response.ErrorCode);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP2.6e (arch-review misc — CLI HttpClient timeout): the public
|
||||
/// <see cref="ManagementHttpClient"/> constructor must bound its underlying
|
||||
/// <see cref="HttpClient.Timeout"/> explicitly (30 s default) rather than leaving the
|
||||
/// framework's 100 s default in place, and must honor the
|
||||
/// <c>SCADABRIDGE_HTTP_TIMEOUT_SECONDS</c> override — consistent with how every other
|
||||
/// CLI setting is environment-overridable (<see cref="CliConfig"/>). Runs in the shared
|
||||
/// "Environment" collection (see <see cref="TestCollections"/>) so it never races another
|
||||
/// test mutating process-wide environment variables.
|
||||
/// </summary>
|
||||
[Collection("Environment")]
|
||||
public class ManagementHttpClientTimeoutTests
|
||||
{
|
||||
private const string EnvVar = "SCADABRIDGE_HTTP_TIMEOUT_SECONDS";
|
||||
|
||||
[Fact]
|
||||
public void DefaultConstructor_SetsThirtySecondTimeout_WhenEnvVarUnset()
|
||||
{
|
||||
var original = Environment.GetEnvironmentVariable(EnvVar);
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable(EnvVar, null);
|
||||
|
||||
using var client = new ManagementHttpClient("http://localhost:9001", "user", "pass");
|
||||
|
||||
Assert.Equal(TimeSpan.FromSeconds(30), ManagementHttpClient.DefaultTimeout);
|
||||
Assert.Equal(TimeSpan.FromSeconds(30), client.EffectiveTimeout);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable(EnvVar, original);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("0")]
|
||||
[InlineData("-5")]
|
||||
[InlineData("not-a-number")]
|
||||
[InlineData("")]
|
||||
public void InvalidOrNonPositiveEnvValue_FallsBackToDefault(string value)
|
||||
{
|
||||
var original = Environment.GetEnvironmentVariable(EnvVar);
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable(EnvVar, value);
|
||||
|
||||
using var client = new ManagementHttpClient("http://localhost:9001", "user", "pass");
|
||||
|
||||
Assert.Equal(TimeSpan.FromSeconds(30), client.EffectiveTimeout);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable(EnvVar, original);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PositiveEnvValue_OverridesDefaultTimeout()
|
||||
{
|
||||
var original = Environment.GetEnvironmentVariable(EnvVar);
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable(EnvVar, "5");
|
||||
|
||||
using var client = new ManagementHttpClient("http://localhost:9001", "user", "pass");
|
||||
|
||||
Assert.Equal(TimeSpan.FromSeconds(5), client.EffectiveTimeout);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable(EnvVar, original);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP2.6b (arch-review misc — Inbound API per-request SQL): <see cref="ApiMethodCache"/>
|
||||
/// hit/miss/expiry/invalidation behavior, independent of the endpoint and subscriber wiring
|
||||
/// (covered separately by <see cref="ScriptArtifactChangeSubscriberTests"/>).
|
||||
/// </summary>
|
||||
public class ApiMethodCacheTests
|
||||
{
|
||||
private static ApiMethod Method(string name) => new(name, "return 1;") { Id = 1 };
|
||||
|
||||
[Fact]
|
||||
public async Task GetOrFetchAsync_SecondCallWithinTtl_ServesFromCache_NoRefetch()
|
||||
{
|
||||
var cache = new ApiMethodCache(TimeSpan.FromMinutes(5));
|
||||
var fetchCount = 0;
|
||||
Task<ApiMethod?> Fetch(CancellationToken _)
|
||||
{
|
||||
fetchCount++;
|
||||
return Task.FromResult<ApiMethod?>(Method("m"));
|
||||
}
|
||||
|
||||
var first = await cache.GetOrFetchAsync("m", Fetch, CancellationToken.None);
|
||||
var second = await cache.GetOrFetchAsync("m", Fetch, CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, fetchCount);
|
||||
Assert.Same(first, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOrFetchAsync_AfterTtlExpiry_Refetches()
|
||||
{
|
||||
var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
var cache = new ApiMethodCache(TimeSpan.FromSeconds(1), time);
|
||||
var fetchCount = 0;
|
||||
Task<ApiMethod?> Fetch(CancellationToken _)
|
||||
{
|
||||
fetchCount++;
|
||||
return Task.FromResult<ApiMethod?>(Method("m"));
|
||||
}
|
||||
|
||||
await cache.GetOrFetchAsync("m", Fetch, CancellationToken.None);
|
||||
time.Advance(TimeSpan.FromSeconds(2));
|
||||
await cache.GetOrFetchAsync("m", Fetch, CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, fetchCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOrFetchAsync_NegativeResult_IsCachedUntilTtlExpires()
|
||||
{
|
||||
var cache = new ApiMethodCache(TimeSpan.FromMinutes(5));
|
||||
var fetchCount = 0;
|
||||
Task<ApiMethod?> Fetch(CancellationToken _)
|
||||
{
|
||||
fetchCount++;
|
||||
return Task.FromResult<ApiMethod?>(null);
|
||||
}
|
||||
|
||||
var first = await cache.GetOrFetchAsync("missing", Fetch, CancellationToken.None);
|
||||
var second = await cache.GetOrFetchAsync("missing", Fetch, CancellationToken.None);
|
||||
|
||||
Assert.Null(first);
|
||||
Assert.Null(second);
|
||||
Assert.Equal(1, fetchCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invalidate_DropsEntry_NextCallRefetches()
|
||||
{
|
||||
var cache = new ApiMethodCache(TimeSpan.FromMinutes(5));
|
||||
var fetchCount = 0;
|
||||
Task<ApiMethod?> Fetch(CancellationToken _)
|
||||
{
|
||||
fetchCount++;
|
||||
return Task.FromResult<ApiMethod?>(Method("m"));
|
||||
}
|
||||
|
||||
await cache.GetOrFetchAsync("m", Fetch, CancellationToken.None);
|
||||
cache.Invalidate("m");
|
||||
await cache.GetOrFetchAsync("m", Fetch, CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, fetchCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Invalidate_UnknownName_DoesNotThrow()
|
||||
{
|
||||
var cache = new ApiMethodCache(TimeSpan.FromMinutes(5));
|
||||
cache.Invalidate("never-cached");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NonPositiveTtl_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new ApiMethodCache(TimeSpan.Zero));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new ApiMethodCache(TimeSpan.FromSeconds(-1)));
|
||||
}
|
||||
|
||||
/// <summary>Minimal controllable <see cref="TimeProvider"/> for TTL-expiry tests.</summary>
|
||||
private sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now;
|
||||
public FakeTimeProvider(DateTimeOffset start) => _now = start;
|
||||
public void Advance(TimeSpan by) => _now += by;
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
}
|
||||
}
|
||||
@@ -65,4 +65,22 @@ public class InboundApiOptionsValidatorTests
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("MaxRequestBodyBytes", result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroApiMethodCacheTtl_IsRejected()
|
||||
{
|
||||
var result = Validate(new InboundApiOptions { ApiMethodCacheTtl = TimeSpan.Zero });
|
||||
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("ApiMethodCacheTtl", result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NegativeApiMethodCacheTtl_IsRejected()
|
||||
{
|
||||
var result = Validate(new InboundApiOptions { ApiMethodCacheTtl = TimeSpan.FromSeconds(-1) });
|
||||
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("ApiMethodCacheTtl", result.FailureMessage);
|
||||
}
|
||||
}
|
||||
|
||||
+30
-1
@@ -41,12 +41,13 @@ public class ScriptArtifactChangeSubscriberTests
|
||||
|
||||
private readonly InboundScriptExecutor _executor = new(
|
||||
NullLogger<InboundScriptExecutor>.Instance, Substitute.For<IServiceProvider>());
|
||||
private readonly ApiMethodCache _methodCache = new(TimeSpan.FromMinutes(5));
|
||||
private readonly RecordingBus _bus = new();
|
||||
private readonly RouteHelper _route = new(
|
||||
Substitute.For<IInstanceLocator>(), Substitute.For<IInstanceRouter>());
|
||||
|
||||
private ScriptArtifactChangeSubscriber CreateSubscriber(IScriptArtifactChangeBus? bus) =>
|
||||
new(_executor, NullLogger<ScriptArtifactChangeSubscriber>.Instance, bus);
|
||||
new(_executor, _methodCache, NullLogger<ScriptArtifactChangeSubscriber>.Instance, bus);
|
||||
|
||||
private Task<InboundScriptResult> Run(ApiMethod m) => _executor.ExecuteAsync(
|
||||
m, new Dictionary<string, object?>(), _route, TimeSpan.FromSeconds(10));
|
||||
@@ -121,6 +122,34 @@ public class ScriptArtifactChangeSubscriberTests
|
||||
Assert.Equal(0, _bus.SubscriberCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP2.6b: an ApiMethod change notification must also drop the resolved-row cache
|
||||
/// entry (not just the compiled handler) — proving EndpointExtensions' next lookup
|
||||
/// re-fetches instead of serving a stale cached row for the rest of the TTL window.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ApiMethodPublish_InvalidatesResolvedMethodCache()
|
||||
{
|
||||
var subscriber = CreateSubscriber(_bus);
|
||||
await subscriber.StartAsync(CancellationToken.None);
|
||||
|
||||
var fetchCount = 0;
|
||||
Task<ApiMethod?> Fetch(CancellationToken _)
|
||||
{
|
||||
fetchCount++;
|
||||
return Task.FromResult<ApiMethod?>(new ApiMethod("m", "return 1;") { Id = 1 });
|
||||
}
|
||||
|
||||
await _methodCache.GetOrFetchAsync("m", Fetch, CancellationToken.None);
|
||||
await _methodCache.GetOrFetchAsync("m", Fetch, CancellationToken.None);
|
||||
Assert.Equal(1, fetchCount); // second call served from cache
|
||||
|
||||
_bus.Publish(ApiMethodChanged("m"));
|
||||
|
||||
await _methodCache.GetOrFetchAsync("m", Fetch, CancellationToken.None);
|
||||
Assert.Equal(2, fetchCount); // cache entry was dropped — re-fetched
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NullBus_NoOps()
|
||||
{
|
||||
|
||||
@@ -215,4 +215,99 @@ public class SiteRepositoryTests : IDisposable
|
||||
// (arch-review 08 §1.3/#23) along with the vestigial SiteNotificationRepository —
|
||||
// notification config is central-only and never lives on a site. The synthetic-ID
|
||||
// stability guarantee is still exercised by ExternalSystemRepository_SyntheticId_IsStableAcrossRestart.
|
||||
|
||||
// ── WP2.6a: ExternalSystemDefinitionCache (arch-review misc — site external-system resolution) ──
|
||||
|
||||
/// <summary>
|
||||
/// WP2.6a: two repository instances sharing one <see cref="ExternalSystemDefinitionCache"/>
|
||||
/// (the production DI shape — one singleton cache, many scoped repository instances) must
|
||||
/// see the SAME cached snapshot: a row written after the cache was already populated by
|
||||
/// the first repository is invisible to the second until the cache is invalidated.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExternalSystemRepository_SharedCache_HitServesStaleSnapshotUntilInvalidated()
|
||||
{
|
||||
var storage = NewStorage();
|
||||
await storage.InitializeAsync();
|
||||
await storage.StoreExternalSystemAsync("Alpha", "https://alpha.test", "None", null, null);
|
||||
|
||||
var cache = new ExternalSystemDefinitionCache();
|
||||
var repo1 = new SiteExternalSystemRepository(storage, cache);
|
||||
var repo2 = new SiteExternalSystemRepository(storage, cache);
|
||||
|
||||
// repo1 populates the shared cache.
|
||||
var initial = await repo1.GetAllExternalSystemsAsync();
|
||||
Assert.Single(initial);
|
||||
|
||||
// A new system is written directly to storage — repo2, sharing the same
|
||||
// cache, must NOT see it yet (cache hit serves the stale snapshot).
|
||||
await storage.StoreExternalSystemAsync("Beta", "https://beta.test", "None", null, null);
|
||||
var stillStale = await repo2.GetAllExternalSystemsAsync();
|
||||
Assert.Single(stillStale);
|
||||
|
||||
// After invalidation, the next read reloads from storage and sees both rows.
|
||||
cache.InvalidateAll();
|
||||
var fresh = await repo2.GetAllExternalSystemsAsync();
|
||||
Assert.Equal(2, fresh.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP2.6a: the by-ID lookups (system and method) must resolve correctly out of the
|
||||
/// cached snapshot — proving the O(1) id-index build in <c>LoadSnapshotAsync</c> is
|
||||
/// wired correctly for both entity kinds, not just the by-name path already covered
|
||||
/// by ExternalSystemGateway-011.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExternalSystemRepository_SharedCache_ByIdLookups_ResolveSystemAndMethod()
|
||||
{
|
||||
var storage = NewStorage();
|
||||
await storage.InitializeAsync();
|
||||
var methodDefs = "[{\"Name\":\"getData\",\"HttpMethod\":\"GET\",\"Path\":\"/data\"}]";
|
||||
await storage.StoreExternalSystemAsync(
|
||||
"WeatherApi", "https://api.example.com", "ApiKey", null, methodDefs);
|
||||
|
||||
var cache = new ExternalSystemDefinitionCache();
|
||||
var repo = new SiteExternalSystemRepository(storage, cache);
|
||||
|
||||
var system = await repo.GetExternalSystemByNameAsync("WeatherApi");
|
||||
Assert.NotNull(system);
|
||||
|
||||
var byId = await repo.GetExternalSystemByIdAsync(system!.Id);
|
||||
Assert.NotNull(byId);
|
||||
Assert.Equal("WeatherApi", byId!.Name);
|
||||
|
||||
var methods = await repo.GetMethodsByExternalSystemIdAsync(system.Id);
|
||||
Assert.Single(methods);
|
||||
|
||||
var methodById = await repo.GetExternalSystemMethodByIdAsync(methods[0].Id);
|
||||
Assert.NotNull(methodById);
|
||||
Assert.Equal("getData", methodById!.Name);
|
||||
|
||||
Assert.Null(await repo.GetExternalSystemByIdAsync(-1));
|
||||
Assert.Null(await repo.GetExternalSystemMethodByIdAsync(-1));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP2.6a: a repository constructed via the single-arg (no shared cache) constructor
|
||||
/// gets its own private cache — a second such repository over the same storage must
|
||||
/// see rows written after the first repository's cache was already populated,
|
||||
/// preserving the pre-cache "always fresh" behavior for callers that opt out of
|
||||
/// sharing.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExternalSystemRepository_PrivateCache_DoesNotShareAcrossInstances()
|
||||
{
|
||||
var storage = NewStorage();
|
||||
await storage.InitializeAsync();
|
||||
await storage.StoreExternalSystemAsync("Alpha", "https://alpha.test", "None", null, null);
|
||||
|
||||
var repo1 = new SiteExternalSystemRepository(storage);
|
||||
Assert.Single(await repo1.GetAllExternalSystemsAsync());
|
||||
|
||||
await storage.StoreExternalSystemAsync("Beta", "https://beta.test", "None", null, null);
|
||||
|
||||
// A brand-new repository instance (its own private cache, unpopulated) sees both rows.
|
||||
var repo2 = new SiteExternalSystemRepository(storage);
|
||||
Assert.Equal(2, (await repo2.GetAllExternalSystemsAsync()).Count);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,4 +75,13 @@ public class SiteRuntimeOptionsValidatorTests
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("StartupBatchDelayMs", result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroAlarmPublishQueueCapacity_IsRejected()
|
||||
{
|
||||
var result = Validate(new SiteRuntimeOptions { AlarmPublishQueueCapacity = 0 });
|
||||
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("AlarmPublishQueueCapacity", result.FailureMessage);
|
||||
}
|
||||
}
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Streaming;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Streaming;
|
||||
|
||||
/// <summary>
|
||||
/// WP2.6d: the hosted reporter must lift <see cref="SiteStreamManager.AlarmPublishDroppedCount"/>
|
||||
/// onto the site health report via <see cref="ISiteHealthCollector"/>. Uses the real
|
||||
/// collector (mirrors <c>ScriptSchedulerStatsReporterTests</c> — NSubstitute is not
|
||||
/// referenced by this test project). The queue's own drop-counting mechanism is verified
|
||||
/// deterministically and separately by
|
||||
/// <see cref="Streaming.SiteStreamManagerTests.CreateAlarmPublishQueue_OverCapacity_DropsOldestAndInvokesCallback"/>;
|
||||
/// this test proves the reporter's poll-and-push wiring runs correctly.
|
||||
/// </summary>
|
||||
public class SiteStreamAlarmDropReporterTests : TestKit, IDisposable
|
||||
{
|
||||
void IDisposable.Dispose() => Shutdown();
|
||||
|
||||
[Fact]
|
||||
public async Task Reporter_PushesAlarmDropCountToCollector()
|
||||
{
|
||||
var options = new SiteRuntimeOptions { StreamBufferSize = 100 };
|
||||
var collector = new SiteHealthCollector();
|
||||
var streamManager = new SiteStreamManager(options, NullLogger<SiteStreamManager>.Instance);
|
||||
streamManager.Initialize(Sys);
|
||||
|
||||
using var reporter = new SiteStreamAlarmDropReporter(
|
||||
collector, streamManager, NullLogger<SiteStreamAlarmDropReporter>.Instance,
|
||||
pollInterval: TimeSpan.FromMilliseconds(50));
|
||||
|
||||
await reporter.StartAsync(CancellationToken.None);
|
||||
try
|
||||
{
|
||||
// The immediate first probe (no drops yet) must reach the report as 0 —
|
||||
// proving the reporter actually ran and pushed a value, not that the field
|
||||
// simply defaulted.
|
||||
await WaitUntilAsync(() =>
|
||||
collector.CollectReport("site-1").SiteStreamAlarmDropCount == streamManager.AlarmPublishDroppedCount);
|
||||
|
||||
var report = collector.CollectReport("site-1");
|
||||
Assert.Equal(streamManager.AlarmPublishDroppedCount, report.SiteStreamAlarmDropCount);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await reporter.StopAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitUntilAsync(Func<bool> condition)
|
||||
{
|
||||
for (var i = 0; i < 100 && !condition(); i++)
|
||||
await Task.Delay(50);
|
||||
Assert.True(condition(), "condition not met within timeout");
|
||||
}
|
||||
}
|
||||
@@ -159,4 +159,86 @@ public class SiteStreamManagerTests : TestKit, IDisposable
|
||||
_streamManager.RemoveSubscriber(probe.Ref);
|
||||
Assert.Equal(0, _streamManager.SubscriptionCount);
|
||||
}
|
||||
|
||||
// ── WP2.6d: separate alarm publish path, drop counter, skip-at-zero-subscribers ──
|
||||
|
||||
/// <summary>
|
||||
/// WP2.6d: alarm state changes now travel a dedicated publish source, isolated from
|
||||
/// the (far higher-volume) attribute path — a burst of attribute events for OTHER
|
||||
/// instances, interleaved with an alarm-only subscriber's events, must not cause any
|
||||
/// alarm to be lost.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void PublishAlarmStateChanged_SurvivesConcurrentAttributeStorm_ForUnrelatedInstances()
|
||||
{
|
||||
var alarmProbe = CreateTestProbe();
|
||||
_streamManager.SubscribeSiteAlarms(alarmProbe.Ref);
|
||||
|
||||
// A storm of attribute events for a DIFFERENT instance — none of which the
|
||||
// alarm subscriber is even listening to — interleaved with alarm events. Before
|
||||
// WP2.6d these shared one upstream buffer; now they are fully separate sources.
|
||||
for (var i = 0; i < 500; i++)
|
||||
{
|
||||
_streamManager.PublishAttributeValueChanged(new AttributeValueChanged(
|
||||
"NoisyPump", "Temperature", "Temperature", i.ToString(), "Good", DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
_streamManager.PublishAlarmStateChanged(new AlarmStateChanged(
|
||||
"Pump1", "HighTemp", AlarmState.Active, 1, DateTimeOffset.UtcNow));
|
||||
|
||||
var received = alarmProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(3));
|
||||
Assert.Equal("Pump1", received.InstanceUniqueName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP2.6d: with zero subscribers of any kind, PublishAlarmStateChanged/
|
||||
/// PublishAttributeValueChanged must be no-ops — no exception, and (for alarms) the
|
||||
/// event never reaches the bounded hand-off queue, so
|
||||
/// <see cref="SiteStreamManager.AlarmPublishDroppedCount"/> stays at zero rather than
|
||||
/// counting events nobody could ever have received anyway.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Publish_WithNoSubscribers_IsNoOp_AndDoesNotCountAsDropped()
|
||||
{
|
||||
_streamManager.PublishAlarmStateChanged(new AlarmStateChanged(
|
||||
"Pump1", "HighTemp", AlarmState.Active, 1, DateTimeOffset.UtcNow));
|
||||
_streamManager.PublishAttributeValueChanged(new AttributeValueChanged(
|
||||
"Pump1", "Temperature", "Temperature", "1", "Good", DateTimeOffset.UtcNow));
|
||||
|
||||
Assert.Equal(0, _streamManager.AlarmPublishDroppedCount);
|
||||
|
||||
// Publishing resumes working normally once a subscriber exists.
|
||||
var probe = CreateTestProbe();
|
||||
_streamManager.SubscribeSiteAlarms(probe.Ref);
|
||||
_streamManager.PublishAlarmStateChanged(new AlarmStateChanged(
|
||||
"Pump1", "HighTemp", AlarmState.Active, 2, DateTimeOffset.UtcNow));
|
||||
|
||||
probe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(3));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP2.6d: once the bounded alarm hand-off queue (the exact factory
|
||||
/// <see cref="SiteStreamManager.Initialize"/> wires into the drop-count callback) is
|
||||
/// full, a further write evicts the oldest queued item and fires the drop callback.
|
||||
/// Exercised directly against the queue factory — deterministic, no actor
|
||||
/// system/async pump race involved (see the factory's own doc comment).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateAlarmPublishQueue_OverCapacity_DropsOldestAndInvokesCallback()
|
||||
{
|
||||
var dropped = 0;
|
||||
var queue = SiteStreamManager.CreateAlarmPublishQueue(capacity: 2, () => Interlocked.Increment(ref dropped));
|
||||
|
||||
// Nothing reads from this queue, so all five writes stay purely upstream —
|
||||
// capacity 2 means the 3rd/4th/5th writes must each evict the oldest.
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
var wrote = queue.Writer.TryWrite(new AlarmStateChanged(
|
||||
"Pump1", "HighTemp", AlarmState.Active, i, DateTimeOffset.UtcNow));
|
||||
Assert.True(wrote); // DropOldest TryWrite always succeeds once capacity > 0
|
||||
}
|
||||
|
||||
Assert.Equal(3, dropped);
|
||||
Assert.Equal(2, queue.Reader.Count);
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -83,4 +83,16 @@ public class StoreAndForwardOptionsValidatorTests
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("SweepTargetParallelism", result.FailureMessage);
|
||||
}
|
||||
|
||||
// ── WP2.6c: bounded observer queue capacity ──
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
public void Validate_NonPositiveObserverQueueCapacity_Fails(int capacity)
|
||||
{
|
||||
var result = Validate(new StoreAndForwardOptions { ObserverQueueCapacity = capacity });
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("ObserverQueueCapacity", result.FailureMessage);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.TestSupport;
|
||||
|
||||
@@ -885,4 +887,82 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
|
||||
release.TrySetResult();
|
||||
await stop.WaitAsync(TimeSpan.FromSeconds(5)); // drains the real sweep promptly once released
|
||||
}
|
||||
|
||||
// ── WP2.6c: bounded, DropOldest observer queue + drop counter ──
|
||||
|
||||
private sealed class BlockingObserver : ICachedCallLifecycleObserver
|
||||
{
|
||||
private readonly TaskCompletionSource _gate;
|
||||
public BlockingObserver(TaskCompletionSource gate) => _gate = gate;
|
||||
public async Task OnAttemptCompletedAsync(CachedCallAttemptContext context, CancellationToken ct = default)
|
||||
=> await _gate.Task;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP2.6c: the cached-call audit-observer queue is bounded — once the single-reader
|
||||
/// pump is stuck awaiting a slow/stuck observer, further posted notifications must
|
||||
/// evict the oldest queued one (DropOldest) instead of growing without bound, and
|
||||
/// every eviction must increment <see cref="StoreAndForwardService.ObserverQueueDroppedCount"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ObserverQueue_BoundedCapacity_DropsOldestAndCountsDrops()
|
||||
{
|
||||
var gate = new TaskCompletionSource();
|
||||
var observer = new BlockingObserver(gate);
|
||||
|
||||
var localDb = TestLocalDb.CreateTemp("ObsQueueBound");
|
||||
var storage = new StoreAndForwardStorage(localDb.Db, NullLogger<StoreAndForwardStorage>.Instance);
|
||||
await storage.InitializeAsync();
|
||||
var service = new StoreAndForwardService(
|
||||
storage,
|
||||
new StoreAndForwardOptions
|
||||
{
|
||||
DefaultRetryInterval = TimeSpan.Zero,
|
||||
DefaultMaxRetries = 5,
|
||||
RetryTimerInterval = TimeSpan.FromHours(1), // timer never fires in-test
|
||||
ObserverQueueCapacity = 2,
|
||||
},
|
||||
NullLogger<StoreAndForwardService>.Instance,
|
||||
cachedCallObserver: observer,
|
||||
siteId: "site-77");
|
||||
|
||||
await service.StartAsync();
|
||||
try
|
||||
{
|
||||
service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
|
||||
_ => throw new HttpRequestException("transient"));
|
||||
|
||||
// Enqueue more messages than the bounded capacity (2) — the pump dequeues
|
||||
// the first notification and blocks on the observer gate, so every
|
||||
// subsequent notification posted during this sweep queues (and, past
|
||||
// capacity, evicts the oldest still-queued one) rather than being
|
||||
// processed.
|
||||
for (var i = 0; i < 6; i++)
|
||||
{
|
||||
await service.EnqueueAsync(
|
||||
StoreAndForwardCategory.ExternalSystem, $"t{i}", "{}",
|
||||
attemptImmediateDelivery: false, retryInterval: TimeSpan.Zero,
|
||||
messageId: TrackedOperationId.New().ToString());
|
||||
}
|
||||
|
||||
await service.RetryPendingMessagesAsync();
|
||||
|
||||
// Give the bounded channel a moment to have absorbed/evicted every post
|
||||
// (the pump itself stays blocked on the gate throughout).
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
|
||||
while (DateTime.UtcNow < deadline && service.ObserverQueueDroppedCount == 0)
|
||||
await Task.Delay(10);
|
||||
|
||||
Assert.True(service.ObserverQueueDroppedCount > 0,
|
||||
"expected at least one notification to be dropped once the bounded queue filled");
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.TrySetResult();
|
||||
await service.StopAsync();
|
||||
var path = localDb.Path;
|
||||
localDb.Dispose();
|
||||
TestLocalDb.DeleteFiles(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user