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:
@@ -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;
|
||||
}
|
||||
|
||||
+84
-18
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user