77ca10aa37
The writer concurrency fake parks on its release signal (the 300 ms delay could release the gate early on a starved pool), the supervisor test's cross-thread hold flag is Volatile, and the EffectiveTimeout doc now states the 1 s floor the code and tests implement. Refs #735 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
171 lines
8.0 KiB
C#
171 lines
8.0 KiB
C#
using System.Collections.Concurrent;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
|
|
|
|
/// <summary>
|
|
/// Review 2026-10-21 G-8: a batch of writes ran strictly serially, three round-trips per
|
|
/// first-touch tag. Distinct tags now run as concurrent lanes bounded at
|
|
/// <see cref="GatewayGalaxyDataWriter.MaxConcurrentWriteLanes"/>; same-tag writes keep their
|
|
/// order inside one lane; results stay at their request's index.
|
|
/// </summary>
|
|
public sealed class GatewayGalaxyDataWriterConcurrencyTests
|
|
{
|
|
private const int WaitMs = 10_000;
|
|
|
|
private static GatewayGalaxyDataWriter NewWriter()
|
|
=> new(new GalaxyMxSession(new Config.GalaxyMxAccessOptions(ClientName: "OtOpcUa-Test")), writeUserId: 0);
|
|
|
|
/// <summary>
|
|
/// Fake ops whose AddItem parks every caller until <see cref="ReleaseAt"/> callers are in
|
|
/// flight at once (or a safety delay elapses), recording the peak concurrency.
|
|
/// </summary>
|
|
private sealed class GatingOps : IMxWriteOps
|
|
{
|
|
private int _inFlight;
|
|
private int _peak;
|
|
private int _nextHandle;
|
|
private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
private readonly ConcurrentDictionary<string, int> _handles = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
public int ReleaseAt { get; init; } = int.MaxValue;
|
|
public int Peak => Volatile.Read(ref _peak);
|
|
public ConcurrentQueue<(int Handle, MxValue Value)> RawWrites { get; } = new();
|
|
public ConcurrentQueue<string> AddItems { get; } = new();
|
|
public Func<int, MxCommandReply>? WriteResponder { get; init; }
|
|
|
|
/// <summary>Deterministic handle per tag when set; otherwise handles are assigned in AddItem completion order.</summary>
|
|
public Func<string, int>? HandleFor { get; init; }
|
|
|
|
public string SessionId => "sess";
|
|
public int ServerHandle => 1;
|
|
|
|
public async Task<int> AddItemAsync(string fullRef, CancellationToken ct)
|
|
{
|
|
AddItems.Enqueue(fullRef);
|
|
var now = Interlocked.Increment(ref _inFlight);
|
|
int seen;
|
|
do { seen = Volatile.Read(ref _peak); } while (now > seen && Interlocked.CompareExchange(ref _peak, now, seen) != seen);
|
|
if (now >= ReleaseAt) _release.TrySetResult();
|
|
// Park on the signal; the delay is only a safety net against a wedged test (a starved
|
|
// pool must not release the gate early and turn the peak assertion flaky).
|
|
await Task.WhenAny(_release.Task, Task.Delay(WaitMs, ct));
|
|
Interlocked.Decrement(ref _inFlight);
|
|
return _handles.GetOrAdd(fullRef, r => HandleFor?.Invoke(r) ?? Interlocked.Increment(ref _nextHandle));
|
|
}
|
|
|
|
public Task<MxCommandReply> InvokeAsync(MxCommandRequest request, CancellationToken ct)
|
|
=> Task.FromResult(new MxCommandReply { ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok } });
|
|
|
|
public Task<MxCommandReply> WriteRawAsync(int itemHandle, MxValue value, int userId, CancellationToken ct)
|
|
{
|
|
RawWrites.Enqueue((itemHandle, value));
|
|
return Task.FromResult(WriteResponder?.Invoke(itemHandle) ?? GoodStatusRow());
|
|
}
|
|
|
|
// A reply carrying a Good MX status ROW (not an empty statuses array): the writer's static
|
|
// meters are process-wide, and an empty-statuses Good would increment galaxy.writes.unconfirmed
|
|
// under the meter tests running in parallel in another class.
|
|
private static MxCommandReply GoodStatusRow()
|
|
{
|
|
var reply = new MxCommandReply { ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok } };
|
|
reply.Statuses.Add(new MxStatusProxy { Success = 1, Category = MxStatusCategory.Ok });
|
|
return reply;
|
|
}
|
|
}
|
|
|
|
private static SecurityClassification Free(string _) => SecurityClassification.FreeAccess;
|
|
|
|
/// <summary>Sixteen distinct tags: lanes overlap (not serial) and never exceed the cap of eight.</summary>
|
|
[Fact]
|
|
public async Task Distinct_tags_write_concurrently_bounded_at_the_lane_cap()
|
|
{
|
|
var ops = new GatingOps { ReleaseAt = GatewayGalaxyDataWriter.MaxConcurrentWriteLanes };
|
|
var writes = Enumerable.Range(0, 16).Select(i => new WriteRequest($"Tag{i}.PV", (double)i)).ToArray();
|
|
|
|
var results = await NewWriter().WriteBatchForTestAsync(ops, writes, Free, CancellationToken.None)
|
|
.WaitAsync(TimeSpan.FromMilliseconds(WaitMs));
|
|
|
|
ops.Peak.ShouldBe(GatewayGalaxyDataWriter.MaxConcurrentWriteLanes,
|
|
"first-touch lanes must overlap (serial = 1) and stay bounded (unbounded > 8)");
|
|
results.Count.ShouldBe(16);
|
|
results.ShouldAllBe(r => r.StatusCode == StatusCodeMap.Good);
|
|
ops.AddItems.Count.ShouldBe(16, "one AddItem per distinct tag");
|
|
ops.RawWrites.Count.ShouldBe(16);
|
|
}
|
|
|
|
/// <summary>Writes to the same tag stay in input order inside their lane and share one AddItem.</summary>
|
|
[Fact]
|
|
public async Task Same_tag_writes_stay_ordered_and_share_one_handle()
|
|
{
|
|
var ops = new GatingOps { ReleaseAt = 2 };
|
|
var writes = new[]
|
|
{
|
|
new WriteRequest("Tank01.SP", 1.0),
|
|
new WriteRequest("Tank02.SP", 10.0),
|
|
new WriteRequest("Tank01.SP", 2.0),
|
|
new WriteRequest("Tank01.SP", 3.0),
|
|
};
|
|
|
|
var results = await NewWriter().WriteBatchForTestAsync(ops, writes, Free, CancellationToken.None)
|
|
.WaitAsync(TimeSpan.FromMilliseconds(WaitMs));
|
|
|
|
results.Count.ShouldBe(4);
|
|
results.ShouldAllBe(r => r.StatusCode == StatusCodeMap.Good);
|
|
ops.AddItems.Count(a => a == "Tank01.SP").ShouldBe(1, "a lane AddItems its tag once");
|
|
var tank01 = ops.RawWrites.Where(w => w.Handle == ops.RawWrites.First(x => x.Value.DoubleValue == 1.0).Handle)
|
|
.Select(w => w.Value.DoubleValue).ToArray();
|
|
tank01.ShouldBe([1.0, 2.0, 3.0], "same-tag writes must keep their authored order");
|
|
}
|
|
|
|
/// <summary>A per-tag failure lands at that request's index while the other lanes stay Good.</summary>
|
|
[Fact]
|
|
public async Task Results_land_at_their_request_index()
|
|
{
|
|
var ops = new GatingOps
|
|
{
|
|
ReleaseAt = 3,
|
|
HandleFor = tag => tag[0] - 'A' + 1, // A.PV → 1, B.PV → 2, C.PV → 3
|
|
WriteResponder = handle => new MxCommandReply
|
|
{
|
|
ProtocolStatus = new ProtocolStatus
|
|
{
|
|
Code = handle == 2 ? ProtocolStatusCode.MxaccessFailure : ProtocolStatusCode.Ok,
|
|
},
|
|
},
|
|
};
|
|
var writes = new[]
|
|
{
|
|
new WriteRequest("A.PV", 1.0),
|
|
new WriteRequest("B.PV", 2.0), // refused by the gateway
|
|
new WriteRequest("C.PV", 3.0),
|
|
};
|
|
|
|
var results = await NewWriter().WriteBatchForTestAsync(ops, writes, Free, CancellationToken.None)
|
|
.WaitAsync(TimeSpan.FromMilliseconds(WaitMs));
|
|
|
|
results.Select(r => r.StatusCode).ShouldBe(
|
|
[StatusCodeMap.Good, StatusCodeMap.BadCommunicationError, StatusCodeMap.Good],
|
|
"each result must sit at its request's index regardless of lane completion order");
|
|
}
|
|
|
|
/// <summary>A single-tag batch takes the direct path and still produces one result per request.</summary>
|
|
[Fact]
|
|
public async Task Single_tag_batch_runs_serially_in_order()
|
|
{
|
|
var ops = new GatingOps { ReleaseAt = 1 };
|
|
var writes = new[] { new WriteRequest("X.PV", 1.0), new WriteRequest("X.PV", 2.0) };
|
|
|
|
var results = await NewWriter().WriteBatchForTestAsync(ops, writes, Free, CancellationToken.None)
|
|
.WaitAsync(TimeSpan.FromMilliseconds(WaitMs));
|
|
|
|
results.Count.ShouldBe(2);
|
|
ops.Peak.ShouldBe(1);
|
|
ops.RawWrites.Select(w => w.Value.DoubleValue).ShouldBe([1.0, 2.0]);
|
|
}
|
|
}
|