feat(core): PollGroupEngine optional per-cycle batch callback

This commit is contained in:
Joseph Doherty
2026-08-15 12:11:12 -04:00
parent bb3ccf264b
commit adebad711f
2 changed files with 237 additions and 1 deletions
@@ -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>