Merge branch 'worktree-agent-aae48b78656e5a4e0' into arch-review-remediation

# Conflicts:
#	src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Repositories/SiteExternalSystemRepository.cs
This commit is contained in:
Joseph Doherty
2026-08-14 21:16:11 -04:00
32 changed files with 1361 additions and 120 deletions
@@ -38,4 +38,18 @@ public class StoreAndForwardOptions
/// 1 = legacy serial. Within a lane delivery stays sequential (per-target FIFO).
/// </summary>
public int SweepTargetParallelism { get; set; } = 4;
/// <summary>
/// WP2.6c (arch-review misc — unbounded S&amp;F observer queue): capacity of the
/// cached-call audit-observer pump's queue. It was the one unbounded
/// <c>Channel&lt;T&gt;</c> left in the system — a slow/stuck
/// <c>ICachedCallLifecycleObserver</c> (a SQLite audit write) could not stretch
/// the retry sweep, but nothing stopped it growing without bound while the sweep kept
/// posting. Bounded with <see cref="System.Threading.Channels.BoundedChannelFullMode.DropOldest"/>
/// — the same overflow policy as its sibling bounded channels
/// (<c>SiteEventLogger</c>'s write queue, the debug/alarm stream hubs) — so a stuck
/// observer sheds the oldest unprocessed notifications instead of leaking memory; drops
/// are counted (see <c>StoreAndForwardService.ObserverQueueDroppedCount</c>).
/// </summary>
public int ObserverQueueCapacity { get; set; } = 10_000;
}
@@ -41,5 +41,9 @@ public sealed class StoreAndForwardOptionsValidator : OptionsValidatorBase<Store
builder.RequireThat(options.SweepTargetParallelism >= 1,
$"ScadaBridge:StoreAndForward:SweepTargetParallelism must be >= 1 " +
$"(was {options.SweepTargetParallelism}); it caps concurrent (category,target) sweep lanes — 1 means serial.");
builder.RequireThat(options.ObserverQueueCapacity >= 1,
$"ScadaBridge:StoreAndForward:ObserverQueueCapacity must be >= 1 " +
$"(was {options.ObserverQueueCapacity}); it bounds the cached-call audit-observer pump's queue.");
}
}
@@ -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())