eaf479349d
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.
241 lines
8.2 KiB
Markdown
241 lines
8.2 KiB
Markdown
# 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<LazyBrowseNode> (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<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`)
|
|
|
|
```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<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`)
|
|
|
|
```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<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):
|
|
|
|
```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.
|