Files
mxaccessgw/docs/plans/2026-05-28-lazy-browse-implementation.md
T
Joseph Doherty b3ebf583ad docs: implementation plan for lazy-browse BrowseChildren RPC
12-task bite-sized plan executing the approved design.
Includes native task persistence file.
2026-05-28 12:41:11 -04:00

47 KiB

Lazy Browse (BrowseChildren) Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.

Goal: Add an OPC UA-style BrowseChildren gRPC RPC and make the dashboard's BrowsePage load one level at a time, both backed by a new parent→children index on the existing shared IGalaxyHierarchyCache.

Architecture: A new GalaxyBrowseProjector is added next to the existing GalaxyHierarchyProjector. It reuses the cache's immutable GalaxyHierarchyCacheEntry and a new ChildrenByParent map built once per refresh inside GalaxyHierarchyIndex. Two consumers call the projector: a new BrowseChildren handler on GalaxyRepositoryGrpcService, and a new in-process IDashboardBrowseService used by BrowsePage.razor for click-to-expand rendering. The full Galaxy SQL refresh path is unchanged.

Tech Stack: .NET 10 / ASP.NET Core gRPC / Blazor Server (Bootstrap CSS only) / xUnit. Wire contract: proto3 (additive only per the file's wire-compatibility policy).

Design source: docs/plans/2026-05-28-lazy-browse-design.md.

Deliberate deviation from the design doc

The design's Section 2 said stale page tokens return FailedPrecondition. The existing DiscoverHierarchy returns InvalidArgument for the same condition (GalaxyRepositoryGrpcService.cs:258-263). Section 1 of the design said "same as today's DiscoverHierarchy." We follow today's behavior — InvalidArgument — so the two RPCs stay consistent. Update the design doc's error table accordingly in Task 10.


Task 0: Worktree & branch

Classification: trivial Estimated implement time: ~1 min Parallelizable with: none

Files:

  • Create: (none)

Step 1: Verify clean working tree from current main

Run: git status --short Expected: only the untracked *-docs-*.md files listed in the repo gitStatus snapshot are allowed; no modified tracked files.

Step 2: Branch

Run: git checkout -b feat/lazy-browse-children Expected: Switched to a new branch 'feat/lazy-browse-children'

No commit.


Task 1: Add proto contract for BrowseChildren

Classification: standard Estimated implement time: ~4 min Parallelizable with: none (everything else needs the generated types)

Files:

  • Modify: src/ZB.MOM.WW.MxGateway.Contracts/Protos/galaxy_repository.proto:21-33 (add RPC), end of file (add messages)
  • Test: src/ZB.MOM.WW.MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs (extend)

Step 1: Write the failing contract round-trip test

Append a new fact to ProtobufContractRoundTripTests (match the file's existing pattern — read it first to see how it builds messages and asserts serialize/parse identity). The test should:

  • Build a BrowseChildrenRequest with every scalar/oneof field set (one option of the parent oneof per assertion, all three exercised in separate Theory rows or three Facts).
  • Build a BrowseChildrenReply with two GalaxyObjects, two child_has_children booleans, next_page_token, total_child_count, and cache_sequence set.
  • Round-trip both via parser.ParseFrom(message.ToByteArray()) and assert Equals.

Step 2: Run test to verify it fails

Run: dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter FullyQualifiedName~ProtobufContractRoundTripTests Expected: compile failure (types not yet generated).

Step 3: Edit the proto

In src/ZB.MOM.WW.MxGateway.Contracts/Protos/galaxy_repository.proto:

Inside service GalaxyRepository, after WatchDeployEvents, add:

  // Returns the direct children of a parent object (or the root objects when
  // `parent` is unset). Designed for OPC UA-style lazy expand: clients walk
  // one level at a time instead of paging the full hierarchy. Filters mirror
  // DiscoverHierarchy exactly. Backed by the same shared hierarchy cache.
  rpc BrowseChildren(BrowseChildrenRequest) returns (BrowseChildrenReply);

At the end of the file (after GalaxyAttribute), append:

message BrowseChildrenRequest {
  // Parent selector. Empty oneof returns root objects (parent_gobject_id == 0).
  oneof parent {
    int32 parent_gobject_id = 1;
    string parent_tag_name = 2;
    string parent_contained_path = 3;
  }

  // Maximum number of direct children to return. Server default 500; cap 5000.
  int32 page_size = 4;
  // Opaque token returned by a previous BrowseChildren response. Bound to the
  // cache sequence, parent selector, and the filter set; a mismatch returns
  // InvalidArgument.
  string page_token = 5;

  // --- Filter parity with DiscoverHierarchy. AND-combined. ---
  repeated int32 category_ids = 6;
  repeated string template_chain_contains = 7;
  string tag_name_glob = 8;
  optional bool include_attributes = 9;
  bool alarm_bearing_only = 10;
  bool historized_only = 11;
}

message BrowseChildrenReply {
  // Direct children matching the filter, sorted areas-first then by
  // case-insensitive display name (same order as the dashboard tree).
  repeated GalaxyObject children = 1;
  // Non-empty when another page of siblings is available.
  string next_page_token = 2;
  // Total matching direct children of the parent (post-filter).
  int32 total_child_count = 3;
  // Parallel array, indexed with `children`. True when the child has at least
  // one matching descendant under the same filter set. Lets a UI choose
  // whether to draw an expand triangle without an extra round trip.
  repeated bool child_has_children = 4;
  // Cache sequence this reply was projected from. Clients may pass it back as
  // part of the page_token contract. Mismatch on the next page -> InvalidArgument.
  uint64 cache_sequence = 5;
}

Step 4: Regenerate and verify

Run: dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj Expected: succeeds; src/ZB.MOM.WW.MxGateway.Contracts/Generated/GalaxyRepository.cs now contains BrowseChildrenRequest, BrowseChildrenReply, and GalaxyRepositoryBase.BrowseChildren.

Step 5: Re-run the test

Run: dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter FullyQualifiedName~ProtobufContractRoundTripTests Expected: PASS.

Step 6: Commit

git add src/ZB.MOM.WW.MxGateway.Contracts/Protos/galaxy_repository.proto \
        src/ZB.MOM.WW.MxGateway.Contracts/Generated/ \
        src/ZB.MOM.WW.MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs
git commit -m "contracts: add BrowseChildren RPC for lazy hierarchy browse"

Task 2: Extend GalaxyHierarchyIndex with ChildrenByParent

Classification: standard Estimated implement time: ~5 min Parallelizable with: none (Tasks 3, 4 read this)

Files:

  • Modify: src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyIndex.cs (add property, populate in Build)
  • Create: src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyIndexTests.cs

Step 1: Write the failing test

Create src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyIndexTests.cs with four facts:

  1. ChildrenByParent_RootsUnderSentinelZero — three objects, all ParentGobjectId == 0, → index.ChildrenByParent[0] has all three.
  2. ChildrenByParent_NestedHierarchy_LinksParentToChildren — area A (id 1, parent 0), object B (id 2, parent 1), object C (id 3, parent 2) → [0]={A}, [1]={B}, [2]={C}.
  3. ChildrenByParent_SelfParentedObject_DoesNotRecurse — object with GobjectId == ParentGobjectId == 5 is included under sentinel 0 (parent ignored when equal to self), never under itself.
  4. ChildrenByParent_SortsAreasFirstThenByDisplayName — under one parent: instance "zebra", area "alpha", area "beta" → returned order [alpha, beta, zebra], areas-first, OrdinalIgnoreCase within group.

Build GalaxyObject lists directly with new GalaxyObject { GobjectId = ..., ParentGobjectId = ..., IsArea = ..., ContainedName = ..., BrowseName = ..., TagName = ... }. Call GalaxyHierarchyIndex.Build(objects).

Step 2: Run the tests to verify they fail

Run: dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter FullyQualifiedName~GalaxyHierarchyIndexTests Expected: compile failure (no ChildrenByParent property yet).

Step 3: Add ChildrenByParent and populate it

In GalaxyHierarchyIndex.cs:

  • Add ctor parameter and read-only property:

    public IReadOnlyDictionary<int, IReadOnlyList<GalaxyObjectView>> ChildrenByParent { get; }
    

    Include it in the private ctor and in Empty (use new Dictionary<int, IReadOnlyList<GalaxyObjectView>>()).

  • In Build, after the existing per-object loop builds views/viewsById/tagsByAddress, add a single pass:

    Dictionary<int, List<GalaxyObjectView>> childrenByParent = new();
    foreach (GalaxyObjectView view in views)
    {
        int parentKey = view.Object.ParentGobjectId;
        // Treat self-parented (corrupt) rows as roots; matches DashboardBrowseTreeBuilder.
        if (parentKey == view.Object.GobjectId)
        {
            parentKey = 0;
        }
        if (!childrenByParent.TryGetValue(parentKey, out List<GalaxyObjectView>? bucket))
        {
            bucket = [];
            childrenByParent[parentKey] = bucket;
        }
        bucket.Add(view);
    }
    
  • Sort each bucket: areas first, then OrdinalIgnoreCase on display name. The display name is view.Object.BrowseName ?? view.Object.ContainedName ?? view.Object.TagName (mirror DashboardBrowseNode.DisplayName). Add a private helper DisplayNameOf(view) so the projector and tests can reuse it later.

    foreach (List<GalaxyObjectView> bucket in childrenByParent.Values)
    {
        bucket.Sort(CompareByAreaThenDisplayName);
    }
    
    Dictionary<int, IReadOnlyList<GalaxyObjectView>> readOnlyChildren = new(childrenByParent.Count);
    foreach (KeyValuePair<int, List<GalaxyObjectView>> kvp in childrenByParent)
    {
        readOnlyChildren[kvp.Key] = kvp.Value;
    }
    
  • Pass readOnlyChildren into the ctor.

Step 4: Run the tests to verify they pass

Run: dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter FullyQualifiedName~GalaxyHierarchyIndexTests Expected: 4/4 PASS.

Step 5: Run the full server test suite to catch regressions

Run: dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj Expected: all green.

Step 6: Commit

git add src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyIndex.cs \
        src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyIndexTests.cs
git commit -m "galaxy: add ChildrenByParent index for level-at-a-time browse"

Task 3: Add GalaxyBrowseProjector.ProjectChildren

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 5 (dashboard service — relies only on this projector via abstraction)

Files:

  • Create: src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseProjector.cs
  • Create: src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseChildrenResult.cs
  • Create: src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyBrowseProjectorTests.cs

Step 1: Write failing tests

In GalaxyBrowseProjectorTests.cs, build a fixture with five objects under one root area:

Area "Plant" (id 1, parent 0)
├── Instance "Mixer_001" (id 2, parent 1)
├── Instance "Mixer_002" (id 3, parent 1)
├── Area "Line_A" (id 4, parent 1)
│   └── Instance "Sensor_A1" (id 5, parent 4)
└── Instance "Pump_001" (id 6, parent 1)

Tests (one fact each):

  1. Project_NoParent_ReturnsRootArea — empty parent oneof → one child: Plant. ChildHasChildren[0] == true.
  2. Project_ByParentGobjectId_ReturnsDirectChildrenParentGobjectId = 1 → 4 children in order [Line_A, Mixer_001, Mixer_002, Pump_001] (area first, then alpha). ChildHasChildren = [true, false, false, false].
  3. Project_ByParentTagName_ResolvesParentParentTagName = "Plant" → same 4 children.
  4. Project_ByParentContainedPath_ResolvesParentParentContainedPath = "Plant" → same 4 children.
  5. Project_UnknownParent_ThrowsNotFoundParentGobjectId = 999RpcException with StatusCode.NotFound.
  6. Project_PagedAcrossSiblings_ReturnsEverySiblingOnce — page size 2 over 4 children at parent 1, walk both pages, assert union is the full set, no duplicates.
  7. Project_TagNameGlobFiltersChildren_AndUpdatesHasChildrenTagNameGlob = "Mixer*", parent 1 → only Mixer_001/Mixer_002; ChildHasChildren = [false, false] (matching glob, no matching descendants).
  8. Project_HistorizedOnlyFiltersDescendants_HasChildrenReflectsFilter — mark only Sensor_A1 as historized; with HistorizedOnly = true and parent 1, only Line_A returned, ChildHasChildren = [true] (because its descendant matches).
  9. Project_IncludeAttributesFalse_ReturnsSkeletons — verify children[i].Attributes is empty after the call.
  10. Project_BrowseSubtrees_ExcludesChildrenOutsideAllowedGlobs — pass ["Plant/Line_*"] as browseSubtreeGlobs, parent 1 → only Line_A.

Use the same CreateEntry helper pattern as the existing GalaxyHierarchyProjectorTests. Read that file first if uncertain.

Step 2: Run tests to verify they fail

Run: dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter FullyQualifiedName~GalaxyBrowseProjectorTests Expected: compile failure (projector doesn't exist).

Step 3: Implement GalaxyBrowseChildrenResult

namespace ZB.MOM.WW.MxGateway.Server.Galaxy;

public sealed record GalaxyBrowseChildrenResult(
    IReadOnlyList<GalaxyObject> Children,
    IReadOnlyList<bool> ChildHasChildren,
    int TotalChildCount,
    string FilterSignature);

Step 4: Implement GalaxyBrowseProjector.ProjectChildren

src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseProjector.cs:

using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Text;
using Grpc.Core;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;

namespace ZB.MOM.WW.MxGateway.Server.Galaxy;

public static class GalaxyBrowseProjector
{
    private static readonly ConditionalWeakTable<
        GalaxyHierarchyCacheEntry,
        ConcurrentDictionary<string, FilteredChildren>> FilteredChildrenCache = new();

    public static GalaxyBrowseChildrenResult ProjectChildren(
        GalaxyHierarchyCacheEntry entry,
        BrowseChildrenRequest request,
        IReadOnlyList<string>? browseSubtreeGlobs,
        int offset,
        int pageSize)
    {
        ArgumentNullException.ThrowIfNull(entry);
        ArgumentNullException.ThrowIfNull(request);
        if (offset < 0) throw new ArgumentOutOfRangeException(nameof(offset));
        if (pageSize <= 0) throw new ArgumentOutOfRangeException(nameof(pageSize));

        int parentId = ResolveParentId(entry, request);
        string filterSignature = ComputeFilterSignature(request, browseSubtreeGlobs, parentId);
        FilteredChildren filtered = GetFilteredChildren(entry, request, browseSubtreeGlobs, parentId, filterSignature);

        bool includeAttributes = IncludeAttributes(request);
        int end = (int)Math.Min((long)offset + pageSize, filtered.Children.Count);
        List<GalaxyObject> page = new(Math.Max(0, end - offset));
        List<bool> hasChildren = new(Math.Max(0, end - offset));
        for (int i = offset; i < end; i++)
        {
            page.Add(CloneObject(filtered.Children[i].Object, includeAttributes));
            hasChildren.Add(filtered.HasMatchingDescendant[i]);
        }

        return new GalaxyBrowseChildrenResult(page, hasChildren, filtered.Children.Count, filterSignature);
    }

    private static int ResolveParentId(GalaxyHierarchyCacheEntry entry, BrowseChildrenRequest request)
    {
        switch (request.ParentCase)
        {
            case BrowseChildrenRequest.ParentOneofCase.None:
                return 0;
            case BrowseChildrenRequest.ParentOneofCase.ParentGobjectId:
                if (request.ParentGobjectId == 0) return 0;
                if (!entry.Index.ObjectViewsById.ContainsKey(request.ParentGobjectId))
                    throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found."));
                return request.ParentGobjectId;
            case BrowseChildrenRequest.ParentOneofCase.ParentTagName:
                {
                    GalaxyObjectView? match = entry.Index.ObjectViews.FirstOrDefault(
                        v => string.Equals(v.Object.TagName, request.ParentTagName, StringComparison.OrdinalIgnoreCase));
                    if (match is null) throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found."));
                    return match.Object.GobjectId;
                }
            case BrowseChildrenRequest.ParentOneofCase.ParentContainedPath:
                {
                    GalaxyObjectView? match = entry.Index.ObjectViews.FirstOrDefault(
                        v => string.Equals(v.ContainedPath, request.ParentContainedPath, StringComparison.OrdinalIgnoreCase));
                    if (match is null) throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found."));
                    return match.Object.GobjectId;
                }
            default:
                return 0;
        }
    }

    private static FilteredChildren GetFilteredChildren(
        GalaxyHierarchyCacheEntry entry,
        BrowseChildrenRequest request,
        IReadOnlyList<string>? browseSubtreeGlobs,
        int parentId,
        string filterSignature)
    {
        ConcurrentDictionary<string, FilteredChildren> memo =
            FilteredChildrenCache.GetValue(entry, static _ => new ConcurrentDictionary<string, FilteredChildren>(StringComparer.Ordinal));

        return memo.GetOrAdd(
            filterSignature,
            static (_, state) =>
            {
                IReadOnlyDictionary<int, IReadOnlyList<GalaxyObjectView>> map = state.Entry.Index.ChildrenByParent;
                IReadOnlyList<GalaxyObjectView> directChildren = map.TryGetValue(state.ParentId, out IReadOnlyList<GalaxyObjectView>? list)
                    ? list
                    : Array.Empty<GalaxyObjectView>();

                List<GalaxyObjectView> matched = [];
                List<bool> hasMatching = [];
                foreach (GalaxyObjectView view in directChildren)
                {
                    if (!MatchesBrowseSubtrees(view, state.BrowseSubtreeGlobs)) continue;
                    if (!MatchesFilters(view.Object, state.Request)) continue;
                    matched.Add(view);
                    hasMatching.Add(HasMatchingDescendant(view, state.Entry.Index, state.Request, state.BrowseSubtreeGlobs));
                }

                return new FilteredChildren(matched, hasMatching);
            },
            (Entry: entry, ParentId: parentId, Request: request, BrowseSubtreeGlobs: browseSubtreeGlobs));
    }

    private static bool HasMatchingDescendant(
        GalaxyObjectView parent,
        GalaxyHierarchyIndex index,
        BrowseChildrenRequest request,
        IReadOnlyList<string>? browseSubtreeGlobs)
    {
        if (!index.ChildrenByParent.TryGetValue(parent.Object.GobjectId, out IReadOnlyList<GalaxyObjectView>? children)) return false;

        Stack<GalaxyObjectView> stack = new();
        foreach (GalaxyObjectView child in children) stack.Push(child);
        while (stack.Count > 0)
        {
            GalaxyObjectView candidate = stack.Pop();
            if (MatchesBrowseSubtrees(candidate, browseSubtreeGlobs)
                && MatchesFilters(candidate.Object, request))
            {
                return true;
            }
            if (index.ChildrenByParent.TryGetValue(candidate.Object.GobjectId, out IReadOnlyList<GalaxyObjectView>? grandchildren))
            {
                foreach (GalaxyObjectView grandchild in grandchildren) stack.Push(grandchild);
            }
        }
        return false;
    }

    private static bool MatchesBrowseSubtrees(GalaxyObjectView view, IReadOnlyList<string>? globs)
        => globs is null || globs.Count == 0 || globs.Any(g => GalaxyGlobMatcher.IsMatch(view.ContainedPath, g));

    private static bool MatchesFilters(GalaxyObject obj, BrowseChildrenRequest request)
    {
        if (request.CategoryIds.Count > 0 && !request.CategoryIds.Contains(obj.CategoryId)) return false;
        foreach (string tpl in request.TemplateChainContains)
        {
            if (!obj.TemplateChain.Any(t => t.Contains(tpl, StringComparison.OrdinalIgnoreCase))) return false;
        }
        if (!string.IsNullOrWhiteSpace(request.TagNameGlob)
            && !GalaxyGlobMatcher.IsMatch(obj.TagName, request.TagNameGlob)) return false;
        if (request.AlarmBearingOnly && !obj.Attributes.Any(a => a.IsAlarm)) return false;
        if (request.HistorizedOnly && !obj.Attributes.Any(a => a.IsHistorized)) return false;
        return true;
    }

    private static bool IncludeAttributes(BrowseChildrenRequest request)
        => !request.HasIncludeAttributes || request.IncludeAttributes;

    private static GalaxyObject CloneObject(GalaxyObject source, bool includeAttributes)
    {
        GalaxyObject clone = source.Clone();
        if (!includeAttributes) clone.Attributes.Clear();
        return clone;
    }

    public static string ComputeFilterSignature(
        BrowseChildrenRequest request,
        IReadOnlyList<string>? browseSubtreeGlobs,
        int parentId)
    {
        StringBuilder sb = new();
        sb.Append("parent=").Append(parentId);
        sb.Append("|cat=").AppendJoin(',', request.CategoryIds.Order());
        sb.Append("|tpl=").AppendJoin(',', request.TemplateChainContains.Order(StringComparer.OrdinalIgnoreCase));
        sb.Append("|glob=").Append(request.TagNameGlob);
        sb.Append("|attrs=").Append(request.HasIncludeAttributes ? request.IncludeAttributes.ToString() : "unset");
        sb.Append("|alarm=").Append(request.AlarmBearingOnly);
        sb.Append("|hist=").Append(request.HistorizedOnly);
        sb.Append("|browse=").AppendJoin(',', (browseSubtreeGlobs ?? Array.Empty<string>()).Order(StringComparer.OrdinalIgnoreCase));
        byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString()));
        return Convert.ToHexString(hash, 0, 12);
    }

    private sealed record FilteredChildren(
        IReadOnlyList<GalaxyObjectView> Children,
        IReadOnlyList<bool> HasMatchingDescendant);
}

Step 5: Run the projector tests

Run: dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter FullyQualifiedName~GalaxyBrowseProjectorTests Expected: 10/10 PASS.

Step 6: Run the full server suite

Run: dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj Expected: all green.

Step 7: Commit

git add src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseProjector.cs \
        src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseChildrenResult.cs \
        src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyBrowseProjectorTests.cs
git commit -m "galaxy: add GalaxyBrowseProjector for direct-children projection"

Task 4: Wire BrowseChildren into GalaxyRepositoryGrpcService

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 5

Files:

  • Modify: src/ZB.MOM.WW.MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs
  • Modify: src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayGrpcScopeResolver.cs:23-26
  • Test: src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryGrpcServiceTests.cs
  • Test: src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcScopeResolverTests.cs

Step 1: Failing scope resolver test

In GatewayGrpcScopeResolverTests.cs (read it first to match style — likely a Theory or per-request-type Fact), add a case that Resolve(new BrowseChildrenRequest()) returns GatewayScopes.MetadataRead.

Step 2: Failing service test

In GalaxyRepositoryGrpcServiceTests.cs (read it first to match the existing fixtures — likely a FakeGalaxyHierarchyCache helper), add facts:

  1. BrowseChildren_RootCall_ReturnsRoots — cache contains the area-tree from Task 3; empty parent → reply contains exactly the root areas, CacheSequence > 0, ChildHasChildren populated.
  2. BrowseChildren_FirstLoadNotComplete_ReturnsUnavailable — fresh cache with Status == Unknown and no completion → RpcException(Unavailable) after the 5s budget. (Use a fake cache whose WaitForFirstLoadAsync never completes within the test's short token.)
  3. BrowseChildren_StaleToken_ReturnsInvalidArgument — projector returns next_page_token with sequence S; bump cache to sequence S+1; second call with that token → InvalidArgument with "stale" in the message.
  4. BrowseChildren_FilterChangeBetweenPages_ReturnsInvalidArgument — page 1 with one filter set, page 2 same token but different TagNameGlobInvalidArgument.
  5. BrowseChildren_BrowseSubtreesConstraint_FiltersChildren — fake identity accessor returns one browse-subtree glob; result is intersected.

Step 3: Run the tests to verify they fail

Run: dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter "FullyQualifiedName~GalaxyRepositoryGrpcServiceTests|FullyQualifiedName~GatewayGrpcScopeResolverTests" Expected: compile failure (handler doesn't exist yet) or runtime failures for the resolver case.

Step 4: Add BrowseChildren to the scope resolver

In GatewayGrpcScopeResolver.cs lines 23-26, extend the metadata-read clause:

TestConnectionRequest or
GetLastDeployTimeRequest or
DiscoverHierarchyRequest or
WatchDeployEventsRequest or
BrowseChildrenRequest => GatewayScopes.MetadataRead,

Step 5: Implement the handler

In GalaxyRepositoryGrpcService.cs, after DiscoverHierarchy and before WatchDeployEvents, add:

private const int DefaultBrowsePageSize = 500;
// (MaxDiscoverPageSize is reused as MaxBrowsePageSize — same 5000 cap.)

public override async Task<BrowseChildrenReply> BrowseChildren(
    BrowseChildrenRequest request,
    ServerCallContext context)
{
    await WaitForCacheBootstrap(context.CancellationToken).ConfigureAwait(false);
    GalaxyDb.GalaxyHierarchyCacheEntry entry = cache.Current;

    if (!entry.HasData)
    {
        throw new RpcException(new Status(
            StatusCode.Unavailable,
            ResolveUnavailableMessage(entry)));
    }

    int pageSize = ResolveBrowsePageSize(request.PageSize);
    IReadOnlyList<string> browseSubtrees = ResolveBrowseSubtrees();

    // Resolve the parent id once so the page-token signature can include it.
    int parentId = ResolveParentIdForToken(entry, request);
    string filterSignature = GalaxyDb.GalaxyBrowseProjector.ComputeFilterSignature(request, browseSubtrees, parentId);
    PageToken pageToken = ParsePageToken(request.PageToken, entry.Sequence, filterSignature);

    GalaxyDb.GalaxyBrowseChildrenResult result = GalaxyDb.GalaxyBrowseProjector.ProjectChildren(
        entry,
        request,
        browseSubtrees,
        pageToken.Offset,
        pageSize);

    if (pageToken.Offset > result.TotalChildCount)
    {
        throw new RpcException(new Status(
            StatusCode.InvalidArgument,
            "BrowseChildren page_token is outside the current children set."));
    }

    BrowseChildrenReply reply = new()
    {
        TotalChildCount = result.TotalChildCount,
        CacheSequence = (ulong)entry.Sequence,
    };
    reply.Children.Add(result.Children);
    reply.ChildHasChildren.Add(result.ChildHasChildren);

    int nextOffset = pageToken.Offset + result.Children.Count;
    if (nextOffset < result.TotalChildCount)
    {
        reply.NextPageToken = FormatPageToken(entry.Sequence, result.FilterSignature, nextOffset);
    }

    return reply;
}

private static int ResolveBrowsePageSize(int requested)
{
    if (requested < 0)
    {
        throw new RpcException(new Status(
            StatusCode.InvalidArgument,
            "BrowseChildren page_size must be greater than zero when provided."));
    }
    int pageSize = requested == 0 ? DefaultBrowsePageSize : requested;
    return Math.Min(pageSize, MaxDiscoverPageSize);
}

// Lightweight parent resolver used only for signature computation. Re-throws
// NotFound consistently with the projector so the error surface matches.
private static int ResolveParentIdForToken(
    GalaxyDb.GalaxyHierarchyCacheEntry entry,
    BrowseChildrenRequest request)
{
    return request.ParentCase switch
    {
        BrowseChildrenRequest.ParentOneofCase.None => 0,
        BrowseChildrenRequest.ParentOneofCase.ParentGobjectId =>
            request.ParentGobjectId == 0 ? 0
            : entry.Index.ObjectViewsById.ContainsKey(request.ParentGobjectId)
                ? request.ParentGobjectId
                : throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found.")),
        BrowseChildrenRequest.ParentOneofCase.ParentTagName =>
            entry.Index.ObjectViews.FirstOrDefault(
                v => string.Equals(v.Object.TagName, request.ParentTagName, StringComparison.OrdinalIgnoreCase))?.Object.GobjectId
            ?? throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found.")),
        BrowseChildrenRequest.ParentOneofCase.ParentContainedPath =>
            entry.Index.ObjectViews.FirstOrDefault(
                v => string.Equals(v.ContainedPath, request.ParentContainedPath, StringComparison.OrdinalIgnoreCase))?.Object.GobjectId
            ?? throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found.")),
        _ => 0,
    };
}

Reuse the existing FormatPageToken / ParsePageToken / PageToken record. They use sequence:filterSig:offset text encoding — that's fine for browse too. The ParsePageToken error messages currently say "DiscoverHierarchy"; leave them — the contract is the same and rewording them costs a wider edit without value. (If a code reviewer flags this in Task 11, generalize the message strings to "page_token is invalid/stale" then.)

Step 6: Run the tests

Run: dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj Expected: all green, including the new BrowseChildren tests.

Step 7: Commit

git add src/ZB.MOM.WW.MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs \
        src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayGrpcScopeResolver.cs \
        src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryGrpcServiceTests.cs \
        src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcScopeResolverTests.cs
git commit -m "grpc: implement BrowseChildren handler + metadata:read scope"

Task 5: Dashboard browse service & lazy BrowsePage

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 4 (different files, both depend on Tasks 2 & 3)

Files:

  • Create: src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardBrowseService.cs
  • Create: src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardBrowseService.cs
  • Modify: src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardBrowseModel.cs (add lazy node fields, keep existing builder)
  • Modify: src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/BrowsePage.razor
  • Modify: src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Shared/BrowseTreeNodeView.razor
  • Modify: the file that registers dashboard services in DI (find with grep -rn "AddScoped.*Dashboard\|AddSingleton.*Dashboard" src/ZB.MOM.WW.MxGateway.Server — register IDashboardBrowseService next to the others)
  • Test: src/ZB.MOM.WW.MxGateway.Tests/Dashboard/DashboardBrowseServiceTests.cs (new file)

Step 1: Failing tests for the service

DashboardBrowseServiceTests.cs:

  1. GetRoots_ReturnsRootObjects_WithHasChildrenHint — fixture with one root area containing one child; GetRoots(default) → one root node, HasChildrenHint == true.
  2. GetChildren_ByParentGobjectId_ReturnsDirectChildren — verify it routes through the projector and returns the same nodes the projector does.
  3. GetChildren_UnknownParent_ReturnsEmptyResultWithErrorFlag — translate the projector's NotFound RpcException into a BrowseLevelResult.NotFound so the Blazor UI can render a friendly message instead of propagating a server exception to the circuit. (Design says project the gRPC error to the UI; this is the dashboard-friendly translation.)
  4. CacheSequence_AdvancesAfterRefresh_NewQueriesReflectIt — bump the fake cache; subsequent calls report the new sequence.

Step 2: Run the tests to verify they fail

Run: dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter FullyQualifiedName~DashboardBrowseServiceTests Expected: compile failure.

Step 3: Implement the contracts and service

IDashboardBrowseService.cs:

using ZB.MOM.WW.MxGateway.Server.Galaxy;

namespace ZB.MOM.WW.MxGateway.Server.Dashboard;

public interface IDashboardBrowseService
{
    BrowseLevelResult GetRoots(BrowseFilterArgs filter);
    BrowseLevelResult GetChildren(int parentGobjectId, BrowseFilterArgs filter);
    ulong CurrentCacheSequence { get; }
}

public sealed record BrowseFilterArgs(
    string? TagNameGlob = null,
    bool AlarmBearingOnly = false,
    bool HistorizedOnly = false);

public sealed record BrowseLevelResult(
    IReadOnlyList<DashboardBrowseNode> Nodes,
    int TotalCount,
    ulong CacheSequence,
    string? Error = null);

DashboardBrowseModel.cs — extend DashboardBrowseNode with three new optional fields (additive, doesn't break the existing eager-tree consumer):

public bool HasChildrenHint { get; init; }
public BrowseLoadState LoadState { get; set; } = BrowseLoadState.NotLoaded;
public string? LoadError { get; set; }
public enum BrowseLoadState { NotLoaded, Loading, Loaded, Error }

Existing HasChildren already returns Children.Count > 0 || Attributes.Count > 0 — leave it; the new HasChildrenHint is what the lazy UI consults before children have loaded.

DashboardBrowseService.cs:

using Grpc.Core;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.MxGateway.Server.Galaxy;

namespace ZB.MOM.WW.MxGateway.Server.Dashboard;

public sealed class DashboardBrowseService(IGalaxyHierarchyCache cache) : IDashboardBrowseService
{
    public ulong CurrentCacheSequence => (ulong)cache.Current.Sequence;

    public BrowseLevelResult GetRoots(BrowseFilterArgs filter)
        => ProjectLevel(parentId: null, filter);

    public BrowseLevelResult GetChildren(int parentGobjectId, BrowseFilterArgs filter)
        => ProjectLevel(parentId: parentGobjectId, filter);

    private BrowseLevelResult ProjectLevel(int? parentId, BrowseFilterArgs filter)
    {
        GalaxyHierarchyCacheEntry entry = cache.Current;
        if (!entry.HasData)
        {
            return new BrowseLevelResult(Array.Empty<DashboardBrowseNode>(), 0, (ulong)entry.Sequence,
                Error: "Galaxy hierarchy is not loaded yet.");
        }

        BrowseChildrenRequest request = new()
        {
            TagNameGlob = filter.TagNameGlob ?? string.Empty,
            AlarmBearingOnly = filter.AlarmBearingOnly,
            HistorizedOnly = filter.HistorizedOnly,
        };
        if (parentId is int pid) request.ParentGobjectId = pid;

        try
        {
            GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
                entry, request, browseSubtreeGlobs: null, offset: 0, pageSize: int.MaxValue);

            List<DashboardBrowseNode> nodes = new(result.Children.Count);
            for (int i = 0; i < result.Children.Count; i++)
            {
                nodes.Add(new DashboardBrowseNode
                {
                    Object = result.Children[i],
                    HasChildrenHint = result.ChildHasChildren[i],
                });
            }
            return new BrowseLevelResult(nodes, result.TotalChildCount, (ulong)entry.Sequence);
        }
        catch (RpcException ex) when (ex.StatusCode == StatusCode.NotFound)
        {
            return new BrowseLevelResult(Array.Empty<DashboardBrowseNode>(), 0, (ulong)entry.Sequence,
                Error: ex.Status.Detail);
        }
    }
}

Register in DI alongside the other dashboard services (find the file, add services.AddScoped<IDashboardBrowseService, DashboardBrowseService>();).

Step 4: Rework BrowseTreeNodeView.razor for lazy expand

Change the toggle path:

  • Show the toggle when Node.HasChildrenHint || Node.Attributes.Count > 0 (replaces the HasChildren check).
  • On Toggle, if LoadState == NotLoaded, set Loading, invoke an async load callback (new [Parameter] public Func<DashboardBrowseNode, Task>? OnLoadChildren), then set Loaded or Error.
  • While loading, show a small spinner glyph in place of the toggle arrow.
  • Render children only when LoadState == Loaded (and the existing _expanded flag is true).
  • Children render with OnLoadChildren passed through so nested nodes can lazy-load too.

Step 5: Rework BrowsePage.razor

  • Inject IDashboardBrowseService BrowseService in place of (or alongside) the existing eager build.
  • Replace the eager BuildRoots() call with BrowseService.GetRoots(new BrowseFilterArgs(...)) on first render.
  • Add an async method LoadChildrenAsync(DashboardBrowseNode node) that calls BrowseService.GetChildren(node.Object.GobjectId, currentFilter), populates node.Children, marks LoadState = Loaded, and calls StateHasChanged().
  • Pass that as OnLoadChildren to the root BrowseTreeNodeViews.
  • Subscribe to deploy events (the page already injects IDashboardLiveDataService — if it doesn't already expose a deploy hook, fall back to IGalaxyDeployNotifier.SubscribeAsync via a new injected dependency). On a sequence change, clear _roots, re-GetRoots, set a one-line banner "Galaxy redeployed — tree refreshed." (no toast, no modal). Banner clears on next user click.
  • Search path unchanged. When Search is non-empty, keep the existing flat-list search code (it walks the full cache via GalaxyHierarchyCache.Current.Objects). Lazy is only for the unfiltered tree.

Step 6: Run all server tests

Run: dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj Expected: all green.

Step 7: Manual smoke on Windows host

Document only — no automation here. The plan's executor flags this for the human:

ssh windev "Restart-Service MxAccessGw"
# Then visit http://10.100.0.48:5130/browse and verify:
# - Initial render shows only root areas
# - Clicking a triangle loads that level
# - A re-deploy banner appears after a forced redeploy

Step 8: Commit

git add src/ZB.MOM.WW.MxGateway.Server/Dashboard/ \
        src/ZB.MOM.WW.MxGateway.Tests/Dashboard/DashboardBrowseServiceTests.cs
git commit -m "dashboard: lazy-load BrowsePage via DashboardBrowseService"

Task 6: Cross-language client smoke (skeleton walk)

Classification: small Estimated implement time: ~4 min Parallelizable with: Tasks 7, 8, 9, 10 (different files)

Files:

  • Modify: clients/dotnet/<test project>/ — add one xUnit fact that opens a GalaxyRepositoryClient, calls BrowseChildren against the dev gateway only when MXGATEWAY_API_KEY is set, asserts cache_sequence > 0. Use Skip = "..." when env var missing.

Only the .NET client gets a new test in this task. The other language clients pick up the new RPC automatically via the next proto-regen in their build scripts; per-language test parity is a separate follow-up.

Step 1: Add the test

Read the existing clients/dotnet test project layout; mirror the pattern of an existing live-only GalaxyRepository test if one exists. Otherwise create clients/dotnet/<TestProject>/Galaxy/BrowseChildrenSmokeTests.cs.

[SkippableFact]
public async Task BrowseChildren_LiveGateway_ReturnsRoots()
{
    string? apiKey = Environment.GetEnvironmentVariable("MXGATEWAY_API_KEY");
    Skip.If(string.IsNullOrEmpty(apiKey), "Set MXGATEWAY_API_KEY to run.");

    string endpoint = Environment.GetEnvironmentVariable("MXGATEWAY_ENDPOINT") ?? "http://localhost:5120";
    using GrpcChannel channel = GrpcChannel.ForAddress(endpoint);
    GalaxyRepository.GalaxyRepositoryClient client = new(channel);

    Metadata headers = new() { { "authorization", $"Bearer {apiKey}" } };
    BrowseChildrenReply reply = await client.BrowseChildrenAsync(new BrowseChildrenRequest(), headers);

    Assert.True(reply.CacheSequence > 0);
    Assert.Equal(reply.Children.Count, reply.ChildHasChildren.Count);
}

Step 2: Build & run

Run: dotnet build clients/dotnet/MxGateway.Client.sln Expected: succeeds (proto regen picks up BrowseChildren).

Run: dotnet test clients/dotnet/MxGateway.Client.sln --filter FullyQualifiedName~BrowseChildrenSmokeTests Expected: SKIPPED (no API key). With key set + gateway running, it passes.

Step 3: Commit

git add clients/dotnet/
git commit -m "client/dotnet: live smoke for BrowseChildren"

Task 7: Regenerate Python/Go/Rust/Java client protos

Classification: small Estimated implement time: ~3 min Parallelizable with: Tasks 6, 8, 9, 10

Files:

  • Modify: clients/python/src/mxgateway/generated/ (regen)
  • Modify: clients/go/... (regen — check the existing build/gen script)
  • Modify: clients/rust/<crate>/generated*/ (regen)
  • Modify: clients/java/<module>/build/generated/ if checked in

Step 1: Run each client's existing generation step

Read each clients/<lang>/README.md for its codegen command. Run them in order:

cd clients/python && python -m pip install -e ".[dev]" && python -m build  # or whatever regenerates protos
cd ../rust && cargo build --workspace
cd ../go && go generate ./...
cd ../java && gradle generateProto

Expected: each succeeds; BrowseChildren types appear in each client's generated tree.

Step 2: Sanity build each

(cd clients/python && python -m pytest --collect-only)        # imports compile
(cd clients/rust && cargo check --workspace)
(cd clients/go && go build ./...)
(cd clients/java && gradle build -x test)

Step 3: Commit (one per language to keep the diff reviewable)

git add clients/python/
git commit -m "client/python: regenerate protos for BrowseChildren"
# repeat for each language

Task 8: Update docs/GalaxyRepository.md

Classification: small Estimated implement time: ~3 min Parallelizable with: Tasks 6, 7, 9, 10

Files:

  • Modify: docs/GalaxyRepository.md

Step 1: Edit

  • In the "RPC Surface" table (around line 33-39), add one row:

    | `BrowseChildren` | Returns the direct children of one parent object (or root objects when unset). Filters mirror `DiscoverHierarchy`. **Served from cache.** |
    
  • Add a new subsection after the existing DiscoverHierarchy description, titled ### BrowseChildren, covering:

    • The parent oneof and how an empty oneof means "roots".
    • Filter parity with DiscoverHierarchy (point readers to the existing filter docs rather than duplicating).
    • child_has_children semantics — computed against the filtered descendant set.
    • Page token contract: (cache_sequence, parent_id, filter_signature, offset). Stale or filter-changed token → InvalidArgument. Unknown parent → NotFound.
    • Default page size 500; cap 5000.
  • Update the architecture diagram (around line 271-286) to show GalaxyBrowseProjector reused by both GalaxyRepositoryGrpcService.BrowseChildren and the new DashboardBrowseService.

Step 2: Commit

git add docs/GalaxyRepository.md
git commit -m "docs: document BrowseChildren RPC and lazy browse architecture"

Task 9: Update gateway.md and per-client READMEs

Classification: small Estimated implement time: ~3 min Parallelizable with: Tasks 6, 7, 8, 10

Files:

  • Modify: gateway.md (one-line entry in the Galaxy RPC table)
  • Modify: clients/dotnet/README.md
  • Modify: clients/python/README.md
  • Modify: clients/rust/README.md
  • Modify: clients/go/README.md (or whatever the existing Go client README is — find with find clients/go -iname 'README*')
  • Modify: clients/java/README.md

Step 1: Add to gateway.md

Find the Galaxy RPC table (grep for DiscoverHierarchy in gateway.md); add a row identical in style to its neighbours describing BrowseChildren.

Step 2: Per-client Browsing lazily snippet

In each client README, add a short section under the existing Galaxy / browse docs:

### Browsing lazily

For OPC UA-style tree UIs, use BrowseChildren to walk one level at a time.
Pass an empty request for roots; subsequent calls supply parent_gobject_id
(or parent_tag_name / parent_contained_path). Each child's matching
`child_has_children[i]` tells you whether to draw an expand triangle.
Filter fields match DiscoverHierarchy.

Adapt the code snippet to each language's idiom (match the existing examples in that README).

Step 3: Commit

git add gateway.md clients/*/README.md
git commit -m "docs: note BrowseChildren in gateway overview and client READMEs"

Task 10: Update docs/DesignDecisions.md and design doc

Classification: trivial Estimated implement time: ~2 min Parallelizable with: Tasks 6, 7, 8, 9

Files:

  • Modify: docs/DesignDecisions.md
  • Modify: docs/plans/2026-05-28-lazy-browse-design.md

Step 1: Add a DesignDecisions.md entry

Append a new bullet noting:

Lazy browse stays wire-only. The gateway continues to pull the full Galaxy hierarchy on each deploy. The BrowseChildren RPC and lazy dashboard render only avoid sending and DOM-materializing the full tree — they do not push laziness into SQL or cache loading. Reasons: snapshot persistence and the dashboard summary both depend on a fully-materialized cache; lazy SQL would dramatically increase per-click latency on a deployment-heavy box and complicate the cold-start path.

Step 2: Reconcile the design doc

In docs/plans/2026-05-28-lazy-browse-design.md, change the error-mapping table row for "Stale page_token" from FailedPrecondition to InvalidArgument and add a one-line note that this matches DiscoverHierarchy's pre-existing behavior.

Step 3: Commit

git add docs/DesignDecisions.md docs/plans/2026-05-28-lazy-browse-design.md
git commit -m "docs: record lazy-browse stays wire-only; align error mapping"

Task 11: Final integration build and verification

Classification: standard Estimated implement time: ~3 min Parallelizable with: none (final gate)

Step 1: Build the whole solution

Run: dotnet build src/MxGateway.sln Expected: 0 warnings, 0 errors (the repo treats warnings as errors).

Step 2: Run all gateway unit tests

Run: dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj Expected: all green; new tests included.

Step 3: Build the .NET client solution

Run: dotnet build clients/dotnet/MxGateway.Client.sln Expected: succeeds.

Step 4: Self-review the diff

Run: git log --oneline main..HEAD and git diff --stat main..HEAD. Sanity-check that each commit corresponds to one plan task; no stray edits.

Step 5: Open PR (only after the user approves)

The executor must stop here and ask the user before pushing or opening a PR.