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
@@ -122,9 +122,52 @@ public class StoreAndForwardService
/// <see cref="StopAsync"/> completes it (a restarted instance needs a fresh
/// channel). Before <see cref="StartAsync"/> starts the pump, posts fall back
/// to inline processing (see <see cref="PostObserverNotification"/>).
/// <para>
/// WP2.6c: bounded (<see cref="StoreAndForwardOptions.ObserverQueueCapacity"/>,
/// default 10,000) with <see cref="BoundedChannelFullMode.DropOldest"/> — this was
/// the one unbounded channel left in the system; a pump that falls behind (a stuck
/// observer) now sheds the oldest unprocessed notification instead of growing
/// without bound. Drops are counted via <see cref="_observerQueueDroppedCount"/>
/// and exposed by <see cref="ObserverQueueDroppedCount"/>.
/// </para>
/// </summary>
private Channel<Func<Task>> _observerQueue =
Channel.CreateUnbounded<Func<Task>>(new UnboundedChannelOptions { SingleReader = true });
private Channel<Func<Task>> _observerQueue = CreateObserverQueue(
FieldInitializerObserverQueueCapacity, onDropped: null);
/// <summary>
/// Capacity used only for the <see cref="_observerQueue"/> field initializer, before
/// <see cref="StartAsync"/> re-creates the channel sized from
/// <see cref="StoreAndForwardOptions.ObserverQueueCapacity"/> (unavailable at field-init
/// time — <see cref="_options"/> is assigned in the constructor body). Irrelevant in
/// practice: a post before <see cref="StartAsync"/> falls back to inline processing
/// (see <see cref="PostObserverNotification"/>), so nothing is ever queued at this size.
/// </summary>
private const int FieldInitializerObserverQueueCapacity = 1;
/// <summary>
/// Cumulative count of cached-call audit-observer notifications dropped because
/// <see cref="_observerQueue"/> was at capacity (WP2.6c). Not reset across
/// <see cref="StartAsync"/>/<see cref="StopAsync"/> cycles — a diagnostic total for
/// the lifetime of this service instance.
/// </summary>
private long _observerQueueDroppedCount;
/// <summary>Diagnostic counter — see <see cref="_observerQueueDroppedCount"/>.</summary>
public long ObserverQueueDroppedCount => Interlocked.Read(ref _observerQueueDroppedCount);
/// <summary>
/// Builds a bounded, single-reader observer queue with DropOldest overflow, invoking
/// <paramref name="onDropped"/> (if supplied) on every eviction.
/// </summary>
private static Channel<Func<Task>> CreateObserverQueue(int capacity, Action? onDropped) =>
Channel.CreateBounded<Func<Task>>(
new BoundedChannelOptions(Math.Max(1, capacity))
{
SingleReader = true,
SingleWriter = false,
FullMode = BoundedChannelFullMode.DropOldest,
},
itemDropped: _ => onDropped?.Invoke());
/// <summary>
/// The single-reader pump draining <see cref="_observerQueue"/>, or
@@ -371,8 +414,17 @@ public class StoreAndForwardService
// StopAsync completes the channel, so a restarted instance needs a fresh
// one. The pump is best-effort: an observer that throws is logged and
// swallowed so a failing audit pipeline never corrupts retry bookkeeping.
_observerQueue = Channel.CreateUnbounded<Func<Task>>(
new UnboundedChannelOptions { SingleReader = true });
// WP2.6c: bounded + DropOldest, sized from options; a drop increments
// _observerQueueDroppedCount (surfaced via ObserverQueueDroppedCount) and is
// logged at Warning so a stuck observer is visible, not just silently lossy.
_observerQueue = CreateObserverQueue(_options.ObserverQueueCapacity, onDropped: () =>
{
Interlocked.Increment(ref _observerQueueDroppedCount);
_logger.LogWarning(
"Cached-call audit-observer queue exceeded its bounded capacity ({Capacity}); " +
"oldest pending notification dropped (total dropped: {Dropped})",
_options.ObserverQueueCapacity, Interlocked.Read(ref _observerQueueDroppedCount));
});
_observerPump = Task.Run(async () =>
{
await foreach (var work in _observerQueue.Reader.ReadAllAsync())