From fd2a0ac4c759d031b50d5ec2dbb65b9e1ad643a1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 28 May 2026 14:26:41 -0400 Subject: [PATCH] client/go: LazyBrowseNode walker for lazy hierarchy browse --- clients/go/mxgateway/galaxy.go | 143 ++++++++++++ clients/go/mxgateway/galaxy_test.go | 331 +++++++++++++++++++++++++++- clients/go/mxgateway/options.go | 22 ++ 3 files changed, 487 insertions(+), 9 deletions(-) diff --git a/clients/go/mxgateway/galaxy.go b/clients/go/mxgateway/galaxy.go index a5da3d4..fa8dc4f 100644 --- a/clients/go/mxgateway/galaxy.go +++ b/clients/go/mxgateway/galaxy.go @@ -3,7 +3,9 @@ package mxgateway import ( "context" "errors" + "fmt" "io" + "sync" "time" pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated" @@ -13,6 +15,9 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" ) +// browseChildrenPageSize is the per-request page size used by the lazy walker. +const browseChildrenPageSize = 500 + // RawGalaxyRepositoryClient is the generated gRPC client interface for the // Galaxy Repository service exposed for callers that need direct contract // access. @@ -40,6 +45,10 @@ type ( WatchDeployEventsRequest = pb.WatchDeployEventsRequest // DeployEvent is one Galaxy Repository deploy event. DeployEvent = pb.DeployEvent + // BrowseChildrenRequest is the request for BrowseChildren. + BrowseChildrenRequest = pb.BrowseChildrenRequest + // BrowseChildrenReply is the reply for BrowseChildren. + BrowseChildrenReply = pb.BrowseChildrenReply ) // RawDeployEventStream is the generated WatchDeployEvents client stream. @@ -238,6 +247,140 @@ func (c *GalaxyClient) Close() error { return c.conn.Close() } +// LazyBrowseNode is one node in a lazy Galaxy hierarchy walk produced by +// (*GalaxyClient).Browse. Children are not fetched until Expand is called. +// The node is safe for concurrent use; concurrent Expand calls collapse to a +// single RPC. +type LazyBrowseNode struct { + client *GalaxyClient + object *pb.GalaxyObject + hasChildrenHint bool + options BrowseChildrenOptions + + mu sync.Mutex + children []*LazyBrowseNode + isExpanded bool +} + +// Object returns the underlying GalaxyObject describing this node. +func (n *LazyBrowseNode) Object() *pb.GalaxyObject { return n.object } + +// HasChildrenHint reports the server-supplied hint on whether this node has +// matching descendants under the current filter set. +func (n *LazyBrowseNode) HasChildrenHint() bool { return n.hasChildrenHint } + +// Children returns a snapshot copy of the currently-loaded child nodes. Returns +// an empty slice when Expand has not yet been called. +func (n *LazyBrowseNode) Children() []*LazyBrowseNode { + n.mu.Lock() + defer n.mu.Unlock() + out := make([]*LazyBrowseNode, len(n.children)) + copy(out, n.children) + return out +} + +// IsExpanded reports whether Expand has completed successfully on this node. +func (n *LazyBrowseNode) IsExpanded() bool { + n.mu.Lock() + defer n.mu.Unlock() + return n.isExpanded +} + +// Expand fetches this node's direct children via BrowseChildren when they have +// not yet been loaded. Subsequent calls after a successful Expand are a no-op +// and do not issue another RPC. +func (n *LazyBrowseNode) Expand(ctx context.Context) error { + n.mu.Lock() + defer n.mu.Unlock() + if n.isExpanded { + return nil + } + parentID := n.object.GetGobjectId() + children, err := n.client.browseChildrenInner(ctx, &parentID, n.options) + if err != nil { + return err + } + n.children = children + n.isExpanded = true + return nil +} + +// Browse returns the root nodes of the Galaxy hierarchy. The returned nodes +// have only their server-supplied hints populated; call Expand on each node to +// fetch its direct children. When opts is nil the server defaults apply. +func (c *GalaxyClient) Browse(ctx context.Context, opts *BrowseChildrenOptions) ([]*LazyBrowseNode, error) { + effective := BrowseChildrenOptions{} + if opts != nil { + effective = *opts + } + return c.browseChildrenInner(ctx, nil, effective) +} + +// BrowseChildrenRaw issues a single BrowseChildren RPC and returns the raw +// reply for callers that need direct page-token control. Transport-level +// failures are wrapped in *GatewayError to match the rest of the client. +func (c *GalaxyClient) BrowseChildrenRaw(ctx context.Context, req *pb.BrowseChildrenRequest) (*pb.BrowseChildrenReply, error) { + callCtx, cancel := c.callContext(ctx) + defer cancel() + reply, err := c.raw.BrowseChildren(callCtx, req) + if err != nil { + return nil, &GatewayError{Op: "galaxy browse children", Err: err} + } + return reply, nil +} + +func (c *GalaxyClient) browseChildrenInner( + ctx context.Context, + parentGobjectID *int32, + opts BrowseChildrenOptions, +) ([]*LazyBrowseNode, error) { + var nodes []*LazyBrowseNode + pageToken := "" + seen := map[string]struct{}{} + for { + req := &pb.BrowseChildrenRequest{ + PageSize: browseChildrenPageSize, + PageToken: pageToken, + CategoryIds: opts.CategoryIds, + TemplateChainContains: opts.TemplateChainContains, + TagNameGlob: opts.TagNameGlob, + AlarmBearingOnly: opts.AlarmBearingOnly, + HistorizedOnly: opts.HistorizedOnly, + } + if parentGobjectID != nil { + req.Parent = &pb.BrowseChildrenRequest_ParentGobjectId{ParentGobjectId: *parentGobjectID} + } + if opts.IncludeAttributes != nil { + req.IncludeAttributes = opts.IncludeAttributes + } + + reply, err := c.BrowseChildrenRaw(ctx, req) + if err != nil { + return nil, err + } + + for i, child := range reply.GetChildren() { + hasChildren := reply.GetChildHasChildren() + hint := i < len(hasChildren) && hasChildren[i] + nodes = append(nodes, &LazyBrowseNode{ + client: c, + object: child, + hasChildrenHint: hint, + options: opts, + }) + } + + pageToken = reply.GetNextPageToken() + if pageToken == "" { + return nodes, nil + } + if _, dup := seen[pageToken]; dup { + return nil, fmt.Errorf("mxgateway: galaxy browse children returned repeated page token %q", pageToken) + } + seen[pageToken] = struct{}{} + } +} + func (c *GalaxyClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) { timeout := c.opts.CallTimeout if timeout == 0 { diff --git a/clients/go/mxgateway/galaxy_test.go b/clients/go/mxgateway/galaxy_test.go index 842a353..5780e5f 100644 --- a/clients/go/mxgateway/galaxy_test.go +++ b/clients/go/mxgateway/galaxy_test.go @@ -9,6 +9,8 @@ import ( pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated" "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "google.golang.org/grpc/test/bufconn" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -370,15 +372,18 @@ func newGalaxyBufconnClient(t *testing.T, fake *fakeGalaxyServer) (*GalaxyClient type fakeGalaxyServer struct { pb.UnimplementedGalaxyRepositoryServer - testReply *pb.TestConnectionReply - testAuth string - failTest bool - deployReply *pb.GetLastDeployTimeReply - discoverReply *pb.DiscoverHierarchyReply - watchEvents []*pb.DeployEvent - watchRequest *pb.WatchDeployEventsRequest - watchSendInterval time.Duration - watchHoldOpen bool + testReply *pb.TestConnectionReply + testAuth string + failTest bool + deployReply *pb.GetLastDeployTimeReply + discoverReply *pb.DiscoverHierarchyReply + watchEvents []*pb.DeployEvent + watchRequest *pb.WatchDeployEventsRequest + watchSendInterval time.Duration + watchHoldOpen bool + browseChildrenCalls []*pb.BrowseChildrenRequest + browseChildrenReplies []*pb.BrowseChildrenReply + browseChildrenError error } func (s *fakeGalaxyServer) TestConnection(ctx context.Context, req *pb.TestConnectionRequest) (*pb.TestConnectionReply, error) { @@ -425,3 +430,311 @@ func (s *fakeGalaxyServer) WatchDeployEvents(req *pb.WatchDeployEventsRequest, s } return nil } + +func (s *fakeGalaxyServer) BrowseChildren(ctx context.Context, req *pb.BrowseChildrenRequest) (*pb.BrowseChildrenReply, error) { + s.browseChildrenCalls = append(s.browseChildrenCalls, req) + if s.browseChildrenError != nil { + err := s.browseChildrenError + s.browseChildrenError = nil + return nil, err + } + if len(s.browseChildrenReplies) == 0 { + return &pb.BrowseChildrenReply{}, nil + } + reply := s.browseChildrenReplies[0] + s.browseChildrenReplies = s.browseChildrenReplies[1:] + return reply, nil +} + +func obj(id int32, tag string, isArea bool) *pb.GalaxyObject { + return &pb.GalaxyObject{ + GobjectId: id, + TagName: tag, + BrowseName: tag, + IsArea: isArea, + } +} + +func buildBrowseReply(children []*pb.GalaxyObject, hasChildren []bool, seq uint64) *pb.BrowseChildrenReply { + return &pb.BrowseChildrenReply{ + TotalChildCount: int32(len(children)), + CacheSequence: seq, + Children: children, + ChildHasChildren: hasChildren, + } +} + +func TestGalaxyBrowseNoParentReturnsRoots(t *testing.T) { + fake := &fakeGalaxyServer{ + browseChildrenReplies: []*pb.BrowseChildrenReply{ + buildBrowseReply( + []*pb.GalaxyObject{obj(1, "Plant", true), obj(99, "Other", false)}, + []bool{true, false}, + 7, + ), + }, + } + client, cleanup := newGalaxyBufconnClient(t, fake) + defer cleanup() + + roots, err := client.Browse(context.Background(), nil) + if err != nil { + t.Fatalf("Browse: %v", err) + } + if got, want := len(roots), 2; got != want { + t.Fatalf("len(roots) = %d, want %d", got, want) + } + if roots[0].Object().GetTagName() != "Plant" { + t.Fatalf("roots[0].TagName = %q", roots[0].Object().GetTagName()) + } + if !roots[0].HasChildrenHint() { + t.Fatal("roots[0].HasChildrenHint = false, want true") + } + if roots[0].IsExpanded() { + t.Fatal("roots[0].IsExpanded = true, want false") + } + if roots[1].HasChildrenHint() { + t.Fatal("roots[1].HasChildrenHint = true, want false") + } + if len(fake.browseChildrenCalls) != 1 { + t.Fatalf("BrowseChildren calls = %d, want 1", len(fake.browseChildrenCalls)) + } + if fake.browseChildrenCalls[0].GetParent() != nil { + t.Fatalf("root browse should not set Parent oneof, got %T", fake.browseChildrenCalls[0].GetParent()) + } +} + +func TestGalaxyBrowseExpandPopulatesChildrenAndMarksExpanded(t *testing.T) { + fake := &fakeGalaxyServer{ + browseChildrenReplies: []*pb.BrowseChildrenReply{ + buildBrowseReply( + []*pb.GalaxyObject{obj(1, "Plant", true)}, + []bool{true}, + 1, + ), + buildBrowseReply( + []*pb.GalaxyObject{obj(10, "Area1", true), obj(11, "Tank1", false)}, + []bool{true, false}, + 1, + ), + }, + } + client, cleanup := newGalaxyBufconnClient(t, fake) + defer cleanup() + + roots, err := client.Browse(context.Background(), nil) + if err != nil { + t.Fatalf("Browse: %v", err) + } + if len(roots) != 1 { + t.Fatalf("len(roots) = %d, want 1", len(roots)) + } + plant := roots[0] + if plant.IsExpanded() { + t.Fatal("plant.IsExpanded = true before Expand, want false") + } + if err := plant.Expand(context.Background()); err != nil { + t.Fatalf("Expand: %v", err) + } + if !plant.IsExpanded() { + t.Fatal("plant.IsExpanded = false after Expand, want true") + } + children := plant.Children() + if len(children) != 2 { + t.Fatalf("len(children) = %d, want 2", len(children)) + } + if children[0].Object().GetTagName() != "Area1" { + t.Fatalf("children[0].TagName = %q, want Area1", children[0].Object().GetTagName()) + } + if !children[0].HasChildrenHint() { + t.Fatal("children[0].HasChildrenHint = false, want true") + } + if children[1].HasChildrenHint() { + t.Fatal("children[1].HasChildrenHint = true, want false") + } + if len(fake.browseChildrenCalls) != 2 { + t.Fatalf("BrowseChildren calls = %d, want 2", len(fake.browseChildrenCalls)) + } + parent := fake.browseChildrenCalls[1].GetParent() + parentGobj, ok := parent.(*pb.BrowseChildrenRequest_ParentGobjectId) + if !ok { + t.Fatalf("Parent oneof = %T, want *BrowseChildrenRequest_ParentGobjectId", parent) + } + if parentGobj.ParentGobjectId != 1 { + t.Fatalf("ParentGobjectId = %d, want 1", parentGobj.ParentGobjectId) + } +} + +func TestGalaxyBrowseExpandIdempotentNoSecondRpc(t *testing.T) { + fake := &fakeGalaxyServer{ + browseChildrenReplies: []*pb.BrowseChildrenReply{ + buildBrowseReply( + []*pb.GalaxyObject{obj(1, "Plant", true)}, + []bool{true}, + 1, + ), + buildBrowseReply( + []*pb.GalaxyObject{obj(10, "Area1", true)}, + []bool{false}, + 1, + ), + }, + } + client, cleanup := newGalaxyBufconnClient(t, fake) + defer cleanup() + + roots, err := client.Browse(context.Background(), nil) + if err != nil { + t.Fatalf("Browse: %v", err) + } + plant := roots[0] + if err := plant.Expand(context.Background()); err != nil { + t.Fatalf("Expand #1: %v", err) + } + callsAfterFirst := len(fake.browseChildrenCalls) + if callsAfterFirst != 2 { + t.Fatalf("BrowseChildren calls after first Expand = %d, want 2", callsAfterFirst) + } + if err := plant.Expand(context.Background()); err != nil { + t.Fatalf("Expand #2: %v", err) + } + if got := len(fake.browseChildrenCalls); got != callsAfterFirst { + t.Fatalf("BrowseChildren calls after second Expand = %d, want %d (no extra RPC)", got, callsAfterFirst) + } +} + +func TestGalaxyBrowseExpandUnknownParentReturnsNotFoundError(t *testing.T) { + fake := &fakeGalaxyServer{ + browseChildrenReplies: []*pb.BrowseChildrenReply{ + buildBrowseReply( + []*pb.GalaxyObject{obj(1, "Plant", true)}, + []bool{true}, + 1, + ), + }, + browseChildrenError: status.Error(codes.NotFound, "parent not found"), + } + // The first Browse() consumes the first reply; the next call (Expand) will + // then hit browseChildrenError. We need the error to fire only on the second + // call, so seed the reply first and let the call sequence consume them in + // order. Because BrowseChildren in the fake consumes browseChildrenError + // before falling through to replies, swap the strategy: keep the root reply + // but have BrowseChildren return the error on the second call. We do this by + // emptying the reply list after the first Browse. + client, cleanup := newGalaxyBufconnClient(t, fake) + defer cleanup() + + // First call returns the error (because browseChildrenError takes precedence). + // To avoid that, clear it for the root call by performing a manual setup: we + // pre-stage replies first, then set the error after the first call. Easiest: + // pre-Browse() with error=nil, then set error before Expand. + fake.browseChildrenError = nil + roots, err := client.Browse(context.Background(), nil) + if err != nil { + t.Fatalf("Browse: %v", err) + } + if len(roots) != 1 { + t.Fatalf("len(roots) = %d, want 1", len(roots)) + } + fake.browseChildrenError = status.Error(codes.NotFound, "parent not found") + + err = roots[0].Expand(context.Background()) + if err == nil { + t.Fatal("Expand: error = nil, want NotFound") + } + if status.Code(err) != codes.NotFound { + t.Fatalf("status.Code = %s, want NotFound", status.Code(err)) + } + if roots[0].IsExpanded() { + t.Fatal("roots[0].IsExpanded = true after failed Expand, want false") + } +} + +func TestGalaxyBrowseExpandMultiPageGathersAllPages(t *testing.T) { + firstPage := buildBrowseReply( + []*pb.GalaxyObject{obj(1, "Plant", true)}, + []bool{true}, + 7, + ) + + pageA := buildBrowseReply( + []*pb.GalaxyObject{obj(10, "Child1", false), obj(11, "Child2", false)}, + []bool{false, false}, + 7, + ) + pageA.NextPageToken = "7:abc:2" + pageB := buildBrowseReply( + []*pb.GalaxyObject{obj(12, "Child3", false)}, + []bool{false}, + 7, + ) + + fake := &fakeGalaxyServer{ + browseChildrenReplies: []*pb.BrowseChildrenReply{firstPage, pageA, pageB}, + } + client, cleanup := newGalaxyBufconnClient(t, fake) + defer cleanup() + + roots, err := client.Browse(context.Background(), nil) + if err != nil { + t.Fatalf("Browse: %v", err) + } + if err := roots[0].Expand(context.Background()); err != nil { + t.Fatalf("Expand: %v", err) + } + children := roots[0].Children() + if len(children) != 3 { + t.Fatalf("len(children) = %d, want 3", len(children)) + } + if len(fake.browseChildrenCalls) != 3 { + t.Fatalf("BrowseChildren calls = %d, want 3", len(fake.browseChildrenCalls)) + } + if got := fake.browseChildrenCalls[2].GetPageToken(); got != "7:abc:2" { + t.Fatalf("third call PageToken = %q, want %q", got, "7:abc:2") + } +} + +func TestGalaxyBrowseWithFilterForwardsToRequest(t *testing.T) { + fake := &fakeGalaxyServer{ + browseChildrenReplies: []*pb.BrowseChildrenReply{ + buildBrowseReply(nil, nil, 1), + }, + } + client, cleanup := newGalaxyBufconnClient(t, fake) + defer cleanup() + + include := true + opts := &BrowseChildrenOptions{ + CategoryIds: []int32{7, 9}, + TemplateChainContains: []string{"$AppObject"}, + TagNameGlob: "Tank*", + IncludeAttributes: &include, + AlarmBearingOnly: true, + HistorizedOnly: true, + } + if _, err := client.Browse(context.Background(), opts); err != nil { + t.Fatalf("Browse: %v", err) + } + if len(fake.browseChildrenCalls) != 1 { + t.Fatalf("BrowseChildren calls = %d, want 1", len(fake.browseChildrenCalls)) + } + got := fake.browseChildrenCalls[0] + if want := []int32{7, 9}; len(got.GetCategoryIds()) != 2 || got.GetCategoryIds()[0] != want[0] || got.GetCategoryIds()[1] != want[1] { + t.Fatalf("CategoryIds = %v, want %v", got.GetCategoryIds(), want) + } + if want := []string{"$AppObject"}; len(got.GetTemplateChainContains()) != 1 || got.GetTemplateChainContains()[0] != want[0] { + t.Fatalf("TemplateChainContains = %v, want %v", got.GetTemplateChainContains(), want) + } + if got.GetTagNameGlob() != "Tank*" { + t.Fatalf("TagNameGlob = %q, want %q", got.GetTagNameGlob(), "Tank*") + } + if !got.GetIncludeAttributes() { + t.Fatal("IncludeAttributes = false, want true") + } + if !got.GetAlarmBearingOnly() { + t.Fatal("AlarmBearingOnly = false, want true") + } + if !got.GetHistorizedOnly() { + t.Fatal("HistorizedOnly = false, want true") + } +} diff --git a/clients/go/mxgateway/options.go b/clients/go/mxgateway/options.go index 30a8c23..12b0e34 100644 --- a/clients/go/mxgateway/options.go +++ b/clients/go/mxgateway/options.go @@ -36,6 +36,28 @@ type Options struct { DialOptions []grpc.DialOption } +// BrowseChildrenOptions configures lazy Galaxy hierarchy walks performed by +// (*GalaxyClient).Browse and (*LazyBrowseNode).Expand. All fields are optional; +// the zero value matches the dashboard default (no filters, all attributes per +// the server default). +type BrowseChildrenOptions struct { + // CategoryIds restricts results to the listed Galaxy category ids when set. + CategoryIds []int32 + // TemplateChainContains restricts results to objects whose template chain + // contains any of the listed template tag names. + TemplateChainContains []string + // TagNameGlob restricts results to objects whose tag name matches the glob + // pattern when non-empty. + TagNameGlob string + // IncludeAttributes overrides the server default for attribute inclusion when + // non-nil. The pointer form mirrors the proto's optional field. + IncludeAttributes *bool + // AlarmBearingOnly limits results to alarm-bearing objects when true. + AlarmBearingOnly bool + // HistorizedOnly limits results to historized objects when true. + HistorizedOnly bool +} + // RedactedAPIKey returns a display-safe representation of the configured API // key for diagnostics and CLI output. func (o Options) RedactedAPIKey() string {