feat(galaxy): wire DiscoverPageSize and ProbeTimeoutSeconds; concurrent first-touch write lanes
Review 2026-10-21 G-5, G-6, G-8 ("wire them all").
- G-5: GatewayGalaxyHierarchySource now pages the DiscoverHierarchy RPC
itself (DiscoverHierarchyRawAsync) at Repository.DiscoverPageSize. The
mxaccessgw client's typed DiscoverHierarchyAsync overloads hard-code a
5000-row page and DiscoverHierarchyOptions carries no page size, so the
authored knob reached nothing; the raw request takes page_size on the
wire, so it is honoured here (non-positive → 5000). The loop keeps the
client's repeated-page-token guard.
- G-6: GalaxyDriverProbe reads ProbeTimeoutSeconds from the config and
runs under min(caller budget, configured cap) — the knob can shorten a
probe, never extend it past the host's budget. The TCP preflight now
shares that deadline instead of hanging on the caller's token alone.
- G-8: GatewayGalaxyDataWriter runs a batch as one lane per distinct tag,
concurrently and bounded at 8 (MaxConcurrentWriteLanes); same-tag
writes keep their authored order inside one lane and results land at
their request's index. Acks stay serial by design.
- GalaxyDriverForm: help text now says what the two knobs actually do.
Refs #735
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -5,22 +5,83 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IGalaxyHierarchySource"/> wrapping the gateway's
|
||||
/// <see cref="GalaxyRepositoryClient"/>. Pages internally via the client's overload.
|
||||
/// <see cref="GalaxyRepositoryClient"/>. Pages the <c>DiscoverHierarchy</c> RPC itself,
|
||||
/// at the configured <c>Repository.DiscoverPageSize</c>: the client's typed
|
||||
/// <c>DiscoverHierarchyAsync</c> overloads hard-code a 5000-row page
|
||||
/// (<c>DiscoverHierarchyOptions</c> carries no page size), so the knob the AdminUI
|
||||
/// authors and the factory parses used to reach nothing. The raw RPC takes the page
|
||||
/// size on the wire (<c>DiscoverHierarchyRequest.page_size</c>), so it is honoured here.
|
||||
/// </summary>
|
||||
public sealed class GatewayGalaxyHierarchySource : IGalaxyHierarchySource
|
||||
{
|
||||
/// <summary>The page size used when the configured one is not positive — the client library's own default.</summary>
|
||||
public const int DefaultPageSize = 5000;
|
||||
|
||||
private readonly GalaxyRepositoryClient _client;
|
||||
private readonly int _pageSize;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="GatewayGalaxyHierarchySource"/> class.
|
||||
/// </summary>
|
||||
/// <param name="client">The gateway's Galaxy repository client for discovering the object hierarchy.</param>
|
||||
public GatewayGalaxyHierarchySource(GalaxyRepositoryClient client)
|
||||
/// <param name="pageSize">
|
||||
/// Objects per <c>DiscoverHierarchy</c> page (<c>Repository.DiscoverPageSize</c>). A value that is
|
||||
/// not positive falls back to <see cref="DefaultPageSize"/>.
|
||||
/// </param>
|
||||
public GatewayGalaxyHierarchySource(GalaxyRepositoryClient client, int pageSize = DefaultPageSize)
|
||||
{
|
||||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
_pageSize = EffectivePageSize(pageSize);
|
||||
}
|
||||
|
||||
/// <summary>The page size this source sends on every <c>DiscoverHierarchy</c> request.</summary>
|
||||
public int PageSize => _pageSize;
|
||||
|
||||
/// <summary>Clamp a configured page size to something the gateway accepts: non-positive → <see cref="DefaultPageSize"/>.</summary>
|
||||
/// <param name="configured">The configured value.</param>
|
||||
/// <returns>The page size to send.</returns>
|
||||
public static int EffectivePageSize(int configured) => configured > 0 ? configured : DefaultPageSize;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<GalaxyObject>> GetHierarchyAsync(CancellationToken cancellationToken)
|
||||
=> _client.DiscoverHierarchyAsync(cancellationToken);
|
||||
=> PageAsync(_client.DiscoverHierarchyRawAsync, _pageSize, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Walk every <c>DiscoverHierarchy</c> page at <paramref name="pageSize"/> and concatenate the
|
||||
/// objects. Mirrors the client library's own loop (including its repeated-page-token guard,
|
||||
/// which turns a misbehaving gateway into an error rather than an endless walk), with the
|
||||
/// RPC behind a delegate so the loop is unit-testable without a channel.
|
||||
/// </summary>
|
||||
/// <param name="discover">The raw <c>DiscoverHierarchy</c> RPC.</param>
|
||||
/// <param name="pageSize">Objects per page; sent on every request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the walk.</param>
|
||||
/// <returns>Every object across all pages, in gateway order.</returns>
|
||||
internal static async Task<IReadOnlyList<GalaxyObject>> PageAsync(
|
||||
Func<DiscoverHierarchyRequest, CancellationToken, Task<DiscoverHierarchyReply>> discover,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(discover);
|
||||
var effective = EffectivePageSize(pageSize);
|
||||
|
||||
var objects = new List<GalaxyObject>();
|
||||
var seenPageTokens = new HashSet<string>(StringComparer.Ordinal);
|
||||
var pageToken = string.Empty;
|
||||
do
|
||||
{
|
||||
var request = new DiscoverHierarchyRequest { PageSize = effective, PageToken = pageToken };
|
||||
var reply = await discover(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
objects.AddRange(reply.Objects);
|
||||
pageToken = reply.NextPageToken ?? string.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(pageToken) && !seenPageTokens.Add(pageToken))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Galaxy DiscoverHierarchy returned a repeated page token '{pageToken}' — refusing to loop.");
|
||||
}
|
||||
}
|
||||
while (!string.IsNullOrWhiteSpace(pageToken));
|
||||
|
||||
return objects;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1821,7 +1821,8 @@ public sealed class GalaxyDriver
|
||||
_ownedRepositoryClient ??= GalaxyRepositoryClient.Create(
|
||||
await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false));
|
||||
return new TracedGalaxyHierarchySource(
|
||||
new GatewayGalaxyHierarchySource(_ownedRepositoryClient), _options.MxAccess.ClientName);
|
||||
new GatewayGalaxyHierarchySource(_ownedRepositoryClient, _options.Repository.DiscoverPageSize),
|
||||
_options.MxAccess.ClientName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -54,12 +54,18 @@ public sealed class GalaxyDriverProbe : IDriverProbe
|
||||
if (string.IsNullOrWhiteSpace(host) || port <= 0)
|
||||
return new(false, "Config has no host/port to probe.", null);
|
||||
|
||||
// The config's own ProbeTimeoutSeconds caps the probe deadline. It can only tighten the
|
||||
// caller's budget, never extend it — the caller's token fires at `timeout` regardless.
|
||||
timeout = EffectiveTimeout(timeout, opts.ProbeTimeoutSeconds);
|
||||
using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
probeCts.CancelAfter(timeout);
|
||||
|
||||
// --- TCP preflight: fast-fail for closed ports / unreachable hosts ---
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await socket.ConnectAsync(host, port, ct);
|
||||
await socket.ConnectAsync(host, port, probeCts.Token);
|
||||
}
|
||||
catch (SocketException ex)
|
||||
{
|
||||
@@ -86,10 +92,7 @@ public sealed class GalaxyDriverProbe : IDriverProbe
|
||||
{
|
||||
client = GalaxyRepositoryClient.Create(BuildProbeClientOptions(opts.Gateway, timeout));
|
||||
|
||||
using var deadlineCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
deadlineCts.CancelAfter(timeout);
|
||||
|
||||
await client.TestConnectionAsync(deadlineCts.Token).ConfigureAwait(false);
|
||||
await client.TestConnectionAsync(probeCts.Token).ConfigureAwait(false);
|
||||
sw.Stop();
|
||||
|
||||
// No RpcException ⇒ the gateway answered the gRPC call successfully.
|
||||
@@ -157,6 +160,24 @@ public sealed class GalaxyDriverProbe : IDriverProbe
|
||||
_ => (false, $"Reachable at {host}:{port} but gateway gRPC handshake failed: {code}"),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The deadline the probe actually runs under: the caller's budget, tightened by the
|
||||
/// config's <see cref="GalaxyDriverOptions.ProbeTimeoutSeconds"/> when that is positive and
|
||||
/// shorter. The knob was authored by the AdminUI and parsed by the factory but consumed by
|
||||
/// nothing — the probe always ran on the caller's budget alone. A non-positive caller budget
|
||||
/// is treated as the config value (or 1 s when that is unset too), as before.
|
||||
/// </summary>
|
||||
/// <param name="callerTimeout">The budget the host handed the probe.</param>
|
||||
/// <param name="configuredSeconds">The config's <c>ProbeTimeoutSeconds</c>; ignored when not positive.</param>
|
||||
/// <returns>The effective probe deadline.</returns>
|
||||
internal static TimeSpan EffectiveTimeout(TimeSpan callerTimeout, int configuredSeconds)
|
||||
{
|
||||
var caller = callerTimeout > TimeSpan.Zero ? callerTimeout : TimeSpan.FromSeconds(1);
|
||||
if (configuredSeconds <= 0) return caller;
|
||||
var configured = TimeSpan.FromSeconds(configuredSeconds);
|
||||
return configured < caller ? configured : caller;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the gRPC client options for the probe ping from the gateway config WITHOUT
|
||||
/// resolving the API-key secret ref (the host owns secret resolution; the probe sends the
|
||||
|
||||
@@ -120,15 +120,87 @@ public sealed class GatewayGalaxyDataWriter : IGalaxyDataWriter
|
||||
// Bind the per-batch MX write operations behind the internal IMxWriteOps seam (see below) so the
|
||||
// whole write pipeline is unit-testable without the sealed SDK session (archreview 06/S-1).
|
||||
var ops = new SessionMxWriteOps(session, serverHandle);
|
||||
return await WriteBatchAsync(ops, writes, securityResolver, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upper bound on write lanes in flight at once. A first-touch write is three round-trips
|
||||
/// (AddItem → AdviseSupervisory → Write) and the batch used to run them strictly serially;
|
||||
/// distinct tags are independent, so their lanes now overlap, bounded so a large batch cannot
|
||||
/// flood the gateway's STA pump. Acks stay serial by design (see <c>GalaxyDriver.AcknowledgeAsync</c>).
|
||||
/// </summary>
|
||||
internal const int MaxConcurrentWriteLanes = 8;
|
||||
|
||||
/// <summary>Test seam: drive the real batch pipeline against a fake <see cref="IMxWriteOps"/>.</summary>
|
||||
/// <param name="ops">The (fake) MX write operations.</param>
|
||||
/// <param name="writes">The batch.</param>
|
||||
/// <param name="securityResolver">Per-tag classification resolver.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>One result per request, in input order.</returns>
|
||||
internal Task<IReadOnlyList<WriteResult>> WriteBatchForTestAsync(
|
||||
IMxWriteOps ops, IReadOnlyList<WriteRequest> writes,
|
||||
Func<string, SecurityClassification> securityResolver, CancellationToken ct)
|
||||
=> WriteBatchAsync(ops, writes, securityResolver, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Run a batch as one lane per distinct tag (OrdinalIgnoreCase): writes to the SAME tag
|
||||
/// stay in input order inside their lane, lanes run concurrently up to
|
||||
/// <see cref="MaxConcurrentWriteLanes"/>, and results land at their request's index.
|
||||
/// A single-tag batch takes the direct path with no gate.
|
||||
/// </summary>
|
||||
private async Task<IReadOnlyList<WriteResult>> WriteBatchAsync(
|
||||
IMxWriteOps ops, IReadOnlyList<WriteRequest> writes,
|
||||
Func<string, SecurityClassification> securityResolver, CancellationToken ct)
|
||||
{
|
||||
var results = new WriteResult[writes.Count];
|
||||
if (writes.Count == 0) return results;
|
||||
|
||||
// Group request indices by tag, first-seen order preserved so the lane start order is
|
||||
// deterministic and a same-tag sequence keeps its authored order.
|
||||
var lanes = new Dictionary<string, List<int>>(StringComparer.OrdinalIgnoreCase);
|
||||
var laneOrder = new List<List<int>>();
|
||||
for (var i = 0; i < writes.Count; i++)
|
||||
{
|
||||
results[i] = await WriteOneAsync(ops, writes[i],
|
||||
securityResolver(writes[i].FullReference), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (!lanes.TryGetValue(writes[i].FullReference, out var lane))
|
||||
{
|
||||
lane = [];
|
||||
lanes[writes[i].FullReference] = lane;
|
||||
laneOrder.Add(lane);
|
||||
}
|
||||
lane.Add(i);
|
||||
}
|
||||
|
||||
if (laneOrder.Count == 1)
|
||||
{
|
||||
await RunLaneAsync(laneOrder[0]).ConfigureAwait(false);
|
||||
return results;
|
||||
}
|
||||
|
||||
using var gate = new SemaphoreSlim(MaxConcurrentWriteLanes, MaxConcurrentWriteLanes);
|
||||
var tasks = new Task[laneOrder.Count];
|
||||
for (var l = 0; l < laneOrder.Count; l++)
|
||||
{
|
||||
var lane = laneOrder[l];
|
||||
tasks[l] = RunGatedAsync(lane);
|
||||
}
|
||||
await Task.WhenAll(tasks).ConfigureAwait(false);
|
||||
return results;
|
||||
|
||||
async Task RunGatedAsync(List<int> lane)
|
||||
{
|
||||
await gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try { await RunLaneAsync(lane).ConfigureAwait(false); }
|
||||
finally { gate.Release(); }
|
||||
}
|
||||
|
||||
async Task RunLaneAsync(List<int> lane)
|
||||
{
|
||||
foreach (var i in lane)
|
||||
{
|
||||
results[i] = await WriteOneAsync(ops, writes[i],
|
||||
securityResolver(writes[i].FullReference), ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Test seam: drive the real single-write pipeline against a fake <see cref="IMxWriteOps"/>
|
||||
|
||||
+2
-2
@@ -92,7 +92,7 @@
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Discover page size</label>
|
||||
<InputNumber @bind-Value="_form.Galaxy.RepositoryDiscoverPageSize" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 5000 objects per page.</div>
|
||||
<div class="form-text">Objects per DiscoverHierarchy page the driver asks the gateway for. Default 5000.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
@@ -138,7 +138,7 @@
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Admin UI probe timeout (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.Galaxy.ProbeTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Max 60. Used by Test Connect. Default 30.</div>
|
||||
<div class="form-text">Caps the Test Connect probe deadline (the probe's own budget still applies, so this only shortens it). Max 60. Default 30.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Browse;
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 G-5: <c>Repository.DiscoverPageSize</c> was authored and parsed but reached
|
||||
/// nothing — the client's typed <c>DiscoverHierarchyAsync</c> hard-codes 5000. The source now pages
|
||||
/// the raw RPC itself at the configured size.
|
||||
/// </summary>
|
||||
public sealed class GatewayGalaxyHierarchySourcePagingTests
|
||||
{
|
||||
private static GalaxyObject Obj(int id) => new() { GobjectId = id, TagName = $"Obj{id}" };
|
||||
|
||||
/// <summary>Every request carries the configured page size and the page tokens chain until the last page.</summary>
|
||||
[Fact]
|
||||
public async Task Pages_at_the_configured_size_and_chains_tokens()
|
||||
{
|
||||
var requests = new List<DiscoverHierarchyRequest>();
|
||||
Task<DiscoverHierarchyReply> Discover(DiscoverHierarchyRequest req, CancellationToken ct)
|
||||
{
|
||||
requests.Add(req);
|
||||
var reply = new DiscoverHierarchyReply();
|
||||
switch (req.PageToken)
|
||||
{
|
||||
case "":
|
||||
reply.Objects.Add([Obj(1), Obj(2)]);
|
||||
reply.NextPageToken = "p2";
|
||||
break;
|
||||
case "p2":
|
||||
reply.Objects.Add([Obj(3), Obj(4)]);
|
||||
reply.NextPageToken = "p3";
|
||||
break;
|
||||
default:
|
||||
reply.Objects.Add(Obj(5));
|
||||
reply.NextPageToken = "";
|
||||
break;
|
||||
}
|
||||
return Task.FromResult(reply);
|
||||
}
|
||||
|
||||
var objects = await GatewayGalaxyHierarchySource.PageAsync(Discover, pageSize: 2, CancellationToken.None);
|
||||
|
||||
objects.Select(o => o.GobjectId).ShouldBe([1, 2, 3, 4, 5]);
|
||||
requests.Count.ShouldBe(3);
|
||||
requests.ShouldAllBe(r => r.PageSize == 2);
|
||||
requests.Select(r => r.PageToken).ShouldBe(["", "p2", "p3"]);
|
||||
}
|
||||
|
||||
/// <summary>A different configured size is what goes on the wire (positive control for the knob).</summary>
|
||||
[Fact]
|
||||
public async Task A_different_page_size_is_sent_on_the_wire()
|
||||
{
|
||||
int? sent = null;
|
||||
Task<DiscoverHierarchyReply> Discover(DiscoverHierarchyRequest req, CancellationToken ct)
|
||||
{
|
||||
sent = req.PageSize;
|
||||
return Task.FromResult(new DiscoverHierarchyReply());
|
||||
}
|
||||
|
||||
await GatewayGalaxyHierarchySource.PageAsync(Discover, pageSize: 250, CancellationToken.None);
|
||||
sent.ShouldBe(250);
|
||||
}
|
||||
|
||||
/// <summary>A non-positive configured size falls back to the client library's 5000 default.</summary>
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-7)]
|
||||
public void Non_positive_page_size_falls_back_to_the_default(int configured)
|
||||
=> GatewayGalaxyHierarchySource.EffectivePageSize(configured).ShouldBe(GatewayGalaxyHierarchySource.DefaultPageSize);
|
||||
|
||||
/// <summary>A repeated page token is refused rather than walked forever.</summary>
|
||||
[Fact]
|
||||
public async Task A_repeated_page_token_is_refused()
|
||||
{
|
||||
var calls = 0;
|
||||
Task<DiscoverHierarchyReply> Discover(DiscoverHierarchyRequest req, CancellationToken ct)
|
||||
{
|
||||
calls++;
|
||||
var reply = new DiscoverHierarchyReply { NextPageToken = "same" };
|
||||
reply.Objects.Add(Obj(calls));
|
||||
return Task.FromResult(reply);
|
||||
}
|
||||
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(
|
||||
() => GatewayGalaxyHierarchySource.PageAsync(Discover, pageSize: 10, CancellationToken.None));
|
||||
ex.Message.ShouldContain("repeated page token");
|
||||
calls.ShouldBe(2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 G-6: <c>ProbeTimeoutSeconds</c> was authored and compared for equivalence but
|
||||
/// never consumed — the probe ran on the caller's budget alone. It now caps the probe deadline
|
||||
/// (it can shorten the caller's budget, never extend it).
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class GalaxyDriverProbeTimeoutTests
|
||||
{
|
||||
/// <summary>The effective deadline is the shorter of the caller's budget and the configured cap.</summary>
|
||||
[Theory]
|
||||
[InlineData(10, 30, 10)] // caller shorter → caller wins
|
||||
[InlineData(10, 2, 2)] // config shorter → config caps
|
||||
[InlineData(10, 0, 10)] // unset config → caller
|
||||
[InlineData(10, -1, 10)] // nonsense config → caller
|
||||
[InlineData(0, 5, 1)] // no caller budget → 1 s floor beats a longer config
|
||||
[InlineData(0, 0, 1)] // nothing → 1 s floor
|
||||
public void EffectiveTimeout_is_the_shorter_of_caller_and_config(int callerSeconds, int configSeconds, int expectedSeconds)
|
||||
=> GalaxyDriverProbe.EffectiveTimeout(TimeSpan.FromSeconds(callerSeconds), configSeconds)
|
||||
.ShouldBe(TimeSpan.FromSeconds(expectedSeconds));
|
||||
|
||||
/// <summary>
|
||||
/// Against a port that accepts TCP but never speaks gRPC, a 1 s configured cap returns long
|
||||
/// before the caller's 20 s budget. The gRPC handshake itself does the waiting here, so this
|
||||
/// exercises the real deadline path, not the pure helper.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Configured_cap_shortens_the_probe_against_a_silent_listener()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
try
|
||||
{
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
var config = $$"""
|
||||
{
|
||||
"Gateway": { "Endpoint": "http://127.0.0.1:{{port}}", "ApiKeySecretRef": "dev:k", "UseTls": false },
|
||||
"MxAccess": { "ClientName": "probe-timeout" },
|
||||
"Repository": {},
|
||||
"Reconnect": {},
|
||||
"ProbeTimeoutSeconds": 1
|
||||
}
|
||||
""";
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var result = await new GalaxyDriverProbe().ProbeAsync(config, TimeSpan.FromSeconds(20), TestContext.Current.CancellationToken);
|
||||
sw.Stop();
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10), $"the 1 s configured cap must bound the probe (message: {result.Message})");
|
||||
result.Message.ShouldNotBeNull();
|
||||
(result.Message!.Contains("timed out", StringComparison.OrdinalIgnoreCase)
|
||||
|| result.Message.Contains("DeadlineExceeded", StringComparison.OrdinalIgnoreCase)
|
||||
|| result.Message.Contains("handshake failed", StringComparison.OrdinalIgnoreCase))
|
||||
.ShouldBeTrue($"unexpected message: '{result.Message}'");
|
||||
}
|
||||
finally
|
||||
{
|
||||
listener.Stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
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();
|
||||
await Task.WhenAny(_release.Task, Task.Delay(300, 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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user