perf(dcl): batch subscribe/read/write seam, bounded reconnect, sharded subscriptions
This commit is contained in:
+204
@@ -0,0 +1,204 @@
|
||||
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Actors;
|
||||
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// WP2.1b — the primitives the batch seam is built from, tested in isolation because the
|
||||
/// OPC Foundation Session/Subscription types they drive cannot be faked without a live
|
||||
/// server: monitored-item shard placement, the apply-serialization gate, the bounded
|
||||
/// pipeline behind the MxGateway supervisory advise, the alarm-stream union prefix, and
|
||||
/// the tag-resolution backoff step.
|
||||
/// </summary>
|
||||
public class BatchSeamPrimitiveTests
|
||||
{
|
||||
// ── Shard placement ──
|
||||
|
||||
[Fact]
|
||||
public void Sharding_SplitsItemsAtTheBudget()
|
||||
{
|
||||
// 12,001 items at the 5,000 default → 3 shards (memo §2 sizing example).
|
||||
Assert.Equal(3, MonitoredItemShardPlanner.ShardCountFor(12_001, 5_000));
|
||||
// 37,500 tags → 8 shards.
|
||||
Assert.Equal(8, MonitoredItemShardPlanner.ShardCountFor(37_500, 5_000));
|
||||
|
||||
var placement = MonitoredItemShardPlanner.Plan([], 5_000, 12_001);
|
||||
Assert.Equal(12_001, placement.Count);
|
||||
Assert.Equal(0, placement[0]);
|
||||
Assert.Equal(0, placement[4_999]);
|
||||
Assert.Equal(1, placement[5_000]);
|
||||
Assert.Equal(2, placement[10_000]);
|
||||
Assert.Equal(2, placement[12_000]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sharding_FillsTheFirstShardWithFreeCapacityBeforeCreatingOne()
|
||||
{
|
||||
// Shard 0 full, shard 1 has one free slot: the next two items fill shard 1 then
|
||||
// open shard 2 — the "first shard with free capacity, else a new shard" policy,
|
||||
// which is also what makes an emptied-and-deleted shard's capacity reusable.
|
||||
var placement = MonitoredItemShardPlanner.Plan([5_000, 4_999], 5_000, 2);
|
||||
Assert.Equal([1, 2], placement);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sharding_NonPositiveBudgetDegradesToOneItemPerShard()
|
||||
{
|
||||
Assert.Equal([0, 1, 2], MonitoredItemShardPlanner.Plan([], 0, 3));
|
||||
}
|
||||
|
||||
// ── Apply serialization ──
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyGate_NeverRunsTwoSectionsConcurrently()
|
||||
{
|
||||
var gate = new AsyncSerialGate();
|
||||
var inFlight = 0;
|
||||
var maxObserved = 0;
|
||||
|
||||
var tasks = Enumerable.Range(0, 32).Select(_ => gate.RunAsync(async () =>
|
||||
{
|
||||
var now = Interlocked.Increment(ref inFlight);
|
||||
InterlockedMax(ref maxObserved, now);
|
||||
await Task.Delay(5);
|
||||
Interlocked.Decrement(ref inFlight);
|
||||
})).ToArray();
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
Assert.Equal(1, maxObserved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyGate_ReleasesWhenASectionThrows()
|
||||
{
|
||||
var gate = new AsyncSerialGate();
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
gate.RunAsync(() => throw new InvalidOperationException("apply failed")));
|
||||
|
||||
// The gate must still admit the next caller — a faulted ApplyChanges must not
|
||||
// wedge every later subscribe/unsubscribe on this client.
|
||||
var ran = false;
|
||||
await gate.RunAsync(() =>
|
||||
{
|
||||
ran = true;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
Assert.True(ran);
|
||||
}
|
||||
|
||||
// ── Bounded pipeline (MxGateway supervisory advise) ──
|
||||
|
||||
[Fact]
|
||||
public async Task BulkPipeline_KeepsInFlightCountWithinTheWindow()
|
||||
{
|
||||
const int parallelism = 16;
|
||||
var inFlight = 0;
|
||||
var maxObserved = 0;
|
||||
var items = Enumerable.Range(0, 200).ToList();
|
||||
|
||||
var results = await BulkPipeline.RunAsync(
|
||||
items,
|
||||
parallelism,
|
||||
async (item, _) =>
|
||||
{
|
||||
var now = Interlocked.Increment(ref inFlight);
|
||||
InterlockedMax(ref maxObserved, now);
|
||||
await Task.Delay(2);
|
||||
Interlocked.Decrement(ref inFlight);
|
||||
return item * 2;
|
||||
},
|
||||
(item, _) => -item);
|
||||
|
||||
Assert.Equal(items.Count, results.Length);
|
||||
Assert.Equal(items.Select(i => i * 2), results);
|
||||
Assert.InRange(maxObserved, 1, parallelism);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BulkPipeline_CapturesPerItemFaultsWithoutAbortingTheBatch()
|
||||
{
|
||||
var results = await BulkPipeline.RunAsync(
|
||||
[1, 2, 3],
|
||||
4,
|
||||
(item, _) => item == 2
|
||||
? throw new InvalidOperationException("advise failed")
|
||||
: Task.FromResult(item),
|
||||
(item, _) => -item);
|
||||
|
||||
Assert.Equal([1, -2, 3], results);
|
||||
}
|
||||
|
||||
// ── Alarm-stream union prefix ──
|
||||
|
||||
[Theory]
|
||||
[InlineData(new[] { "Area1.Tank1", "Area1.Tank2" }, "Area1.Tank")]
|
||||
[InlineData(new[] { "Area1.Tank1" }, "Area1.Tank1")]
|
||||
[InlineData(new[] { "Area1.Tank1", "Area2.Tank1" }, "Area")]
|
||||
[InlineData(new[] { "Plant.A", "Zone.B" }, "")]
|
||||
[InlineData(new[] { "Area1.Tank1", "" }, "")]
|
||||
[InlineData(new string[0], "")]
|
||||
public void AlarmPrefix_IsTheLongestCommonPrefix(string[] sources, string expected)
|
||||
{
|
||||
Assert.Equal(expected, AlarmFilterPrefix.LongestCommonPrefix(sources));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmPrefix_CoversOnlySourcesUnderTheLivePrefix()
|
||||
{
|
||||
Assert.True(AlarmFilterPrefix.Covers("Area1.", "Area1.Tank1"));
|
||||
Assert.False(AlarmFilterPrefix.Covers("Area1.", "Area2.Tank1"));
|
||||
// A gateway-wide stream covers everything.
|
||||
Assert.True(AlarmFilterPrefix.Covers("", "Anything.At.All"));
|
||||
}
|
||||
|
||||
// ── Tag-resolution backoff ──
|
||||
|
||||
[Fact]
|
||||
public void Backoff_DoublesPerFailedRoundAndCapsAtTheMaximum()
|
||||
{
|
||||
var floor = TimeSpan.FromSeconds(10);
|
||||
var max = TimeSpan.FromMinutes(5);
|
||||
|
||||
var sequence = new List<TimeSpan>();
|
||||
var current = floor;
|
||||
for (var round = 0; round < 8; round++)
|
||||
{
|
||||
current = DataConnectionActor.NextTagResolutionInterval(current, floor, max);
|
||||
sequence.Add(current);
|
||||
}
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
TimeSpan.FromSeconds(20),
|
||||
TimeSpan.FromSeconds(40),
|
||||
TimeSpan.FromSeconds(80),
|
||||
TimeSpan.FromSeconds(160),
|
||||
TimeSpan.FromSeconds(300),
|
||||
TimeSpan.FromSeconds(300),
|
||||
TimeSpan.FromSeconds(300),
|
||||
TimeSpan.FromSeconds(300),
|
||||
], sequence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Backoff_MisconfiguredCeilingBelowFloorDegradesToAFixedInterval()
|
||||
{
|
||||
var floor = TimeSpan.FromSeconds(10);
|
||||
var next = DataConnectionActor.NextTagResolutionInterval(floor, floor, TimeSpan.FromSeconds(1));
|
||||
Assert.Equal(floor, next);
|
||||
}
|
||||
|
||||
private static void InterlockedMax(ref int target, int value)
|
||||
{
|
||||
int seen;
|
||||
do
|
||||
{
|
||||
seen = Volatile.Read(ref target);
|
||||
if (value <= seen)
|
||||
return;
|
||||
}
|
||||
while (Interlocked.CompareExchange(ref target, value, seen) != seen);
|
||||
}
|
||||
}
|
||||
+43
-1
@@ -11,6 +11,14 @@ public sealed class FakeMxGatewayClient : IMxGatewayClient, IMxGatewayClientFact
|
||||
public MxGatewayConnectionOptions? ConnectedWith;
|
||||
public readonly List<string> Subscribed = new();
|
||||
public readonly List<string> Unsubscribed = new();
|
||||
/// <summary>One entry per SubscribeBulkAsync call, carrying that call's tag list.</summary>
|
||||
public readonly List<IReadOnlyList<string>> BulkSubscribeCalls = new();
|
||||
/// <summary>One entry per UnsubscribeBulkAsync call, carrying that call's id list.</summary>
|
||||
public readonly List<IReadOnlyList<string>> BulkUnsubscribeCalls = new();
|
||||
/// <summary>Tags the fake reports as failed rows from a bulk subscribe.</summary>
|
||||
public readonly HashSet<string> BulkSubscribeFailures = new(StringComparer.Ordinal);
|
||||
/// <summary>Every alarm-stream prefix the adapter opened a stream with (null = gateway-wide).</summary>
|
||||
public readonly List<string?> AlarmStreamPrefixes = new();
|
||||
public readonly TaskCompletionSource EventLoopGate = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public Action<MxValueUpdate>? OnUpdate;
|
||||
public Func<IReadOnlyList<string>, IReadOnlyList<MxReadOutcome>>? ReadHandler;
|
||||
@@ -41,6 +49,34 @@ public sealed class FakeMxGatewayClient : IMxGatewayClient, IMxGatewayClientFact
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<MxSubscribeOutcome>> SubscribeBulkAsync(
|
||||
IReadOnlyList<string> tagPaths, CancellationToken ct = default)
|
||||
{
|
||||
BulkSubscribeCalls.Add(tagPaths.ToList());
|
||||
var outcomes = new List<MxSubscribeOutcome>(tagPaths.Count);
|
||||
foreach (var tag in tagPaths)
|
||||
{
|
||||
if (BulkSubscribeFailures.Contains(tag))
|
||||
{
|
||||
outcomes.Add(new MxSubscribeOutcome(tag, false, null, "not found"));
|
||||
continue;
|
||||
}
|
||||
|
||||
Subscribed.Add(tag);
|
||||
outcomes.Add(new MxSubscribeOutcome(
|
||||
tag, true, (++_nextHandle).ToString(), null));
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<MxSubscribeOutcome>>(outcomes);
|
||||
}
|
||||
|
||||
public Task UnsubscribeBulkAsync(IReadOnlyList<string> subscriptionIds, CancellationToken ct = default)
|
||||
{
|
||||
BulkUnsubscribeCalls.Add(subscriptionIds.ToList());
|
||||
Unsubscribed.AddRange(subscriptionIds);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<MxReadOutcome>> ReadAsync(IReadOnlyList<string> tags, CancellationToken ct = default)
|
||||
=> Task.FromResult(ReadHandler!(tags));
|
||||
|
||||
@@ -62,7 +98,13 @@ public sealed class FakeMxGatewayClient : IMxGatewayClient, IMxGatewayClientFact
|
||||
string? alarmFilterPrefix,
|
||||
Action<ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms.NativeAlarmTransition> onTransition,
|
||||
CancellationToken ct = default)
|
||||
=> Task.CompletedTask; // no alarm feed in the fake
|
||||
{
|
||||
// No alarm feed in the fake — but the prefix each stream is opened with is
|
||||
// recorded so the union-filter (longest-common-prefix) behaviour is testable.
|
||||
lock (AlarmStreamPrefixes)
|
||||
AlarmStreamPrefixes.Add(alarmFilterPrefix);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
|
||||
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// WP2.1b — the MxGateway adapter's batch subscribe seam and the alarm-stream union
|
||||
/// filter. The plain-vs-supervisory advise choice itself lives inside
|
||||
/// <c>RealMxGatewayClient</c> (it needs a live MXAccess session); its bounded-pipeline
|
||||
/// mechanism is pinned separately by <see cref="BatchSeamPrimitiveTests"/>.
|
||||
/// </summary>
|
||||
[Collection("DataConnectionManagerActor")]
|
||||
public class MxGatewayBatchSeamTests
|
||||
{
|
||||
private static MxGatewayDataConnection NewAdapter(FakeMxGatewayClient fake) =>
|
||||
new(fake, NullLogger<MxGatewayDataConnection>.Instance);
|
||||
|
||||
private static Dictionary<string, string> Details() => new()
|
||||
{
|
||||
["Endpoint"] = "http://gw:5000",
|
||||
["ApiKey"] = "key",
|
||||
["ClientName"] = "client-a",
|
||||
["WriteUserId"] = "0",
|
||||
["ReadTimeoutMs"] = "2000",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeBatch_IssuesExactlyOneBulkCallPerBatch()
|
||||
{
|
||||
var fake = new FakeMxGatewayClient();
|
||||
var adapter = NewAdapter(fake);
|
||||
await adapter.ConnectAsync(Details());
|
||||
|
||||
var results = await adapter.SubscribeBatchAsync(
|
||||
["A.x", "A.y", "A.z"], (_, _) => { });
|
||||
|
||||
// ONE bulk RPC for the whole batch — replacing the historical AddItem + Advise
|
||||
// pair PER TAG (6 RPCs for these three tags).
|
||||
Assert.Single(fake.BulkSubscribeCalls);
|
||||
Assert.Equal(3, fake.BulkSubscribeCalls[0].Count);
|
||||
Assert.Equal(3, results.Count);
|
||||
Assert.All(results, r => Assert.True(r.Success));
|
||||
Assert.All(results, r => Assert.NotNull(r.SubscriptionId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeBatch_ReportsPerTagFailuresWithoutFailingTheBatch()
|
||||
{
|
||||
var fake = new FakeMxGatewayClient();
|
||||
fake.BulkSubscribeFailures.Add("A.y");
|
||||
var adapter = NewAdapter(fake);
|
||||
await adapter.ConnectAsync(Details());
|
||||
|
||||
var results = await adapter.SubscribeBatchAsync(["A.x", "A.y"], (_, _) => { });
|
||||
|
||||
Assert.True(results.Single(r => r.TagPath == "A.x").Success);
|
||||
var failed = results.Single(r => r.TagPath == "A.y");
|
||||
Assert.False(failed.Success);
|
||||
Assert.Null(failed.SubscriptionId);
|
||||
Assert.NotNull(failed.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnsubscribeBatch_ReleasesEveryIdInOneBulkCall()
|
||||
{
|
||||
var fake = new FakeMxGatewayClient();
|
||||
var adapter = NewAdapter(fake);
|
||||
await adapter.ConnectAsync(Details());
|
||||
|
||||
var results = await adapter.SubscribeBatchAsync(["A.x", "A.y"], (_, _) => { });
|
||||
await adapter.UnsubscribeBatchAsync(results.Select(r => r.SubscriptionId!).ToList());
|
||||
|
||||
Assert.Single(fake.BulkUnsubscribeCalls);
|
||||
Assert.Equal(2, fake.BulkUnsubscribeCalls[0].Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AlarmStream_OpensOnTheLongestCommonPrefix_AndOnlyRestartsForAnEscapingSource()
|
||||
{
|
||||
var fake = new FakeMxGatewayClient();
|
||||
var adapter = NewAdapter(fake);
|
||||
await adapter.ConnectAsync(Details());
|
||||
|
||||
void NoTransition(NativeAlarmTransition _) { }
|
||||
|
||||
// First source → the stream opens scoped to it.
|
||||
await adapter.SubscribeAlarmsAsync("Area1.Tank1", null, NoTransition);
|
||||
await WaitForPrefixCountAsync(fake, 1);
|
||||
Assert.Equal("Area1.Tank1", fake.AlarmStreamPrefixes[0]);
|
||||
|
||||
// A sibling under the same prefix widens the union → one restart on "Area1.Tank".
|
||||
await adapter.SubscribeAlarmsAsync("Area1.Tank2", null, NoTransition);
|
||||
await WaitForPrefixCountAsync(fake, 2);
|
||||
Assert.Equal("Area1.Tank", fake.AlarmStreamPrefixes[1]);
|
||||
|
||||
// A source ALREADY covered by the live prefix must NOT restart the stream.
|
||||
await adapter.SubscribeAlarmsAsync("Area1.Tank3.Sub", null, NoTransition);
|
||||
await Task.Delay(100);
|
||||
Assert.Equal(2, fake.AlarmStreamPrefixes.Count);
|
||||
|
||||
// A source outside the prefix widens it again — here down to gateway-wide.
|
||||
await adapter.SubscribeAlarmsAsync("Zone9.Pump", null, NoTransition);
|
||||
await WaitForPrefixCountAsync(fake, 3);
|
||||
Assert.Null(fake.AlarmStreamPrefixes[2]); // "" → gateway-wide
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AlarmStream_UnsubscribeNeverRestartsTheStream()
|
||||
{
|
||||
var fake = new FakeMxGatewayClient();
|
||||
var adapter = NewAdapter(fake);
|
||||
await adapter.ConnectAsync(Details());
|
||||
|
||||
var first = await adapter.SubscribeAlarmsAsync("Area1.Tank1", null, _ => { });
|
||||
// Wait for the first stream to actually open before widening: the open runs on a
|
||||
// Task.Run whose token the restart cancels, so a restart racing an unstarted task
|
||||
// would leave the first prefix unrecorded.
|
||||
await WaitForPrefixCountAsync(fake, 1);
|
||||
await adapter.SubscribeAlarmsAsync("Area1.Tank2", null, _ => { });
|
||||
await WaitForPrefixCountAsync(fake, 2);
|
||||
|
||||
// Dropping a source leaves the prefix too BROAD at worst — bandwidth, not
|
||||
// correctness — so no restart is issued.
|
||||
await adapter.UnsubscribeAlarmsAsync(first);
|
||||
await Task.Delay(100);
|
||||
Assert.Equal(2, fake.AlarmStreamPrefixes.Count);
|
||||
}
|
||||
|
||||
private static async Task WaitForPrefixCountAsync(FakeMxGatewayClient fake, int expected)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
lock (fake.AlarmStreamPrefixes)
|
||||
{
|
||||
if (fake.AlarmStreamPrefixes.Count >= expected)
|
||||
return;
|
||||
}
|
||||
await Task.Delay(20);
|
||||
}
|
||||
|
||||
Assert.Fail($"alarm stream was opened {fake.AlarmStreamPrefixes.Count} time(s), expected {expected}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user