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:
Joseph Doherty
2026-10-22 06:41:47 -04:00
parent 1b3169273d
commit 6dedf5158f
8 changed files with 498 additions and 14 deletions
@@ -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"/>
@@ -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>