feat(transport): observability for the import-session cap (register row 53)
Nothing counted open import sessions and nothing logged a rejection at MaxConcurrentImportSessions (default 8), so the register-row-43 session leak could only be inferred from rotating test failures. - Gauge scadabridge.central.transport.import_sessions.open on the existing ZB.MOM.WW.ScadaBridge meter — already in SiteServiceRegistration.ObservedMeters (the ZbTelemetryOptions.Meters allowlist), so no telemetry config change is needed and the series is not silently dropped. Provider-backed, mirroring the StoreAndForward queue-depth gauge: registered by BundleSessionEvictionService .StartAsync (Transport's only lifecycle seam) and identity-checked-cleared on StopAsync so a late stop cannot clobber a newer registrant. - IBundleSessionStore.OpenSessionCount counts only UNEXPIRED sessions. The raw dictionary count would report the node as pinned at the cap for up to a one-minute sweep interval after a burst had drained — the exact false positive the instrument exists to rule out. - BundleSessionStore.Open logs Warning with open count, cap and TTL when it rejects. Fires only on a real cap hit: a store full of expired sessions is reclaimed first and logs nothing. - Docs: docs/components/Transport.md + docs/requirements/Component-Transport.md. Tests: Transport 173 passed (+6 new — Warning content, two negative controls, unexpired-only counting, gauge register/clear, no-clobber-on-late-stop).
This commit is contained in:
@@ -18,6 +18,15 @@ public interface IBundleSessionStore
|
||||
/// <summary>Removes all sessions whose expiry has passed.</summary>
|
||||
void EvictExpired();
|
||||
|
||||
/// <summary>
|
||||
/// Register row 53: the number of sessions currently held that have not yet expired.
|
||||
/// Backs the <c>scadabridge.central.transport.import_sessions.open</c> gauge, so it
|
||||
/// must be cheap and side-effect-free (it is read from the metrics collection thread)
|
||||
/// and must NOT count expired-but-unswept entries — otherwise the gauge would read
|
||||
/// as "at the cap" for up to a sweep interval after a burst drained.
|
||||
/// </summary>
|
||||
int OpenSessionCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// T-003: returns the current unlock-failure count for a bundle keyed by its
|
||||
/// content hash. The counter is server-owned so a second tab / CLI caller
|
||||
|
||||
@@ -118,6 +118,14 @@ public static class ScadaBridgeTelemetry
|
||||
/// <summary>Provider that yields the live StoreAndForward queue depth; set by a later task.</summary>
|
||||
private static Func<long>? _queueDepthProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Register row 53: provider that yields the number of currently open (unexpired)
|
||||
/// Transport bundle-import sessions, registered by the Transport component's
|
||||
/// session-eviction hosted service. Null on a site node (Transport is central-only),
|
||||
/// where the gauge simply reports 0.
|
||||
/// </summary>
|
||||
private static Func<long>? _importSessionCountProvider;
|
||||
|
||||
#pragma warning disable IDE0052 // Held to keep the observable gauges alive for the meter's lifetime.
|
||||
/// <summary>Gauge reporting the number of currently open site connections.</summary>
|
||||
private static readonly ObservableGauge<long> _siteConnectionUp =
|
||||
@@ -138,6 +146,20 @@ public static class ScadaBridgeTelemetry
|
||||
() => Interlocked.Read(ref _liveAlarmAggregatorsActive),
|
||||
unit: "1",
|
||||
description: "Number of per-site live-alarm aggregators currently running on the active central node.");
|
||||
|
||||
/// <summary>
|
||||
/// Register row 53: gauge reporting the number of open Transport bundle-import
|
||||
/// sessions on this central node. Each open session pins a fully decrypted bundle
|
||||
/// in memory and consumes one of <c>TransportOptions.MaxConcurrentImportSessions</c>
|
||||
/// (default 8), so a series that plateaus at the cap is the signature of leaked
|
||||
/// sessions — the failure mode register row 43 could only be inferred from rotating
|
||||
/// test failures because nothing counted them.
|
||||
/// </summary>
|
||||
private static readonly ObservableGauge<long> _transportImportSessionsOpen =
|
||||
Meter.CreateObservableGauge<long>("scadabridge.central.transport.import_sessions.open",
|
||||
() => Volatile.Read(ref _importSessionCountProvider) is { } p ? p() : 0L,
|
||||
unit: "1",
|
||||
description: "Number of currently open bundle-import sessions on this central node.");
|
||||
#pragma warning restore IDE0052
|
||||
|
||||
// ---------------- Emit helpers ----------------
|
||||
@@ -242,4 +264,42 @@ public static class ScadaBridgeTelemetry
|
||||
// delegate, so a stale stop cannot clobber a successor's provider.
|
||||
Interlocked.CompareExchange(ref _queueDepthProvider, null, provider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register row 53: registers the provider the open-import-sessions gauge reads on
|
||||
/// each observation. Called by the Transport component's session-eviction hosted
|
||||
/// service on start. A null provider is ignored so the gauge falls back to 0.
|
||||
/// The callback is invoked synchronously on the metrics collection thread, so it
|
||||
/// must stay cheap — the Transport implementation reads a
|
||||
/// <see cref="System.Collections.Concurrent.ConcurrentDictionary{TKey,TValue}"/> it
|
||||
/// already owns.
|
||||
/// </summary>
|
||||
/// <param name="provider">A callback returning the current open-session count.</param>
|
||||
public static void SetImportSessionCountProvider(Func<long> provider)
|
||||
{
|
||||
if (provider is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Volatile.Write(ref _importSessionCountProvider, provider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the open-import-sessions provider, but only if the currently registered
|
||||
/// provider is the exact <paramref name="provider"/> delegate passed in
|
||||
/// (reference-equal compare-and-clear), so a late stop of a superseded instance
|
||||
/// cannot stomp a newer registrant. Mirrors <see cref="ClearQueueDepthProvider"/>.
|
||||
/// </summary>
|
||||
/// <param name="provider">The provider delegate to remove; ignored unless it is the
|
||||
/// one currently registered.</param>
|
||||
public static void ClearImportSessionCountProvider(Func<long> provider)
|
||||
{
|
||||
if (provider is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Interlocked.CompareExchange(ref _importSessionCountProvider, null, provider);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user