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:
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user