11 KiB
Lazy Browse for Galaxy Repository
Date: 2026-05-28 Status: approved, ready for implementation plan
Problem
GalaxyRepository.DiscoverHierarchy returns the whole hierarchy (paged, but
clients still walk every object). The dashboard BrowsePage then materializes
the full tree into a Blazor component graph on every render. For large
deployments this means oversized gRPC replies for external clients and a
heavy DOM for the dashboard, even when an operator only wants to expand a
single Area.
External integrations (OPC UA bridges in particular) need OPC UA-style "browse one level on demand" semantics instead of bulk download.
Scope
Lazy browse lands at three layers:
- gRPC — a new
BrowseChildrenRPC that returns the direct children of a parent. - Dashboard —
BrowsePageloads roots only and fetches each level on expand, in-process (no gRPC self-hop). - Server projection — a parent→children index added to the existing
IGalaxyHierarchyCacheentry; reused by both the RPC and the dashboard service.
Out of scope: changing how the gateway loads Galaxy SQL. The hierarchy cache still pulls the full deployment on each deploy change; persistence and the dashboard summary are unchanged. The lazy story is wire-side and UI-side only.
Architecture
gRPC client Dashboard (Blazor circuit)
| |
v v
GalaxyRepositoryGrpcService IDashboardBrowseService (in-process)
.BrowseChildren .GetRoots / .GetChildren
\ /
\ /
v v
GalaxyBrowseProjector.ProjectChildren
|
v
IGalaxyHierarchyCache.Current (GalaxyHierarchyCacheEntry)
|
+-- ObjectViews (existing)
+-- ChildrenByParent (NEW, built once per refresh)
GalaxyBrowseProjector is the single source of truth for "direct children of
a parent, filtered, ordered, paged." Both the gRPC handler and the dashboard
service call it.
Proto contract
Added to src/ZB.MOM.WW.MxGateway.Contracts/Protos/galaxy_repository.proto,
additive only per the existing wire-compatibility policy.
service GalaxyRepository {
// ... existing RPCs ...
rpc BrowseChildren(BrowseChildrenRequest) returns (BrowseChildrenReply);
}
message BrowseChildrenRequest {
oneof parent {
int32 parent_gobject_id = 1;
string parent_tag_name = 2;
string parent_contained_path = 3;
}
int32 page_size = 4; // default 500, max 5000
string page_token = 5; // opaque
// 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; // default true
bool alarm_bearing_only = 10;
bool historized_only = 11;
}
message BrowseChildrenReply {
repeated GalaxyObject children = 1;
string next_page_token = 2;
int32 total_child_count = 3; // matching direct children (post-filter)
repeated bool child_has_children = 4; // parallel-indexed with `children`
uint64 cache_sequence = 5;
}
GalaxyObject and GalaxyAttribute are unchanged. Root browse = empty
parent oneof.
Error mapping
| Condition | Status |
|---|---|
Unknown parent_gobject_id / parent_tag_name / parent_contained_path |
NotFound |
Stale page_token (cache deployed forward) |
InvalidArgument; current cache_sequence in trailers |
| Filter set differs between pages of the same token | InvalidArgument |
| First load not complete within 5s | Unavailable |
API key missing metadata:read scope |
PermissionDenied |
| No API key | Unauthenticated |
Stale and filter-changed page tokens both surface as InvalidArgument — same contract as DiscoverHierarchy, since BrowseChildren reuses the same token encoding (sequence:filter-signature:offset).
browse_subtrees API-key constraints intersect with the request as today.
Server projection
New index on GalaxyHierarchyCacheEntry
Built once per refresh, in GalaxyHierarchyIndex:
// gobject_id -> direct children, sorted areas-first then by display name.
// Roots (parent_gobject_id == 0 or absent) live under sentinel key 0.
IReadOnlyDictionary<int, IReadOnlyList<GalaxyObjectView>> ChildrenByParent;
Cost: one O(n) pass over ObjectViews during refresh. Memory: O(n) — one int
plus a list reference per object, negligible next to the existing object list.
GalaxyBrowseProjector.ProjectChildren
- Resolve
parentselector toparent_gobject_id(root sentinel for empty). Unknown parent →NotFound. - Look up direct children from
ChildrenByParent. - Apply the same filter pipeline as
DiscoverHierarchy. Extract the filter logic fromGalaxyHierarchyProjector.ApplyFiltersinto a shared helper used by both projectors so filter semantics cannot drift. - Intersect with API-key
browse_subtreesglobs. - Memoize the filtered, ordered list keyed by
(parent_id, filter_signature)in aConditionalWeakTable<GalaxyHierarchyCacheEntry, ...>— mirrors the existingGalaxyHierarchyProjector.FilteredViewCachepattern; GC'd when the entry is. - Compute
child_has_childrenfor each returned child against the filtered descendant set (recurse downChildrenByParent, short-circuit on first match; memoize the per-child boolean alongside the filtered list). - Slice page; encode
next_page_token={cache_sequence, parent_id, filter_signature, offset}base64 protobuf.
Sort order: areas first, then OrdinalIgnoreCase by display name —
identical to DashboardBrowseTreeBuilder.CompareNodes so the dashboard and
external clients see the same ordering.
gRPC handler
GalaxyRepositoryGrpcService.BrowseChildren:
- Wait up to 5s for first cache load (same
WaitForFirstLoadAsyncpattern asDiscoverHierarchy). - Read
Currententry. - Call
GalaxyBrowseProjector.ProjectChildren. - Map
GalaxyObjectView→GalaxyObjectproto via existingGalaxyProtoMapper.MapObject. Skeleton branch (include_attributes=false) omits the attribute list — already supported by the mapper forDiscoverHierarchy.
Authorization: GatewayGrpcScopeResolver.BrowseChildren requires
metadata:read — same scope as DiscoverHierarchy.
Dashboard
New in-process service:
public interface IDashboardBrowseService
{
BrowseLevelResult GetRoots(BrowseFilterArgs filter);
BrowseLevelResult GetChildren(int parentGobjectId, BrowseFilterArgs filter);
GalaxyObject? FindByContainedPath(string path); // for deep-link expansion
}
public sealed record BrowseLevelResult(
IReadOnlyList<DashboardBrowseNode> Nodes,
int TotalCount,
ulong CacheSequence);
Backed by the same GalaxyBrowseProjector the gRPC service uses — dashboard
and external clients render identical results.
BrowsePage.razor changes:
- Initial render fetches roots only.
- Each
DashboardBrowseNodetracksLoadState(NotLoaded/Loading/Loaded/Error). BrowseTreeNodeView.razorshows the expand triangle fromHasChildrenHint(the projector'schild_has_children) regardless of whether children have been loaded yet. First expand triggers an async load and re-render.- Loaded children are kept for the lifetime of the Blazor circuit OR until
the cache sequence advances (subscribed via the existing
IGalaxyDeployNotifier). On invalidation: collapse all expansions, show a one-line "Galaxy redeployed, click to re-expand" hint. - Attributes render inline under their object (unchanged) since
include_attributes=trueis the default. - Search box: when non-empty, fall back to the existing bulk
DiscoverHierarchypath +DashboardBrowseTreeBuilder. Lazy browse is for unfiltered exploration; building a streaming search is out of scope.
DashboardBrowseTreeBuilder.Build stays, used by the search fallback and
the existing tests.
Testing
Unit tests (no live MXAccess / Galaxy required):
GalaxyHierarchyIndexTests—ChildrenByParentcorrectness: roots under sentinel, deep nesting, self-parented objects, duplicate ids.GalaxyBrowseProjectorTests—- Direct-children selection by
gobject_id,tag_name,contained_path. - Filter parity with
DiscoverHierarchy: same fixtures, same filter permutations, identical filtered-set output. child_has_childrencorrectness under filters that eliminate descendants.- Ordering matches
DashboardBrowseTreeBuilderbyte-for-byte. - Sibling pagination across multiple pages.
- Page-token round trip (serialize → deserialize → same offset).
- Stale
page_token→InvalidArgument. - Unknown parent →
NotFound. - Filter change between pages of the same token →
InvalidArgument.
- Direct-children selection by
GalaxyRepositoryGrpcServiceTests— newBrowseChildrenhappy path, first-load wait,browse_subtreesintersection,metadata:readscope enforcement.DashboardBrowseServiceTests— in-process service matches projector output, deploy-sequence invalidation, error states.ProtobufContractRoundTripTests— extended forBrowseChildrenRequest/BrowseChildrenReply.
Manual verification on a live MXAccess host: expand a deep Area in the dashboard, force a redeploy mid-expand, observe invalidation banner.
No new opt-in live-only tests — the projector is pure over the cache, and the cache itself is already exercised by existing live tests.
Documentation updates (same change)
Per the repo rule that doc updates ride with the code change:
docs/GalaxyRepository.md— addBrowseChildrento the RPC Surface table; new subsection covering semantics, filter parity, page-token contract, and error mapping. Update the architecture diagram to show the shared projector.gateway.md— one-line entry forBrowseChildrenin the Galaxy RPC table.clients/{dotnet,python,rust,go,java}/README.md— short "Browsing lazily" snippet showing aBrowseChildrenwalk withchild_has_childrendriving expand-triangle UI.docs/DesignDecisions.md— entry noting the explicit choice to keep the full hierarchy in the server cache; lazy is wire-only.
Non-goals
- SQL-side incremental loading. The gateway still pulls the full Galaxy hierarchy on each deploy change.
- Multi-snapshot retention. A deploy invalidates outstanding page tokens; clients re-walk.
- Streaming search. Search keeps the bulk
DiscoverHierarchy+ tree-build path. - Reconciling a partially-expanded dashboard tree across a deploy — the invalidation banner forces a re-expand.