Adds one high-level walker per client (.NET/Python/Rust/Go/Java) plus six unit tests each against existing fake transports. One-shot idempotent Expand semantics; pagination hidden inside the helper. Includes Java toolchain bootstrap (Homebrew Temurin + Gradle) so the Java client can build locally on the macOS dev host.
8.2 KiB
Client Lazy-Browse Walker Helpers + Per-Language Tests
Date: 2026-05-28 Status: approved, ready for implementation plan
Problem
The BrowseChildren RPC shipped (branch feat/lazy-browse-children, merged
or pending merge), but each language client exposes only the raw generated
gRPC stub. Callers must hand-write recursion, sibling pagination, and
NotFound translation themselves. Only one client (.NET) has a smoke test,
and it is skippable.
This work adds a small high-level walker to each client and unit tests so callers can build OPC UA-style browse trees without re-implementing the same plumbing five times.
Scope
Each of the five clients (.NET, Python, Rust, Go, Java) gains:
- A low-level
BrowseChildren*Asyncwrapper on the existingGalaxyRepositoryClient, mirroring the existingDiscoverHierarchy*Asyncshape. - A high-level
LazyBrowseNodetype plus aBrowseAsyncfactory. - Five unit tests against the language's existing fake-transport fixture.
Plus a one-time toolchain bootstrap so the Java client builds locally on the macOS dev host (Homebrew install of Temurin 21 + Gradle).
Architecture
LazyBrowseNode is shared in shape across languages:
LazyBrowseNode {
Object GalaxyObject (immutable, from server)
HasChildrenHint bool (server's child_has_children value)
Children list<LazyBrowseNode> (empty until Expand)
IsExpanded bool
ExpandAsync(ct) Task (idempotent; no-op after first call)
}
GalaxyRepositoryClient.BrowseAsync(parent?, ct) returns a list of root
LazyBrowseNodes. Empty parent means structural roots. Each returned
node is unexpanded; the caller invokes ExpandAsync to fetch direct
children. After expand, Children is a list of further LazyBrowseNodes.
Pagination is hidden. ExpandAsync walks next_page_token internally
until all siblings of this parent are gathered. Callers see one flat
Children list.
Errors: server NotFound becomes a language-idiomatic typed error
(MxGatewayException in .NET, GalaxyNotFoundError in Python,
GalaxyError::NotFound in Rust, typed error in Go,
GalaxyNotFoundException in Java).
Filters: BrowseAsync accepts a BrowseChildrenOptions (or
language-equivalent) mirroring the existing DiscoverHierarchyOptions. The
same options apply to every ExpandAsync call rooted from that factory
call — stored on the node so child expansions inherit them.
Per-language API
Each language adapts to its own idioms; the structure is parallel.
.NET (clients/dotnet/ZB.MOM.WW.MxGateway.Client/GalaxyRepositoryClient.cs)
public sealed class LazyBrowseNode
{
public GalaxyObject Object { get; }
public bool HasChildrenHint { get; }
public IReadOnlyList<LazyBrowseNode> Children { get; }
public bool IsExpanded { get; }
public Task ExpandAsync(CancellationToken ct = default);
}
public Task<IReadOnlyList<LazyBrowseNode>> BrowseAsync(
BrowseChildrenOptions? options = null,
CancellationToken ct = default);
public Task<BrowseChildrenReply> BrowseChildrenRawAsync(
BrowseChildrenRequest request,
CancellationToken ct = default);
Python (clients/python/src/zb_mom_ww_mxgateway/galaxy.py)
@dataclass
class LazyBrowseNode:
object: GalaxyObject
has_children_hint: bool
children: list["LazyBrowseNode"]
is_expanded: bool
async def expand(self) -> None: ...
async def browse(
self,
options: BrowseChildrenOptions | None = None,
) -> list[LazyBrowseNode]: ...
Rust (clients/rust/src/galaxy.rs)
pub struct LazyBrowseNode { /* private fields; Arc<Mutex<>> for Children */ }
impl LazyBrowseNode {
pub fn object(&self) -> &GalaxyObject;
pub fn has_children_hint(&self) -> bool;
pub fn children(&self) -> Vec<LazyBrowseNode>; // cloned snapshot
pub fn is_expanded(&self) -> bool;
pub async fn expand(&self) -> Result<(), GalaxyError>;
}
pub async fn browse(
&self,
options: Option<BrowseChildrenOptions>,
) -> Result<Vec<LazyBrowseNode>, GalaxyError>;
Go (clients/go/mxgateway/galaxy.go)
type LazyBrowseNode struct { /* unexported */ }
func (n *LazyBrowseNode) Object() *pb.GalaxyObject
func (n *LazyBrowseNode) HasChildrenHint() bool
func (n *LazyBrowseNode) Children() []*LazyBrowseNode
func (n *LazyBrowseNode) IsExpanded() bool
func (n *LazyBrowseNode) Expand(ctx context.Context) error
func (c *Client) Browse(
ctx context.Context,
opts *BrowseChildrenOptions,
) ([]*LazyBrowseNode, error)
Java (clients/java/zb-mom-ww-mxgateway-client/)
public final class LazyBrowseNode {
public GalaxyObject getObject();
public boolean hasChildrenHint();
public List<LazyBrowseNode> getChildren();
public boolean isExpanded();
public CompletableFuture<Void> expandAsync();
}
public CompletableFuture<List<LazyBrowseNode>> browseAsync(
BrowseChildrenOptions options);
If the existing Java client surface is synchronous, mirror that — both sync and async variants are acceptable as long as the choice matches the client's existing convention.
Tests
Each language adds these six facts against its existing fake-transport
fixture (FakeGalaxyRepositoryTransport in .NET, the equivalent in each
other client):
| # | Test | Purpose |
|---|---|---|
| 1 | Browse_NoParent_ReturnsRoots |
factory returns roots, each unexpanded, hint reflects fake's child_has_children |
| 2 | Expand_PopulatesChildrenAndMarksExpanded |
one ExpandAsync call fires one BrowseChildren RPC; Children populated; IsExpanded flips |
| 3 | Expand_CalledTwice_NoSecondRpc |
idempotency — fake records RPC count == 1 |
| 4 | Expand_UnknownParent_ThrowsGalaxyNotFound |
server NotFound surfaces as language-typed error |
| 5 | Expand_MultiPageSiblings_GathersAllPages |
fake returns NextPageToken on first call; helper walks pages until empty; flat Children list |
| 6 | Browse_WithFilter_ForwardsToRequest |
options propagate into the wire request (tag_name_glob etc.) |
No new live-only tests in this batch. The existing
BrowseChildrenSmokeTests in .NET covers wire compatibility.
Java toolchain bootstrap
The macOS dev host lacks a JVM. Install via Homebrew (one-time):
brew install temurin@21
brew install gradle
Verify:
java -version # expect 21.x
gradle --version # expect 8.x or 9.x
If the Temurin formula does not auto-link JAVA_HOME, add to shell init:
export JAVA_HOME="$(/usr/libexec/java_home -v 21)"
Then verify the existing Java client builds against its committed generated tree:
cd clients/java
gradle build -x test
If build succeeds, regenerate protos to pick up BrowseChildren:
gradle generateProto
This produces the new Java RPC stubs that the walker work depends on.
Failure path: if Homebrew installs but gradle build fails for an
environmental reason (e.g., proto plugin version mismatch), fall back to
"defer Java" — implement the other four clients and document that Java
walker work waits for the Windows host. Do not spend more than ~30
minutes debugging local Java issues; the Windows host already builds the
Java client cleanly.
Documentation updates
Each client's README.md "Browsing lazily" snippet (added in commit
0d6193c) gets one short example block showing the high-level walker
in addition to the existing raw-RPC snippet. Approximately three
sentences plus a 5-line code block per language.
No changes to gateway.md, docs/GalaxyRepository.md, or
docs/DesignDecisions.md — those describe the wire contract; the
walkers are client-side ergonomics, not part of the wire surface.
Non-goals
- Async iterator / streaming walker (rejected in brainstorming — encourages eager-to-completion consumption that defeats laziness).
- Explicit
RefreshAsynconLazyBrowseNode(single-shot expand is enough; caller invalidates the tree by re-callingBrowseAsync). - Tree-builder helpers that pre-fetch the whole hierarchy (that's just
DiscoverHierarchywith extra round-trips). - Server changes — the wire contract is final.
- Cross-client integration test runner — each client tests in isolation.
- Java regen on Mac if Homebrew install fails — defer to Windows host.