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.
This commit is contained in:
Joseph Doherty
2026-08-14 20:59:43 -04:00
parent ee193cd2bb
commit a212283104
32 changed files with 1361 additions and 111 deletions
@@ -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);
}
}