Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ScriptArtifactChangeSubscriberTests.cs
T
Joseph Doherty a212283104 perf(misc): cached hot-path lookups, bounded observer queue, alarm-priority stream path
WP2.6 (arch-review remediation, cross-cutting misc):
- SiteExternalSystemRepository: name/ID-indexed ExternalSystemDefinitionCache replaces
  the fetch-all + reverse-map scan on every by-ID/method lookup; loaded once per
  redeploy, invalidated by DeploymentManagerActor after HandleDeployArtifacts applies
  external-system changes. Static JsonSerializerOptions for method-list parsing.
- Inbound API: short-TTL ApiMethodCache fronts the per-request ApiMethod repository
  fetch; invalidated by name via the existing ScriptArtifactChangeSubscriber/
  IScriptArtifactChangeBus pipeline, self-healing via TTL for changes the bus
  doesn't cover (e.g. Management API edits).
- StoreAndForward: the cached-call audit-observer queue — the one unbounded channel
  left in the system — is now bounded (ObserverQueueCapacity, default 10,000) with
  DropOldest overflow and a dropped-notification counter.
- SiteStreamManager: alarm state changes now travel a dedicated publish
  source/broadcast hub, isolated from the (far higher-volume) attribute path, so an
  attribute storm can no longer evict a pending alarm transition; the alarm hand-off
  queue is bounded with a drop counter surfaced on the site health report
  (SiteStreamAlarmDropCount via the new SiteStreamAlarmDropReporter), and publishing
  is skipped entirely at zero subscribers on either path.
- CLI ManagementHttpClient: explicit 30s HttpClient.Timeout on the shared
  construction (was the 100s framework default), overridable via
  SCADABRIDGE_HTTP_TIMEOUT_SECONDS.

Deviation: the failback-probe heartbeat item is NOT included — its only viable
surface (CentralChannelProvider.cs / heartbeat consumers) lives entirely in the
Communication project, explicitly off-limits to this work package this phase.

Tests: SiteRuntime.Tests (550), InboundAPI.Tests (278), StoreAndForward.Tests (133),
CLI.Tests (390), HealthMonitoring.Tests (97) — all green after full solution build.
2026-08-14 20:59:43 -04:00

163 lines
6.6 KiB
C#

using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Scripting;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Scripting;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
/// <summary>
/// Arch-review R2 N1: the Inbound API must be a REAL subscriber of
/// IScriptArtifactChangeBus — a bundle-import publish must invalidate a cached
/// handler immediately, without waiting for the per-request revision-check
/// self-heal (which remains the correctness fallback per the invalidation
/// contract's normative semantics).
/// </summary>
public class ScriptArtifactChangeSubscriberTests
{
/// <summary>Minimal in-test bus: records subscriptions, delivers synchronously.</summary>
private sealed class RecordingBus : IScriptArtifactChangeBus
{
private readonly List<Action<ScriptArtifactsChanged>> _handlers = new();
public int SubscriberCount => _handlers.Count;
public void Publish(ScriptArtifactsChanged notification)
{
foreach (var handler in _handlers.ToArray()) handler(notification);
}
public IDisposable Subscribe(Action<ScriptArtifactsChanged> handler)
{
_handlers.Add(handler);
return new Token(() => _handlers.Remove(handler));
}
private sealed class Token(Action dispose) : IDisposable
{
public void Dispose() => dispose();
}
}
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, _methodCache, NullLogger<ScriptArtifactChangeSubscriber>.Instance, bus);
private Task<InboundScriptResult> Run(ApiMethod m) => _executor.ExecuteAsync(
m, new Dictionary<string, object?>(), _route, TimeSpan.FromSeconds(10));
private static ScriptArtifactsChanged ApiMethodChanged(params string[] names) =>
new(ScriptArtifactKinds.ApiMethod, names, "BundleImport", DateTimeOffset.UtcNow);
[Fact]
public async Task ApiMethodPublish_InvalidatesCachedHandler_WithoutRevisionCheckSelfHeal()
{
var subscriber = CreateSubscriber(_bus);
await subscriber.StartAsync(CancellationToken.None);
// Pin a SCRIPT-LESS handler: the RegisterHandler seam is exempt from the
// per-request revision check, so the ONLY mechanism that can stop it
// serving is an explicit InvalidateMethod — exactly what the bus must trigger.
_executor.RegisterHandler("m", async _ => { await Task.CompletedTask; return 99; });
var row = new ApiMethod("m", "return 1;") { Id = 1, TimeoutSeconds = 10 };
var before = await Run(row);
Assert.Contains("99", before.ResultJson); // pinned handler serves; no self-heal fires
_bus.Publish(ApiMethodChanged("m"));
var after = await Run(row);
Assert.True(after.Success);
Assert.Contains("1", after.ResultJson); // recompiled from the fresh row — invalidation, not self-heal
}
[Fact]
public async Task ApiMethodPublish_PurgesKnownBadRecord()
{
var subscriber = CreateSubscriber(_bus);
await subscriber.StartAsync(CancellationToken.None);
var broken = new ApiMethod("bad", "%%% not C# %%%") { Id = 2, TimeoutSeconds = 10 };
Assert.False(_executor.CompileAndRegister(broken));
Assert.Equal(1, _executor.KnownBadMethodCount);
_bus.Publish(ApiMethodChanged("bad"));
Assert.Equal(0, _executor.KnownBadMethodCount);
}
[Fact]
public async Task NonApiMethodKinds_AreIgnored()
{
var subscriber = CreateSubscriber(_bus);
await subscriber.StartAsync(CancellationToken.None);
_executor.RegisterHandler("m", async _ => { await Task.CompletedTask; return 99; });
var row = new ApiMethod("m", "return 1;") { Id = 3, TimeoutSeconds = 10 };
_bus.Publish(new ScriptArtifactsChanged(
ScriptArtifactKinds.SharedScript, new[] { "m" }, "BundleImport", DateTimeOffset.UtcNow));
_bus.Publish(new ScriptArtifactsChanged(
ScriptArtifactKinds.Template, new[] { "m" }, "BundleImport", DateTimeOffset.UtcNow));
var result = await Run(row);
Assert.Contains("99", result.ResultJson); // pinned handler untouched
}
[Fact]
public async Task StopAsync_Unsubscribes()
{
var subscriber = CreateSubscriber(_bus);
await subscriber.StartAsync(CancellationToken.None);
Assert.Equal(1, _bus.SubscriberCount);
await subscriber.StopAsync(CancellationToken.None);
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()
{
// Site roles / plain test hosts have no bus registered — the hosted
// service must start and stop cleanly (the revision check alone applies).
var subscriber = CreateSubscriber(bus: null);
await subscriber.StartAsync(CancellationToken.None);
await subscriber.StopAsync(CancellationToken.None);
}
}