fix(galaxy): seed platforms with one template filter per request (#864)

The gateway's shared GalaxyHierarchyProjector ANDs template_chain_contains
entries, so the single request carrying both $WinPlatform and $AppEngine
matched no object and the probe watcher always seeded 0 platforms.
GatewayGalaxyPlatformSource now runs one paged sweep per template and
unions/dedupes by tag name. The tests use a fake gateway that applies the
projector's AND semantics (they fail on the old single request).

Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
This commit is contained in:
Joseph Doherty
2026-11-11 12:24:03 -05:00
parent e857135e8b
commit c4bc1ef309
2 changed files with 125 additions and 46 deletions
@@ -5,11 +5,19 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
/// <summary>
/// Default <see cref="IGalaxyPlatformSource"/>: a platform-only <c>DiscoverHierarchy</c> sweep
/// over the gateway's <see cref="GalaxyRepositoryClient"/> — the request filters on
/// <c>template_chain_contains</c> (<c>$WinPlatform</c>, <c>$AppEngine</c>) and asks for no
/// attributes, so it is a few rows per galaxy rather than the full object + attribute dump the
/// address-space discovery pulls. Paged at the configured <c>Repository.DiscoverPageSize</c>.
/// over the gateway's <see cref="GalaxyRepositoryClient"/> — one sweep per platform template
/// (<c>$WinPlatform</c>, then <c>$AppEngine</c>), each filtered on that single
/// <c>template_chain_contains</c> token with no attributes, so it is a few rows per galaxy rather
/// than the full object + attribute dump the address-space discovery pulls. Paged at the
/// configured <c>Repository.DiscoverPageSize</c>.
/// </summary>
/// <remarks>
/// ⚠️ <b>One template per request (#864).</b> The gateway's shared
/// <c>GalaxyHierarchyProjector.MatchesFilters</c> ANDs the <c>template_chain_contains</c> entries —
/// an object must match <i>every</i> token — so a single request carrying both templates matches no
/// platform (a <c>$WinPlatform</c>'s chain has no <c>$AppEngine</c>, and vice versa) and the probe
/// watcher seeded 0 platforms in production. The sweeps are unioned and de-duplicated by tag name.
/// </remarks>
public sealed class GatewayGalaxyPlatformSource : IGalaxyPlatformSource
{
/// <summary>Template-chain tokens that identify a platform / engine object.</summary>
@@ -32,15 +40,16 @@ public sealed class GatewayGalaxyPlatformSource : IGalaxyPlatformSource
=> QueryAsync(_client.DiscoverHierarchyRawAsync, _pageSize, cancellationToken);
/// <summary>
/// The platform-only sweep behind a delegate so it is unit-testable without a channel. Every
/// request carries the template filter and <c>include_attributes = false</c>; the reply is
/// The platform-only sweeps behind a delegate so it is unit-testable without a channel. One
/// paged sweep per entry of <see cref="PlatformTemplates"/>; every request carries exactly that
/// one template token and <c>include_attributes = false</c>; the reply is
/// re-filtered on the returned template chain (a gateway that ignores the filter still
/// yields only platforms) and reduced to non-empty tag names.
/// </summary>
/// <param name="discover">The raw <c>DiscoverHierarchy</c> RPC.</param>
/// <param name="pageSize">Objects per page.</param>
/// <param name="cancellationToken">Cancellation token for the walk.</param>
/// <returns>Distinct platform tag names in gateway order.</returns>
/// <returns>Distinct platform tag names: <c>$WinPlatform</c> sweep first, then <c>$AppEngine</c>, each in gateway order.</returns>
internal static async Task<IReadOnlyList<string>> QueryAsync(
Func<DiscoverHierarchyRequest, CancellationToken, Task<DiscoverHierarchyReply>> discover,
int pageSize,
@@ -51,33 +60,37 @@ public sealed class GatewayGalaxyPlatformSource : IGalaxyPlatformSource
var names = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var seenPageTokens = new HashSet<string>(StringComparer.Ordinal);
var pageToken = string.Empty;
do
foreach (var template in PlatformTemplates)
{
var request = new DiscoverHierarchyRequest
var seenPageTokens = new HashSet<string>(StringComparer.Ordinal);
var pageToken = string.Empty;
do
{
PageSize = effective,
PageToken = pageToken,
IncludeAttributes = false,
};
request.TemplateChainContains.Add(PlatformTemplates);
var request = new DiscoverHierarchyRequest
{
PageSize = effective,
PageToken = pageToken,
IncludeAttributes = false,
};
// Exactly ONE token per request: the gateway ANDs template filters (#864).
request.TemplateChainContains.Add(template);
var reply = await discover(request, cancellationToken).ConfigureAwait(false);
foreach (var obj in reply.Objects)
{
if (string.IsNullOrEmpty(obj.TagName) || !IsPlatform(obj)) continue;
if (seen.Add(obj.TagName)) names.Add(obj.TagName);
}
var reply = await discover(request, cancellationToken).ConfigureAwait(false);
foreach (var obj in reply.Objects)
{
if (string.IsNullOrEmpty(obj.TagName) || !IsPlatform(obj)) continue;
if (seen.Add(obj.TagName)) names.Add(obj.TagName);
}
pageToken = reply.NextPageToken ?? string.Empty;
if (!string.IsNullOrWhiteSpace(pageToken) && !seenPageTokens.Add(pageToken))
{
throw new InvalidOperationException(
$"Galaxy DiscoverHierarchy (platform query) returned a repeated page token '{pageToken}' — refusing to loop.");
pageToken = reply.NextPageToken ?? string.Empty;
if (!string.IsNullOrWhiteSpace(pageToken) && !seenPageTokens.Add(pageToken))
{
throw new InvalidOperationException(
$"Galaxy DiscoverHierarchy (platform query, {template}) returned a repeated page token '{pageToken}' — refusing to loop.");
}
}
while (!string.IsNullOrWhiteSpace(pageToken));
}
while (!string.IsNullOrWhiteSpace(pageToken));
return names;
}
@@ -15,33 +15,99 @@ public sealed class GatewayGalaxyPlatformSourceTests
return o;
}
/// <summary>Every request carries the platform template filter, no attributes, and the page size; replies are re-filtered.</summary>
[Fact]
public async Task Requests_filter_on_platform_templates_without_attributes_and_replies_are_refiltered()
/// <summary>
/// A fake gateway that applies the request filters with the SAME semantics as the shared
/// <c>GalaxyHierarchyProjector.MatchesFilters</c>: every <c>template_chain_contains</c> token must
/// match (AND, case-insensitive substring over the chain), <c>category_ids</c> is an OR set. Pages
/// the filtered result at the request's page size.
/// </summary>
private sealed class FilteringGateway(params GalaxyObject[] galaxy)
{
var requests = new List<DiscoverHierarchyRequest>();
Task<DiscoverHierarchyReply> Discover(DiscoverHierarchyRequest req, CancellationToken ct)
public List<DiscoverHierarchyRequest> Requests { get; } = [];
public Task<DiscoverHierarchyReply> Discover(DiscoverHierarchyRequest req, CancellationToken ct)
{
requests.Add(req);
Requests.Add(req.Clone());
var matched = galaxy.Where(o => Matches(o, req)).ToList();
var offset = req.PageToken == "" ? 0 : int.Parse(req.PageToken.Split(':')[1]);
var reply = new DiscoverHierarchyReply();
if (req.PageToken == "")
reply.Objects.Add(matched.Skip(offset).Take(req.PageSize));
if (offset + req.PageSize < matched.Count)
{
reply.Objects.Add([Obj("Plat01", "$WinPlatform"), Obj("Tank01", "$UserDefined")]);
reply.NextPageToken = "p2";
}
else
{
reply.Objects.Add([Obj("Eng01", "$AppEngine", "$WinPlatform"), Obj("Plat01", "$WinPlatform"), Obj("", "$WinPlatform")]);
reply.NextPageToken = $"{string.Join(",", req.TemplateChainContains)}:{offset + req.PageSize}";
}
return Task.FromResult(reply);
}
var names = await GatewayGalaxyPlatformSource.QueryAsync(Discover, pageSize: 50, CancellationToken.None);
private static bool Matches(GalaxyObject obj, DiscoverHierarchyRequest req)
{
if (req.CategoryIds.Count > 0 && !req.CategoryIds.Contains(obj.CategoryId)) return false;
foreach (var filter in req.TemplateChainContains)
{
if (!obj.TemplateChain.Any(t => t.Contains(filter, StringComparison.OrdinalIgnoreCase))) return false;
}
return true;
}
}
names.ShouldBe(["Plat01", "Eng01"]);
requests.Count.ShouldBe(2);
requests.ShouldAllBe(r => r.PageSize == 50 && r.IncludeAttributes == false);
requests.ShouldAllBe(r => r.TemplateChainContains.Contains("$WinPlatform") && r.TemplateChainContains.Contains("$AppEngine"));
/// <summary>
/// #864: against a gateway that ANDs template filters (as the real one does), every deployed
/// <c>$WinPlatform</c> AND every <c>$AppEngine</c> is found — the old single request carrying both
/// tokens matched none. Shape of the VD03 DEV galaxy: 3 platforms, 2 engines.
/// </summary>
[Fact]
public async Task Finds_every_platform_and_engine_against_an_AND_filtering_gateway()
{
var gw = new FilteringGateway(
Obj("CTRX_DEV", "$WinPlatform"),
Obj("DevWinPlatform", "$WinPlatform"),
Obj("JoePlatform", "$JoePlatformTemplate", "$WinPlatform"),
Obj("DevAppEngine", "$AppEngine"),
Obj("JoeAppEngine", "$AppEngine"),
Obj("Tank01", "$UserDefined"),
Obj("Area01", "$Area"));
var names = await GatewayGalaxyPlatformSource.QueryAsync(gw.Discover, pageSize: 50, CancellationToken.None);
names.ShouldBe(["CTRX_DEV", "DevWinPlatform", "JoePlatform", "DevAppEngine", "JoeAppEngine"]);
gw.Requests.ShouldAllBe(r => r.TemplateChainContains.Count == 1 && r.PageSize == 50 && r.IncludeAttributes == false);
gw.Requests.Select(r => r.TemplateChainContains[0]).Distinct().ShouldBe(["$WinPlatform", "$AppEngine"]);
}
/// <summary>Each per-template sweep pages to completion independently; tag names are de-duplicated across sweeps.</summary>
[Fact]
public async Task Each_template_sweep_pages_independently_and_names_are_deduplicated()
{
var gw = new FilteringGateway(
Obj("Plat01", "$WinPlatform"),
Obj("Plat02", "$WinPlatform"),
Obj("Plat03", "$WinPlatform"),
// An object whose chain carries both templates is reported once.
Obj("Hybrid01", "$AppEngine", "$WinPlatform"),
Obj("Eng01", "$AppEngine"),
Obj("Eng02", "$AppEngine"),
Obj("", "$WinPlatform"));
var names = await GatewayGalaxyPlatformSource.QueryAsync(gw.Discover, pageSize: 2, CancellationToken.None);
names.ShouldBe(["Plat01", "Plat02", "Plat03", "Hybrid01", "Eng01", "Eng02"]);
// $WinPlatform: 5 matches (incl. the empty tag) => 3 pages; $AppEngine: 3 matches => 2 pages.
gw.Requests.Count(r => r.TemplateChainContains[0] == "$WinPlatform").ShouldBe(3);
gw.Requests.Count(r => r.TemplateChainContains[0] == "$AppEngine").ShouldBe(2);
}
/// <summary>A gateway that ignores the filter still yields only platforms (replies are re-filtered on the chain).</summary>
[Fact]
public async Task Replies_are_refiltered_on_the_template_chain()
{
Task<DiscoverHierarchyReply> Discover(DiscoverHierarchyRequest req, CancellationToken ct)
{
var reply = new DiscoverHierarchyReply();
reply.Objects.Add([Obj("Plat01", "$WinPlatform"), Obj("Tank01", "$UserDefined"), Obj("Eng01", "$AppEngine")]);
return Task.FromResult(reply);
}
(await GatewayGalaxyPlatformSource.QueryAsync(Discover, pageSize: 50, CancellationToken.None)).ShouldBe(["Plat01", "Eng01"]);
}
/// <summary>A gateway that strips the template chain is trusted to have filtered server-side.</summary>