perf(drivers): fixed-rate poll pacing with overrun counter; non-blocking unsubscribe
This commit is contained in:
@@ -598,6 +598,106 @@ public sealed class PollGroupEngineTests
|
||||
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
// ---- Fixed-rate pacing + overrun counter + async unsubscribe (Task 11) ----
|
||||
|
||||
/// <summary>
|
||||
/// A reader slower than the publishing interval must NOT push the cycle out to
|
||||
/// (read + interval): the loop paces at a fixed RATE, so the post-poll delay is
|
||||
/// <c>max(interval - elapsed, 0)</c> and the next poll starts immediately. The overrun is
|
||||
/// counted and reported through the <c>onOverrun</c> callback.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Numbers: interval 150 ms, reader 250 ms. Old (delay-after-completion) cycle = 400 ms →
|
||||
/// ≈5 polls started in 2 s. Fixed-rate cycle = 250 ms → ≈8. The ≥7 assertion sits clear of
|
||||
/// both the old shape's ceiling and CI jitter.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task A_slow_reader_overrun_is_counted_and_the_next_poll_starts_promptly()
|
||||
{
|
||||
var readCount = 0;
|
||||
async Task<IReadOnlyList<DataValueSnapshot>> Reader(IReadOnlyList<string> refs, CancellationToken ct)
|
||||
{
|
||||
Interlocked.Increment(ref readCount);
|
||||
await Task.Delay(250, ct);
|
||||
var now = DateTime.UtcNow;
|
||||
return refs.Select(_ => new DataValueSnapshot(1, 0u, now, now)).ToList();
|
||||
}
|
||||
|
||||
var overruns = new ConcurrentQueue<TimeSpan>();
|
||||
await using var engine = new PollGroupEngine(
|
||||
Reader,
|
||||
(_, _, _) => { },
|
||||
minInterval: TimeSpan.FromMilliseconds(50),
|
||||
onOverrun: elapsed => overruns.Enqueue(elapsed));
|
||||
|
||||
var handle = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(150));
|
||||
await Task.Delay(2000, TestContext.Current.CancellationToken);
|
||||
var observedReads = Volatile.Read(ref readCount);
|
||||
var observedOverruns = engine.OverrunCount;
|
||||
await engine.UnsubscribeAsync(handle);
|
||||
|
||||
observedReads.ShouldBeGreaterThanOrEqualTo(7);
|
||||
observedOverruns.ShouldBeGreaterThanOrEqualTo(4);
|
||||
overruns.Count.ShouldBeGreaterThanOrEqualTo(4);
|
||||
overruns.ShouldAllBe(e => e > TimeSpan.FromMilliseconds(150));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Falsification control for the overrun counter: a reader that completes well inside the
|
||||
/// interval must never register an overrun (an unconditional increment would fail here).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_fast_reader_never_overruns()
|
||||
{
|
||||
var src = new FakeSource();
|
||||
src.Values["X"] = 1;
|
||||
|
||||
var overruns = new ConcurrentQueue<TimeSpan>();
|
||||
await using var engine = new PollGroupEngine(
|
||||
src.ReadAsync,
|
||||
(_, _, _) => { },
|
||||
minInterval: TimeSpan.FromMilliseconds(50),
|
||||
onOverrun: e => overruns.Enqueue(e));
|
||||
|
||||
var handle = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(100));
|
||||
await WaitForAsync(() => Volatile.Read(ref src.ReadCount) >= 4, TimeSpan.FromSeconds(3));
|
||||
await engine.UnsubscribeAsync(handle);
|
||||
|
||||
Volatile.Read(ref src.ReadCount).ShouldBeGreaterThanOrEqualTo(4);
|
||||
engine.OverrunCount.ShouldBe(0);
|
||||
overruns.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// <see cref="Unsubscribe_halts_the_loop"/> through <c>UnsubscribeAsync</c>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task UnsubscribeAsync_stops_callbacks_like_the_sync_path()
|
||||
{
|
||||
var src = new FakeSource();
|
||||
src.Values["X"] = 1;
|
||||
|
||||
var events = new ConcurrentQueue<(ISubscriptionHandle, string, DataValueSnapshot)>();
|
||||
await using var engine = new PollGroupEngine(src.ReadAsync,
|
||||
(h, r, s) => events.Enqueue((h, r, s)));
|
||||
|
||||
var handle = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(100));
|
||||
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(1));
|
||||
(await engine.UnsubscribeAsync(handle)).ShouldBeTrue();
|
||||
var afterUnsub = events.Count;
|
||||
engine.ActiveSubscriptionCount.ShouldBe(0);
|
||||
|
||||
src.Values["X"] = 999;
|
||||
await Task.Delay(400, TestContext.Current.CancellationToken);
|
||||
events.Count.ShouldBe(afterUnsub);
|
||||
|
||||
// An unknown handle is false through the async path too.
|
||||
(await engine.UnsubscribeAsync(new DummyHandle())).ShouldBeFalse();
|
||||
(await engine.UnsubscribeAsync(handle)).ShouldBeFalse();
|
||||
}
|
||||
|
||||
private sealed record DummyHandle : ISubscriptionHandle
|
||||
{
|
||||
/// <summary>Gets a diagnostic identifier for this handle.</summary>
|
||||
|
||||
Reference in New Issue
Block a user