DiscoverHierarchy: subtree root + server-side filters #102

Closed
opened 2026-04-29 12:51:12 -04:00 by dohertj2 · 2 comments
Owner

Motivation

galaxy_repository.v1.DiscoverHierarchy today returns the entire deployed Galaxy as one paged stream. Every consumer that only cares about a slice (a single Area, only $WinPlatform runtime hosts, only alarm-bearing or historized attributes, a tag-name pattern for an Admin UI search box) has to pull the whole hierarchy and filter client-side.

For a 50k-object Galaxy this is wasteful for first-load and for any UI that does narrow lookups, and it makes future per-key scoped permissions (#follow-up) harder to implement because the server has no concept of "this caller's view of the hierarchy."

The gateway already materializes the full object list inside GalaxyHierarchyCache once per deploy, so server-side slicing is cheap — Where/Skip/Take over an in-memory list.

Proposed proto change

message DiscoverHierarchyRequest {
  int32 page_size = 1;
  string page_token = 2;

  // Optional. When set, return only this object and its descendants.
  // Empty = full hierarchy (current behavior).
  oneof root {
    int32 root_gobject_id = 3;
    string root_tag_name = 4;
    string root_contained_path = 5;  // e.g. "Area1/Line3"
  }

  // Optional. Cap on descendant depth from root.
  // 0 = root only, unset = unlimited.
  google.protobuf.Int32Value max_depth = 6;

  // Optional server-side filters applied against the cached list.
  repeated int32 category_ids = 7;              // e.g. only $WinPlatform/$AppEngine
  repeated string template_chain_contains = 8;  // template-name substring match
  string tag_name_glob = 9;                     // glob, e.g. "Line3_*"
  bool include_attributes = 10;                 // default true; false = skeleton only
  bool alarm_bearing_only = 11;                 // is_alarm == true on >=1 attribute
  bool historized_only = 12;                    // is_historized == true on >=1 attribute
}

Field numbers 3-12 are additive; existing clients keep working unchanged.

Server behavior

  • All filters apply server-side over the cached materialized list. No new SQL.
  • root_* resolves to a gobject_id; missing root returns NotFound.
  • Combination semantics: filters AND together. Empty/unset = no constraint.
  • max_depth is computed against the resolved root, not the Galaxy root.
  • tag_name_glob uses standard */? glob, anchored, case-insensitive.
  • include_attributes=false returns GalaxyObject skeletons with empty attributes — useful for Admin UI tree lazy-load.
  • Paging applies after filtering; total_object_count reflects post-filter count.
  • Existing call (no oneof, no filters) returns the full hierarchy as today.

Acceptance

  • Proto fields added with the numbers above.
  • Server-side filtering implemented in the cache projection layer (no SQL changes).
  • total_object_count is post-filter.
  • Round-trip tests for: full hierarchy (regression), subtree by gobject_id, subtree by tag_name, subtree by contained_path, max_depth cap, each filter individually, all filters combined, paging across a filtered result.
  • Cross-language client smoke matrix updated.
  • .NET client GalaxyRepositoryClient exposes a typed DiscoverHierarchyAsync(DiscoverHierarchyOptions) overload covering the new fields. The current zero-arg DiscoverHierarchyAsync() keeps current behavior.
  • docs/GalaxyRepository.md documents subtree + filter semantics.

Out of scope

Per-API-key constraint scoping (e.g. "this key may only see Area1/*") is a separate feature — see the per-key scoping issue. This issue ships the mechanism; that issue plugs key-bound constraints into the same code path.

Source

Surfaced during lmxopcua Galaxy → MxGateway migration planning (see lmx_mxgw_impl.md audit). OtOpcUa itself doesn't need this for the v1 migration since it pulls the whole hierarchy at startup, but it unblocks Admin UI use cases and is a structural prerequisite for per-key scoping.

## Motivation `galaxy_repository.v1.DiscoverHierarchy` today returns the entire deployed Galaxy as one paged stream. Every consumer that only cares about a slice (a single Area, only `$WinPlatform` runtime hosts, only alarm-bearing or historized attributes, a tag-name pattern for an Admin UI search box) has to pull the whole hierarchy and filter client-side. For a 50k-object Galaxy this is wasteful for first-load and for any UI that does narrow lookups, and it makes future per-key scoped permissions (#follow-up) harder to implement because the server has no concept of "this caller's view of the hierarchy." The gateway already materializes the full object list inside `GalaxyHierarchyCache` once per deploy, so server-side slicing is cheap — `Where`/`Skip`/`Take` over an in-memory list. ## Proposed proto change ```proto message DiscoverHierarchyRequest { int32 page_size = 1; string page_token = 2; // Optional. When set, return only this object and its descendants. // Empty = full hierarchy (current behavior). oneof root { int32 root_gobject_id = 3; string root_tag_name = 4; string root_contained_path = 5; // e.g. "Area1/Line3" } // Optional. Cap on descendant depth from root. // 0 = root only, unset = unlimited. google.protobuf.Int32Value max_depth = 6; // Optional server-side filters applied against the cached list. repeated int32 category_ids = 7; // e.g. only $WinPlatform/$AppEngine repeated string template_chain_contains = 8; // template-name substring match string tag_name_glob = 9; // glob, e.g. "Line3_*" bool include_attributes = 10; // default true; false = skeleton only bool alarm_bearing_only = 11; // is_alarm == true on >=1 attribute bool historized_only = 12; // is_historized == true on >=1 attribute } ``` Field numbers 3-12 are additive; existing clients keep working unchanged. ## Server behavior - All filters apply server-side over the cached materialized list. No new SQL. - `root_*` resolves to a `gobject_id`; missing root returns `NotFound`. - Combination semantics: filters AND together. Empty/unset = no constraint. - `max_depth` is computed against the resolved root, not the Galaxy root. - `tag_name_glob` uses standard `*`/`?` glob, anchored, case-insensitive. - `include_attributes=false` returns `GalaxyObject` skeletons with empty `attributes` — useful for Admin UI tree lazy-load. - Paging applies after filtering; `total_object_count` reflects post-filter count. - Existing call (no oneof, no filters) returns the full hierarchy as today. ## Acceptance - [ ] Proto fields added with the numbers above. - [ ] Server-side filtering implemented in the cache projection layer (no SQL changes). - [ ] `total_object_count` is post-filter. - [ ] Round-trip tests for: full hierarchy (regression), subtree by `gobject_id`, subtree by `tag_name`, subtree by `contained_path`, `max_depth` cap, each filter individually, all filters combined, paging across a filtered result. - [ ] Cross-language client smoke matrix updated. - [ ] `.NET` client `GalaxyRepositoryClient` exposes a typed `DiscoverHierarchyAsync(DiscoverHierarchyOptions)` overload covering the new fields. The current zero-arg `DiscoverHierarchyAsync()` keeps current behavior. - [ ] `docs/GalaxyRepository.md` documents subtree + filter semantics. ## Out of scope Per-API-key constraint scoping (e.g. "this key may only see `Area1/*`") is a separate feature — see the per-key scoping issue. This issue ships the *mechanism*; that issue plugs key-bound constraints into the same code path. ## Source Surfaced during `lmxopcua` Galaxy → MxGateway migration planning (see `lmx_mxgw_impl.md` audit). OtOpcUa itself doesn't need this for the v1 migration since it pulls the whole hierarchy at startup, but it unblocks Admin UI use cases and is a structural prerequisite for per-key scoping.
dohertj2 added the area:contractsarea:gatewaytype:featurepriority:p2 labels 2026-04-29 12:51:12 -04:00
Author
Owner

Companion: #103 (per-key scoped permissions). The browse_subtrees constraint there reuses the filtering pipeline added by this issue.

Companion: #103 (per-key scoped permissions). The `browse_subtrees` constraint there reuses the filtering pipeline added by this issue.
Author
Owner

Implemented in b995c17 (codex/fix-runtime-review-findings).

Verification passed:

  • dotnet build src\MxGateway.Contracts\MxGateway.Contracts.csproj
  • scripts\publish-client-proto-inputs.ps1
  • clients\go\generate-proto.ps1
  • clients\python\generate-proto.ps1
  • gradle :mxgateway-client:generateProto
  • dotnet test src\MxGateway.sln --no-restore
  • dotnet test clients\dotnet\MxGateway.Client.sln --no-restore
  • go test ./...
  • python -m pytest
  • cargo fmt --all --check
  • cargo test --workspace
  • gradle test
  • git diff --check
Implemented in b995c17 (`codex/fix-runtime-review-findings`). Verification passed: - `dotnet build src\MxGateway.Contracts\MxGateway.Contracts.csproj` - `scripts\publish-client-proto-inputs.ps1` - `clients\go\generate-proto.ps1` - `clients\python\generate-proto.ps1` - `gradle :mxgateway-client:generateProto` - `dotnet test src\MxGateway.sln --no-restore` - `dotnet test clients\dotnet\MxGateway.Client.sln --no-restore` - `go test ./...` - `python -m pytest` - `cargo fmt --all --check` - `cargo test --workspace` - `gradle test` - `git diff --check`
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: dohertj2/mxaccessgw#102