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.
This commit is contained in:
@@ -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<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.
|
||||
Reference in New Issue
Block a user