feat(core): PollGroupEngine optional per-cycle batch callback
This commit is contained in:
@@ -24,6 +24,15 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// can record the failure. Without an <c>onError</c> callback the exception is silently
|
||||
/// swallowed (preserves the original behaviour for drivers that have not opted in yet).</para>
|
||||
///
|
||||
/// <para>An optional <c>onBatch</c> callback delivers the SAME changes collected per poll
|
||||
/// cycle: it is raised at most once per cycle, only when that cycle produced at least one
|
||||
/// change, and always AFTER every per-tag <c>onChange</c> callback of that same cycle. Drivers
|
||||
/// forward it to <see cref="IBatchSubscribable.OnDataChangeBatch"/> so downstream consumers can
|
||||
/// take one message per cycle instead of one per tag. Both callbacks fire by design — a
|
||||
/// consumer subscribes to exactly one of the two. Without an <c>onBatch</c> callback the engine
|
||||
/// behaves byte-identically to before: no batch is collected and no per-cycle allocation is
|
||||
/// made.</para>
|
||||
///
|
||||
/// <para>Programmer errors and obviously-fatal exceptions (<see cref="OutOfMemoryException"/>,
|
||||
/// <see cref="ThreadAbortException"/>, <see cref="StackOverflowException"/>,
|
||||
/// <see cref="AccessViolationException"/>) are NOT caught — they propagate and tear the poll
|
||||
@@ -34,6 +43,7 @@ public sealed class PollGroupEngine : IAsyncDisposable
|
||||
private readonly Func<IReadOnlyList<string>, CancellationToken, Task<IReadOnlyList<DataValueSnapshot>>> _reader;
|
||||
private readonly Action<ISubscriptionHandle, string, DataValueSnapshot> _onChange;
|
||||
private readonly Action<Exception>? _onError;
|
||||
private readonly Action<ISubscriptionHandle, IReadOnlyList<DataChangeItem>>? _onBatch;
|
||||
private readonly TimeSpan _minInterval;
|
||||
private readonly TimeSpan? _backoffCap;
|
||||
private readonly ConcurrentDictionary<long, SubscriptionState> _subscriptions = new();
|
||||
@@ -58,12 +68,18 @@ public sealed class PollGroupEngine : IAsyncDisposable
|
||||
/// (interval, 2×, 4×, … saturating at this cap) instead of hammering a dead device every
|
||||
/// tick; a successful poll resets to the base interval. <c>null</c> (default) keeps the
|
||||
/// byte-identical fixed-interval cadence for consumers that have not opted in.</param>
|
||||
/// <param name="onBatch">Optional per-cycle batch callback — invoked once per poll cycle that
|
||||
/// produced at least one change, carrying exactly that cycle's changed items in reader
|
||||
/// order, after all of the cycle's per-tag <paramref name="onChange"/> callbacks. The
|
||||
/// driver forwards it to its own <see cref="IBatchSubscribable.OnDataChangeBatch"/> event.
|
||||
/// <c>null</c> (default) collects nothing and allocates nothing per cycle.</param>
|
||||
public PollGroupEngine(
|
||||
Func<IReadOnlyList<string>, CancellationToken, Task<IReadOnlyList<DataValueSnapshot>>> reader,
|
||||
Action<ISubscriptionHandle, string, DataValueSnapshot> onChange,
|
||||
TimeSpan? minInterval = null,
|
||||
Action<Exception>? onError = null,
|
||||
TimeSpan? backoffCap = null)
|
||||
TimeSpan? backoffCap = null,
|
||||
Action<ISubscriptionHandle, IReadOnlyList<DataChangeItem>>? onBatch = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(reader);
|
||||
ArgumentNullException.ThrowIfNull(onChange);
|
||||
@@ -72,6 +88,7 @@ public sealed class PollGroupEngine : IAsyncDisposable
|
||||
_onError = onError;
|
||||
_minInterval = minInterval ?? DefaultMinInterval;
|
||||
_backoffCap = backoffCap;
|
||||
_onBatch = onBatch;
|
||||
}
|
||||
|
||||
/// <summary>Register a new polled subscription and start its background loop.</summary>
|
||||
@@ -211,6 +228,10 @@ public sealed class PollGroupEngine : IAsyncDisposable
|
||||
$"Reader contract violation: expected {state.TagReferences.Count} snapshots but received {snapshots.Count}. " +
|
||||
"The reader delegate must return one snapshot per input reference in input order.");
|
||||
|
||||
// Collected lazily: nothing is allocated for a cycle with no changes, and nothing at all
|
||||
// when no batch callback is wired.
|
||||
List<DataChangeItem>? changed = null;
|
||||
|
||||
for (var i = 0; i < state.TagReferences.Count; i++)
|
||||
{
|
||||
var tagRef = state.TagReferences[i];
|
||||
@@ -221,8 +242,12 @@ public sealed class PollGroupEngine : IAsyncDisposable
|
||||
{
|
||||
state.LastValues[tagRef] = current;
|
||||
_onChange(state.Handle, tagRef, current);
|
||||
if (_onBatch is not null) (changed ??= new List<DataChangeItem>()).Add(new DataChangeItem(tagRef, current));
|
||||
}
|
||||
}
|
||||
|
||||
// One raise per cycle, after every per-tag callback of that cycle, never empty.
|
||||
if (changed is { Count: > 0 }) _onBatch!(state.Handle, changed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user