feat(drivers): per-driver poll-overrun meter on the shared OtOpcUa Meter; NoteOverrun + backoffCap doc notes

This commit is contained in:
Joseph Doherty
2026-08-17 03:46:27 -04:00
parent acb3772426
commit e22ecea008
10 changed files with 147 additions and 12 deletions
@@ -670,6 +670,50 @@ public sealed class PollGroupEngineTests
overruns.Count.ShouldBeLessThanOrEqualTo(1);
}
/// <summary>
/// The counter and the callback must be the SAME signal, not two approximations of it: every
/// increment of <see cref="PollGroupEngine.OverrunCount"/> is matched by exactly one
/// <c>onOverrun</c> invocation. This is what lets a driver's meter (the production consumer,
/// <c>OtOpcUaTelemetry.PollOverruns</c>) be read as the counter itself — a callback that
/// fired on only some increments, or twice on one, would silently mis-scale the metric.
/// </summary>
/// <remarks>
/// The two existing pacing tests
/// (<see cref="A_slow_reader_overrun_is_counted_and_the_next_poll_starts_promptly"/> /
/// <see cref="A_fast_reader_never_overruns"/>) assert each side against its own loose
/// threshold, so they would both still pass if the callback fired on, say, every other
/// increment. This one compares the two directly, and does so only AFTER
/// <c>UnsubscribeAsync</c> has drained the loop — while a poll is in flight the increment
/// and the callback are two separate statements, so a live read could legitimately catch
/// them one apart and the equality would be flaky rather than falsifiable.
/// </remarks>
[Fact]
public async Task Every_overrun_increment_is_matched_by_exactly_one_onOverrun_callback()
{
async Task<IReadOnlyList<DataValueSnapshot>> Reader(IReadOnlyList<string> refs, CancellationToken ct)
{
await Task.Delay(120, ct);
var now = DateTime.UtcNow;
// A fresh value every poll, so the change path runs on every cycle too.
return refs.Select(_ => new DataValueSnapshot(now.Ticks, 0u, now, now)).ToList();
}
var overruns = new ConcurrentQueue<TimeSpan>();
await using var engine = new PollGroupEngine(
Reader,
(_, _, _) => { },
minInterval: TimeSpan.FromMilliseconds(20),
onOverrun: e => overruns.Enqueue(e));
var handle = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(40));
await Task.Delay(1000, TestContext.Current.CancellationToken);
await engine.UnsubscribeAsync(handle);
// Sanity: the scenario actually produced overruns, so the equality below is not vacuous.
engine.OverrunCount.ShouldBeGreaterThanOrEqualTo(3);
overruns.Count.ShouldBe((int)engine.OverrunCount);
}
/// <summary>
/// The async teardown must be behaviourally identical to the sync one: the handle is known,
/// the loop stops, and no further callback fires. Mirrors