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; /// /// 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 /// ; same-tag writes keep their /// order inside one lane; results stay at their request's index. /// 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); /// /// Fake ops whose AddItem parks every caller until callers are in /// flight at once (or a safety delay elapses), recording the peak concurrency. /// private sealed class GatingOps : IMxWriteOps { private int _inFlight; private int _peak; private int _nextHandle; private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly ConcurrentDictionary _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 AddItems { get; } = new(); public Func? WriteResponder { get; init; } /// Deterministic handle per tag when set; otherwise handles are assigned in AddItem completion order. public Func? HandleFor { get; init; } public string SessionId => "sess"; public int ServerHandle => 1; public async Task 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 InvokeAsync(MxCommandRequest request, CancellationToken ct) => Task.FromResult(new MxCommandReply { ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok } }); public Task 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; /// Sixteen distinct tags: lanes overlap (not serial) and never exceed the cap of eight. [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); } /// Writes to the same tag stay in input order inside their lane and share one AddItem. [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"); } /// A per-tag failure lands at that request's index while the other lanes stay Good. [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"); } /// A single-tag batch takes the direct path and still produces one result per request. [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]); } }