From b4bc2df0150f6f77cd32cf4e54d64c06ebe0c034 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 28 May 2026 14:29:15 -0400 Subject: [PATCH] client/java: LazyBrowseNode walker for lazy hierarchy browse --- .../client/BrowseChildrenOptions.java | 105 +++++++++ .../client/GalaxyRepositoryClient.java | 95 ++++++++ .../ww/mxgateway/client/LazyBrowseNode.java | 75 +++++++ .../client/GalaxyRepositoryClientTests.java | 210 ++++++++++++++++++ 4 files changed, 485 insertions(+) create mode 100644 clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/BrowseChildrenOptions.java create mode 100644 clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/LazyBrowseNode.java diff --git a/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/BrowseChildrenOptions.java b/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/BrowseChildrenOptions.java new file mode 100644 index 0000000..2015fe5 --- /dev/null +++ b/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/BrowseChildrenOptions.java @@ -0,0 +1,105 @@ +package com.zb.mom.ww.mxgateway.client; + +import java.util.Collections; +import java.util.List; + +/** + * Filters and shape options for {@link GalaxyRepositoryClient#browse(BrowseChildrenOptions)}. + * Mirror of the existing DiscoverHierarchy options for the lazy-browse path. + * + *

All filter fields are AND-combined server-side. Empty / unset fields disable + * that filter. The {@code includeAttributes} tri-state uses {@code null} to mean + * "let the server use its default"; non-{@code null} forwards the explicit flag. + */ +public final class BrowseChildrenOptions { + private final List categoryIds; + private final List templateChainContains; + private final String tagNameGlob; + private final Boolean includeAttributes; + private final boolean alarmBearingOnly; + private final boolean historizedOnly; + + private BrowseChildrenOptions(Builder b) { + this.categoryIds = List.copyOf(b.categoryIds); + this.templateChainContains = List.copyOf(b.templateChainContains); + this.tagNameGlob = b.tagNameGlob; + this.includeAttributes = b.includeAttributes; + this.alarmBearingOnly = b.alarmBearingOnly; + this.historizedOnly = b.historizedOnly; + } + + /** @return immutable list of category IDs to include; empty disables this filter. */ + public List getCategoryIds() { return categoryIds; } + + /** @return immutable list of template names that must appear in each child's template chain. */ + public List getTemplateChainContains() { return templateChainContains; } + + /** @return SQL-LIKE-style glob applied to {@code tag_name}; empty disables. */ + public String getTagNameGlob() { return tagNameGlob; } + + /** @return tri-state override for {@code include_attributes}; {@code null} keeps the server default. */ + public Boolean getIncludeAttributes() { return includeAttributes; } + + /** @return restrict to alarm-bearing objects. */ + public boolean isAlarmBearingOnly() { return alarmBearingOnly; } + + /** @return restrict to objects with at least one historized attribute. */ + public boolean isHistorizedOnly() { return historizedOnly; } + + /** @return a fresh builder. */ + public static Builder builder() { return new Builder(); } + + /** @return options with every filter disabled and {@code includeAttributes} unset. */ + public static BrowseChildrenOptions empty() { return builder().build(); } + + /** Fluent builder for {@link BrowseChildrenOptions}. */ + public static final class Builder { + private List categoryIds = Collections.emptyList(); + private List templateChainContains = Collections.emptyList(); + private String tagNameGlob = ""; + private Boolean includeAttributes = null; + private boolean alarmBearingOnly = false; + private boolean historizedOnly = false; + + /** Sets the category-id filter. */ + public Builder categoryIds(List v) { + this.categoryIds = v == null ? Collections.emptyList() : v; + return this; + } + + /** Sets the template-chain-contains filter. */ + public Builder templateChainContains(List v) { + this.templateChainContains = v == null ? Collections.emptyList() : v; + return this; + } + + /** Sets the tag-name glob. */ + public Builder tagNameGlob(String v) { + this.tagNameGlob = v == null ? "" : v; + return this; + } + + /** Sets the tri-state {@code includeAttributes} override; {@code null} keeps the server default. */ + public Builder includeAttributes(Boolean v) { + this.includeAttributes = v; + return this; + } + + /** Toggles the alarm-bearing-only filter. */ + public Builder alarmBearingOnly(boolean v) { + this.alarmBearingOnly = v; + return this; + } + + /** Toggles the historized-only filter. */ + public Builder historizedOnly(boolean v) { + this.historizedOnly = v; + return this; + } + + /** Builds the immutable options. */ + public BrowseChildrenOptions build() { + return new BrowseChildrenOptions(this); + } + } +} diff --git a/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/GalaxyRepositoryClient.java b/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/GalaxyRepositoryClient.java index 8ef38c8..e46a829 100644 --- a/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/GalaxyRepositoryClient.java +++ b/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/GalaxyRepositoryClient.java @@ -4,6 +4,8 @@ import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.MoreExecutors; import galaxy_repository.v1.GalaxyRepositoryGrpc; +import galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply; +import galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest; import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent; import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply; import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest; @@ -37,6 +39,7 @@ import javax.net.ssl.SSLException; */ public final class GalaxyRepositoryClient implements AutoCloseable { private static final int DISCOVER_HIERARCHY_PAGE_SIZE = 5000; + private static final int BROWSE_CHILDREN_PAGE_SIZE = 500; private final ManagedChannel ownedChannel; private final MxGatewayClientOptions options; @@ -213,6 +216,98 @@ public final class GalaxyRepositoryClient implements AutoCloseable { return discoverHierarchyPageAsync("", new java.util.ArrayList<>(), new java.util.HashSet<>()); } + /** + * Lazy-browse entry point: fetches the root layer of the Galaxy hierarchy. + * Each returned {@link LazyBrowseNode} can be expanded on demand via + * {@link LazyBrowseNode#expand()} to load its direct children. + * + * @return the root nodes (no parent selector) with default options + * @throws MxGatewayException on transport or protocol failure + */ + public List browse() { + return browse(null); + } + + /** + * Lazy-browse entry point with caller-supplied filters / shape. + * + * @param options filter and shape options; {@code null} means {@link BrowseChildrenOptions#empty()} + * @return the root nodes matching the options + * @throws MxGatewayException on transport or protocol failure + */ + public List browse(BrowseChildrenOptions options) { + BrowseChildrenOptions effective = options == null ? BrowseChildrenOptions.empty() : options; + return browseChildrenInner(null, effective); + } + + /** + * Issues a single {@code BrowseChildren} RPC and returns the raw reply. + * Callers wanting full control over pagination can drive the loop themselves. + * + * @param request the request to send + * @return the reply + * @throws MxGatewayException on transport or protocol failure + */ + public BrowseChildrenReply browseChildrenRaw(BrowseChildrenRequest request) { + try { + return rawBlockingStub().browseChildren(request); + } catch (RuntimeException error) { + if (error instanceof MxGatewayException) { + throw error; + } + throw MxGatewayErrors.fromGrpc("galaxy browse children", error); + } + } + + /** + * Drives the BrowseChildren paging loop for a single parent (or roots when + * {@code parentGobjectId} is {@code null}). Detects repeated page tokens to + * avoid infinite loops on a buggy server. + */ + List browseChildrenInner(Integer parentGobjectId, BrowseChildrenOptions options) { + java.util.ArrayList nodes = new java.util.ArrayList<>(); + java.util.HashSet seenPageTokens = new java.util.HashSet<>(); + String pageToken = ""; + while (true) { + BrowseChildrenRequest.Builder builder = BrowseChildrenRequest.newBuilder() + .setPageSize(BROWSE_CHILDREN_PAGE_SIZE) + .setPageToken(pageToken) + .setAlarmBearingOnly(options.isAlarmBearingOnly()) + .setHistorizedOnly(options.isHistorizedOnly()); + if (parentGobjectId != null) { + builder.setParentGobjectId(parentGobjectId.intValue()); + } + if (!options.getCategoryIds().isEmpty()) { + builder.addAllCategoryIds(options.getCategoryIds()); + } + if (!options.getTemplateChainContains().isEmpty()) { + builder.addAllTemplateChainContains(options.getTemplateChainContains()); + } + if (!options.getTagNameGlob().isEmpty()) { + builder.setTagNameGlob(options.getTagNameGlob()); + } + if (options.getIncludeAttributes() != null) { + builder.setIncludeAttributes(options.getIncludeAttributes()); + } + + BrowseChildrenReply reply = browseChildrenRaw(builder.build()); + + for (int i = 0; i < reply.getChildrenCount(); i++) { + boolean hint = i < reply.getChildHasChildrenCount() && reply.getChildHasChildren(i); + nodes.add(new LazyBrowseNode(this, reply.getChildren(i), hint, options)); + } + + pageToken = reply.getNextPageToken(); + if (pageToken == null || pageToken.isEmpty()) { + return nodes; + } + if (!seenPageTokens.add(pageToken)) { + throw new MxGatewayException( + "galaxy browse children returned repeated page token: " + pageToken); + } + } + } + /** * Subscribes to {@code WatchDeployEvents} via the async stub and consumes * results through a blocking iterator. Closing the returned stream cancels diff --git a/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/LazyBrowseNode.java b/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/LazyBrowseNode.java new file mode 100644 index 0000000..34ebd21 --- /dev/null +++ b/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/LazyBrowseNode.java @@ -0,0 +1,75 @@ +package com.zb.mom.ww.mxgateway.client; + +import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject; + +import java.util.Collections; +import java.util.List; + +/** + * One node in a lazy-loaded Galaxy browse tree. Holds the underlying + * {@link GalaxyObject} and exposes {@link #expand()} to fetch its direct + * children on demand. Expansion is one-shot: a second call is a no-op. + * Pagination of large sibling sets is handled internally by the client. + */ +public final class LazyBrowseNode { + private final GalaxyRepositoryClient client; + private final GalaxyObject object; + private final boolean hasChildrenHint; + private final BrowseChildrenOptions options; + private final Object lock = new Object(); + private List children = Collections.emptyList(); + private boolean isExpanded; + + LazyBrowseNode( + GalaxyRepositoryClient client, + GalaxyObject object, + boolean hasChildrenHint, + BrowseChildrenOptions options) { + this.client = client; + this.object = object; + this.hasChildrenHint = hasChildrenHint; + this.options = options; + } + + /** @return the underlying Galaxy object proto for this node. */ + public GalaxyObject getObject() { + return object; + } + + /** @return {@code true} when the server reports this node has at least one matching descendant. */ + public boolean hasChildrenHint() { + return hasChildrenHint; + } + + /** @return a snapshot of direct children loaded by {@link #expand()}; empty until then. */ + public List getChildren() { + synchronized (lock) { + return List.copyOf(children); + } + } + + /** @return {@code true} after the first {@link #expand()} call completes. */ + public boolean isExpanded() { + synchronized (lock) { + return isExpanded; + } + } + + /** + * Fetches direct children from the gateway and populates {@link #getChildren()}. + * Idempotent: subsequent calls are no-ops and do not issue a second RPC. + * + * @throws MxGatewayException on transport or protocol failure + */ + public void expand() { + synchronized (lock) { + if (isExpanded) { + return; + } + List loaded = + client.browseChildrenInner(Integer.valueOf(object.getGobjectId()), options); + this.children = loaded; + this.isExpanded = true; + } + } +} diff --git a/clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/GalaxyRepositoryClientTests.java b/clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/GalaxyRepositoryClientTests.java index 3b4cfbf..ba84e34 100644 --- a/clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/GalaxyRepositoryClientTests.java +++ b/clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/GalaxyRepositoryClientTests.java @@ -8,6 +8,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import com.google.protobuf.Timestamp; import galaxy_repository.v1.GalaxyRepositoryGrpc; +import galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply; +import galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest; import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent; import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply; import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest; @@ -24,6 +26,7 @@ import io.grpc.Server; import io.grpc.ServerCall; import io.grpc.ServerCallHandler; import io.grpc.ServerInterceptor; +import io.grpc.Status; import io.grpc.inprocess.InProcessChannelBuilder; import io.grpc.inprocess.InProcessServerBuilder; import io.grpc.stub.ClientCallStreamObserver; @@ -31,9 +34,13 @@ import io.grpc.stub.ClientResponseObserver; import io.grpc.stub.StreamObserver; import java.time.Duration; import java.time.Instant; +import java.util.ArrayDeque; +import java.util.Collections; import java.util.List; import java.util.Optional; +import java.util.Queue; import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; @@ -306,6 +313,209 @@ final class GalaxyRepositoryClientTests { } } + @Test + void browseNoParentReturnsRoots() throws Exception { + BrowseChildrenService service = new BrowseChildrenService(); + service.replies.add(browseReply( + List.of(obj(1, "Plant", true), obj(2, "Other", false)), + List.of(true, false), + 1L, + "")); + + try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>()); + GalaxyRepositoryClient client = g.client("")) { + List roots = client.browse(); + + assertEquals(2, roots.size()); + assertEquals("Plant", roots.get(0).getObject().getTagName()); + assertTrue(roots.get(0).hasChildrenHint()); + assertFalse(roots.get(0).isExpanded()); + assertEquals("Other", roots.get(1).getObject().getTagName()); + assertFalse(roots.get(1).hasChildrenHint()); + assertFalse(roots.get(1).isExpanded()); + assertEquals(1, service.calls.size()); + assertFalse(service.calls.get(0).hasParentGobjectId()); + } + } + + @Test + void browseExpandPopulatesChildrenAndMarksExpanded() throws Exception { + BrowseChildrenService service = new BrowseChildrenService(); + service.replies.add(browseReply( + List.of(obj(1, "Plant", true)), + List.of(true), + 1L, + "")); + service.replies.add(browseReply( + List.of(obj(10, "Line1", false)), + List.of(false), + 1L, + "")); + + try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>()); + GalaxyRepositoryClient client = g.client("")) { + List roots = client.browse(); + roots.get(0).expand(); + + assertTrue(roots.get(0).isExpanded()); + assertEquals(1, roots.get(0).getChildren().size()); + assertEquals("Line1", roots.get(0).getChildren().get(0).getObject().getTagName()); + assertEquals(2, service.calls.size()); + assertTrue(service.calls.get(1).hasParentGobjectId()); + assertEquals(1, service.calls.get(1).getParentGobjectId()); + } + } + + @Test + void browseExpandIdempotentNoSecondRpc() throws Exception { + BrowseChildrenService service = new BrowseChildrenService(); + service.replies.add(browseReply( + List.of(obj(1, "Plant", true)), + List.of(true), + 1L, + "")); + service.replies.add(browseReply( + List.of(obj(10, "Line1", false)), + List.of(false), + 1L, + "")); + + try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>()); + GalaxyRepositoryClient client = g.client("")) { + List roots = client.browse(); + roots.get(0).expand(); + roots.get(0).expand(); + + assertEquals(2, service.calls.size()); + assertEquals(1, roots.get(0).getChildren().size()); + } + } + + @Test + void browseExpandUnknownParentThrowsGalaxyNotFound() throws Exception { + BrowseChildrenService service = new BrowseChildrenService(); + service.replies.add(browseReply( + List.of(obj(1, "Plant", true)), + List.of(true), + 1L, + "")); + service.errors.add(Status.NOT_FOUND.withDescription("Parent not found").asRuntimeException()); + + try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>()); + GalaxyRepositoryClient client = g.client("")) { + List roots = client.browse(); + + MxGatewayException error = assertThrows(MxGatewayException.class, () -> roots.get(0).expand()); + assertTrue( + error.getMessage().toLowerCase().contains("not found"), + "expected message to mention 'not found', got: " + error.getMessage()); + } + } + + @Test + void browseExpandMultiPageGathersAllPages() throws Exception { + BrowseChildrenService service = new BrowseChildrenService(); + // Roots + service.replies.add(browseReply( + List.of(obj(7, "Plant", true)), + List.of(true), + 1L, + "")); + // First child page with a next token + service.replies.add(browseReply( + List.of(obj(70, "ChildA", false), obj(71, "ChildB", false)), + List.of(false, false), + 1L, + "7:abc:2")); + // Second child page closes the loop + service.replies.add(browseReply( + List.of(obj(72, "ChildC", false)), + List.of(false), + 1L, + "")); + + try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>()); + GalaxyRepositoryClient client = g.client("")) { + List roots = client.browse(); + roots.get(0).expand(); + + assertEquals(3, roots.get(0).getChildren().size()); + assertEquals(3, service.calls.size()); + assertEquals("7:abc:2", service.calls.get(2).getPageToken()); + } + } + + @Test + void browseWithFilterForwardsToRequest() throws Exception { + BrowseChildrenService service = new BrowseChildrenService(); + // Default reply is empty; only the request shape matters here. + try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>()); + GalaxyRepositoryClient client = g.client("")) { + client.browse(BrowseChildrenOptions.builder() + .tagNameGlob("Mixer*") + .alarmBearingOnly(true) + .build()); + } + + assertEquals(1, service.calls.size()); + BrowseChildrenRequest request = service.calls.get(0); + assertEquals("Mixer*", request.getTagNameGlob()); + assertTrue(request.getAlarmBearingOnly()); + } + + private static GalaxyObject obj(int id, String tag, boolean isArea) { + return GalaxyObject.newBuilder() + .setGobjectId(id) + .setTagName(tag) + .setBrowseName(tag) + .setIsArea(isArea) + .build(); + } + + private static BrowseChildrenReply browseReply( + List children, + List childHasChildren, + long cacheSequence, + String nextPageToken) { + BrowseChildrenReply.Builder b = BrowseChildrenReply.newBuilder() + .setTotalChildCount(children.size()) + .setCacheSequence(cacheSequence) + .setNextPageToken(nextPageToken); + b.addAllChildren(children); + b.addAllChildHasChildren(childHasChildren); + return b.build(); + } + + private static final class BrowseChildrenService extends TestService { + final List calls = + Collections.synchronizedList(new CopyOnWriteArrayList<>()); + final Queue replies = new ArrayDeque<>(); + final Queue errors = new ArrayDeque<>(); + + @Override + public void browseChildren( + BrowseChildrenRequest request, StreamObserver responseObserver) { + calls.add(request); + BrowseChildrenReply reply; + Throwable err; + synchronized (this) { + // Prefer queued replies first; once they're exhausted, fall through to any + // queued error. This matches the .NET fake's ordering used by parity tests. + reply = replies.poll(); + err = reply == null ? errors.poll() : null; + } + if (err != null) { + responseObserver.onError(err); + return; + } + if (reply == null) { + reply = BrowseChildrenReply.getDefaultInstance(); + } + responseObserver.onNext(reply); + responseObserver.onCompleted(); + } + } + private abstract static class TestService extends GalaxyRepositoryGrpc.GalaxyRepositoryImplBase { @Override public void testConnection(