Files
mxaccessgw/docs/plans/2026-05-28-client-walker-design.md
T
Joseph Doherty eaf479349d docs: design for client-side LazyBrowseNode walker + per-language tests
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.
2026-05-28 14:12:03 -04:00

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:

  1. A low-level BrowseChildren*Async wrapper on the existing GalaxyRepositoryClient, mirroring the existing DiscoverHierarchy*Async shape.
  2. A high-level LazyBrowseNode type plus a BrowseAsync factory.
  3. 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 RefreshAsync on LazyBrowseNode (single-shot expand is enough; caller invalidates the tree by re-calling BrowseAsync).
  • Tree-builder helpers that pre-fetch the whole hierarchy (that's just DiscoverHierarchy with 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.