perf(historian): bounded-parallel per-tag drain (DrainConcurrency, default 8)
This commit is contained in:
+183
@@ -607,6 +607,189 @@ public sealed class ContinuousHistorizationRecorderTests : TestKit
|
||||
}, TimeSpan.FromSeconds(10));
|
||||
}
|
||||
|
||||
// ---------------- Bounded-parallel drain (DrainConcurrency) ----------------
|
||||
|
||||
/// <summary>
|
||||
/// A drain pass writes distinct historian tags CONCURRENTLY, bounded by <c>DrainConcurrency</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The tags in one peeked batch are independent gateway round-trips, so a sequential foreach makes
|
||||
/// a pass cost <c>tags × latency</c>. The load-bearing observation is a POSITIVE one — two writes
|
||||
/// genuinely in flight at the same instant — which is why the writer double blocks every call on a
|
||||
/// TCS and records the peak in-flight count. Measuring wall-clock time instead would be a flake
|
||||
/// generator and would not distinguish "parallel" from "fast".
|
||||
/// <para>
|
||||
/// The values arrive as ONE <see cref="VirtualTagActor.DependencyValueBatch"/> deliberately:
|
||||
/// the per-value handler nudges a DrainTick after each append, so three separate messages could
|
||||
/// start the drain on a batch of one and the pass would hold a single group — nothing to
|
||||
/// parallelise. A batch message appends all three before any DrainTick is dequeued.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task Drain_writes_distinct_tags_concurrently_within_the_window()
|
||||
{
|
||||
var mux = CreateTestProbe();
|
||||
var writer = new GatingValueWriter();
|
||||
var outbox = new InMemoryOutbox();
|
||||
|
||||
IActorRef rec = Sys.ActorOf(ContinuousHistorizationRecorder.Props(
|
||||
mux.Ref, writer, outbox, historizedRefs: new[] { "A", "B", "C" },
|
||||
drainInterval: TimeSpan.FromMilliseconds(50),
|
||||
drainConcurrency: 8));
|
||||
|
||||
rec.Tell(new VirtualTagActor.DependencyValueBatch(new[]
|
||||
{
|
||||
new VirtualTagActor.DependencyValueChanged("A", 1.0, DateTime.UtcNow),
|
||||
new VirtualTagActor.DependencyValueChanged("B", 2.0, DateTime.UtcNow),
|
||||
new VirtualTagActor.DependencyValueChanged("C", 3.0, DateTime.UtcNow),
|
||||
}));
|
||||
|
||||
await AwaitAssertAsync(
|
||||
() =>
|
||||
{
|
||||
Assert.True(
|
||||
writer.MaxObservedConcurrency >= 2,
|
||||
$"expected overlapping writes; peak in-flight was {writer.MaxObservedConcurrency}");
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
TimeSpan.FromSeconds(10));
|
||||
|
||||
writer.Release();
|
||||
|
||||
// …and the pass still acks all-or-nothing: the whole peeked prefix leaves the outbox.
|
||||
await AwaitAssertAsync(async () =>
|
||||
{
|
||||
var status = await rec.Ask<ContinuousHistorizationRecorder.RecorderStatus>(
|
||||
ContinuousHistorizationRecorder.GetStatus.Instance, TimeSpan.FromSeconds(3));
|
||||
Assert.Equal(0, status.QueuedDepth);
|
||||
Assert.Equal(0, status.AbandonedEntries);
|
||||
}, TimeSpan.FromSeconds(10));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Falsification control: <c>DrainConcurrency = 1</c> is exactly the old sequential drain.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Same fixture as the test above, so the only difference is the window size. Without this an
|
||||
/// implementation that ignored the option entirely (always unbounded) would pass the concurrency
|
||||
/// test and nothing would pin the bound.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task Drain_concurrency_of_one_is_sequential()
|
||||
{
|
||||
var mux = CreateTestProbe();
|
||||
var writer = new GatingValueWriter();
|
||||
var outbox = new InMemoryOutbox();
|
||||
|
||||
IActorRef rec = Sys.ActorOf(ContinuousHistorizationRecorder.Props(
|
||||
mux.Ref, writer, outbox, historizedRefs: new[] { "A", "B", "C" },
|
||||
drainInterval: TimeSpan.FromMilliseconds(50),
|
||||
drainConcurrency: 1));
|
||||
|
||||
rec.Tell(new VirtualTagActor.DependencyValueBatch(new[]
|
||||
{
|
||||
new VirtualTagActor.DependencyValueChanged("A", 1.0, DateTime.UtcNow),
|
||||
new VirtualTagActor.DependencyValueChanged("B", 2.0, DateTime.UtcNow),
|
||||
new VirtualTagActor.DependencyValueChanged("C", 3.0, DateTime.UtcNow),
|
||||
}));
|
||||
|
||||
// The first write is in flight and blocked…
|
||||
await AwaitAssertAsync(
|
||||
() =>
|
||||
{
|
||||
Assert.True(writer.CallCount >= 1, "the drain must have started a write");
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
TimeSpan.FromSeconds(10));
|
||||
|
||||
// …and stays alone: with a window of 8 the other two groups would have entered by now.
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(300));
|
||||
Assert.Equal(1, writer.MaxObservedConcurrency);
|
||||
Assert.Equal(1, writer.CallCount);
|
||||
|
||||
writer.Release();
|
||||
|
||||
await AwaitAssertAsync(async () =>
|
||||
{
|
||||
var status = await rec.Ask<ContinuousHistorizationRecorder.RecorderStatus>(
|
||||
ContinuousHistorizationRecorder.GetStatus.Instance, TimeSpan.FromSeconds(3));
|
||||
Assert.Equal(0, status.QueuedDepth);
|
||||
}, TimeSpan.FromSeconds(10));
|
||||
Assert.Equal(1, writer.MaxObservedConcurrency);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IHistorianValueWriter"/> double that BLOCKS every call until <see cref="Release"/>,
|
||||
/// recording the peak number of calls in flight simultaneously. That peak is what distinguishes a
|
||||
/// bounded-parallel drain from a sequential one; a writer that returns synchronously (like
|
||||
/// <see cref="FakeValueWriter"/>) can never observe overlap however parallel the caller is.
|
||||
/// </summary>
|
||||
private sealed class GatingValueWriter : IHistorianValueWriter
|
||||
{
|
||||
private readonly Lock _gate = new();
|
||||
private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private int _inFlight;
|
||||
private int _maxObserved;
|
||||
private int _calls;
|
||||
|
||||
/// <summary>Peak number of <see cref="WriteLiveValuesAsync"/> calls observed in flight at once.</summary>
|
||||
public int MaxObservedConcurrency
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _maxObserved;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Lifetime count of <see cref="WriteLiveValuesAsync"/> invocations.</summary>
|
||||
public int CallCount
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _calls;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Unblocks every pending and future call.</summary>
|
||||
public void Release() => _release.TrySetResult();
|
||||
|
||||
public async Task<bool> WriteLiveValuesAsync(
|
||||
string tag, IReadOnlyList<HistorizationValue> values, CancellationToken ct)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_calls++;
|
||||
_inFlight++;
|
||||
if (_inFlight > _maxObserved)
|
||||
{
|
||||
_maxObserved = _inFlight;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// A bounded wait rather than an unbounded one: a regression that never releases fails the
|
||||
// assertion rather than hanging the whole test run.
|
||||
await _release.Task.WaitAsync(TimeSpan.FromSeconds(30), ct).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_inFlight--;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>In-memory <see cref="IHistorianValueWriter"/> double: records every value written and
|
||||
/// returns <see cref="Succeed"/> as the ack. Thread-safe — the recorder drains off the actor thread.</summary>
|
||||
private sealed class FakeValueWriter : IHistorianValueWriter
|
||||
|
||||
Reference in New Issue
Block a user