Merge branch 'worktree-agent-aae48b78656e5a4e0' into arch-review-remediation
# Conflicts: # src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Repositories/SiteExternalSystemRepository.cs
This commit is contained in:
@@ -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&F observer queue): capacity of the
|
||||
/// cached-call audit-observer pump's queue. It was the one unbounded
|
||||
/// <c>Channel<T></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())
|
||||
|
||||
Reference in New Issue
Block a user