From eaf479349db3c381749c7ca4e4d616ff1bcdcaed Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 28 May 2026 14:12:03 -0400 Subject: [PATCH] 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. --- docs/plans/2026-05-28-client-walker-design.md | 240 ++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 docs/plans/2026-05-28-client-walker-design.md diff --git a/docs/plans/2026-05-28-client-walker-design.md b/docs/plans/2026-05-28-client-walker-design.md new file mode 100644 index 0000000..4d526b2 --- /dev/null +++ b/docs/plans/2026-05-28-client-walker-design.md @@ -0,0 +1,240 @@ +# 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: + +```text +LazyBrowseNode { + Object GalaxyObject (immutable, from server) + HasChildrenHint bool (server's child_has_children value) + Children list (empty until Expand) + IsExpanded bool + ExpandAsync(ct) Task (idempotent; no-op after first call) +} +``` + +`GalaxyRepositoryClient.BrowseAsync(parent?, ct)` returns a list of root +`LazyBrowseNode`s. 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 `LazyBrowseNode`s. + +**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`) + +```csharp +public sealed class LazyBrowseNode +{ + public GalaxyObject Object { get; } + public bool HasChildrenHint { get; } + public IReadOnlyList Children { get; } + public bool IsExpanded { get; } + public Task ExpandAsync(CancellationToken ct = default); +} + +public Task> BrowseAsync( + BrowseChildrenOptions? options = null, + CancellationToken ct = default); + +public Task BrowseChildrenRawAsync( + BrowseChildrenRequest request, + CancellationToken ct = default); +``` + +### Python (`clients/python/src/zb_mom_ww_mxgateway/galaxy.py`) + +```python +@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`) + +```rust +pub struct LazyBrowseNode { /* private fields; Arc> for Children */ } + +impl LazyBrowseNode { + pub fn object(&self) -> &GalaxyObject; + pub fn has_children_hint(&self) -> bool; + pub fn children(&self) -> Vec; // cloned snapshot + pub fn is_expanded(&self) -> bool; + pub async fn expand(&self) -> Result<(), GalaxyError>; +} + +pub async fn browse( + &self, + options: Option, +) -> Result, GalaxyError>; +``` + +### Go (`clients/go/mxgateway/galaxy.go`) + +```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/`) + +```java +public final class LazyBrowseNode { + public GalaxyObject getObject(); + public boolean hasChildrenHint(); + public List getChildren(); + public boolean isExpanded(); + public CompletableFuture expandAsync(); +} + +public CompletableFuture> 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): + +```bash +brew install temurin@21 +brew install gradle +``` + +Verify: +```bash +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: +```bash +export JAVA_HOME="$(/usr/libexec/java_home -v 21)" +``` + +Then verify the existing Java client builds against its committed +generated tree: +```bash +cd clients/java +gradle build -x test +``` + +If build succeeds, regenerate protos to pick up `BrowseChildren`: +```bash +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.