From d543679044311a73875efcb7f473565a2cf486f5 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 29 Apr 2026 10:39:49 -0400 Subject: [PATCH] Fix runtime review findings --- .../FakeGalaxyRepositoryTransport.cs | 7 +- .../GalaxyRepositoryClientTests.cs | 25 +- .../MxGatewayClientCliTests.cs | 4 +- .../GalaxyRepositoryClient.cs | 27 +- .../MxGateway.Client/MxGatewayClient.cs | 2 + .../MxGatewayClientOptions.cs | 9 + .../generated/galaxy_repository.pb.go | 58 +- clients/go/mxgateway/client.go | 12 + clients/go/mxgateway/galaxy.go | 26 +- clients/go/mxgateway/galaxy_test.go | 27 +- clients/go/mxgateway/options.go | 1 + clients/go/mxgateway/version.go | 2 +- .../mxgateway/cli/MxGatewayCliTests.java | 4 +- .../mxgateway/client/DeployEventStream.java | 10 +- .../client/GalaxyRepositoryClient.java | 36 +- .../mxgateway/client/MxGatewayClient.java | 2 +- .../client/MxGatewayClientOptions.java | 17 + .../client/MxGatewayClientVersion.java | 2 +- .../client/GalaxyRepositoryClientTests.java | 121 ++- .../v1/GalaxyRepositoryOuterClass.java | 738 ++++++++++++++++-- .../descriptors/mxaccessgw-client-v1.protoset | Bin 62955 -> 63627 bytes clients/proto/fixtures/behavior/manifest.json | 2 +- .../golden/open-session-reply.ok.json | 2 +- .../parity/parity-fixture-matrix.json | 2 +- clients/proto/proto-inputs.json | 2 +- clients/python/src/mxgateway/galaxy.py | 23 +- .../generated/galaxy_repository_pb2.py | 28 +- clients/python/src/mxgateway/options.py | 11 +- clients/python/tests/test_auth_options.py | 26 +- clients/python/tests/test_galaxy.py | 11 + clients/rust/crates/mxgw-cli/src/main.rs | 2 +- clients/rust/src/client.rs | 5 +- clients/rust/src/galaxy.rs | 132 +++- clients/rust/src/options.rs | 14 + clients/rust/src/version.rs | 2 +- docs/GalaxyRepository.md | 47 +- docs/GatewayConfiguration.md | 8 +- docs/Grpc.md | 5 + docs/Sessions.md | 4 +- .../GatewayContractInfo.cs | 2 +- .../Generated/GalaxyRepository.cs | 237 +++++- .../Protos/galaxy_repository.proto | 12 +- .../EffectiveProtocolConfiguration.cs | 4 +- .../EffectiveSessionConfiguration.cs | 3 + .../GatewayConfigurationProvider.cs | 7 +- .../Configuration/GatewayOptionsValidator.cs | 14 + .../Configuration/ProtocolOptions.cs | 2 + .../Configuration/SessionOptions.cs | 4 + .../Components/Pages/GalaxyPage.razor | 6 +- .../DashboardConnectionStringDisplay.cs | 28 + .../Dashboard/DashboardGalaxyProjector.cs | 90 +-- .../Galaxy/GalaxyHierarchyCache.cs | 141 +++- .../Galaxy/GalaxyHierarchyCacheEntry.cs | 15 +- .../Grpc/GalaxyRepositoryGrpcService.cs | 68 +- ...uthorizationServiceCollectionExtensions.cs | 11 + .../Sessions/GatewaySession.cs | 38 +- .../SessionLeaseMonitorHostedService.cs | 45 ++ .../Sessions/SessionManager.cs | 2 + .../SessionServiceCollectionExtensions.cs | 1 + src/MxGateway.Server/Workers/WorkerClient.cs | 39 +- src/MxGateway.Server/appsettings.json | 6 +- .../Configuration/GatewayOptionsTests.cs | 12 +- .../Contracts/GatewayContractInfoTests.cs | 4 +- .../Galaxy/GalaxyHierarchyCacheTests.cs | 2 +- .../DashboardConnectionStringDisplayTests.cs | 21 + .../DashboardSnapshotServiceTests.cs | 32 +- .../Grpc/GalaxyRepositoryGrpcServiceTests.cs | 170 ++++ .../Gateway/Sessions/SessionManagerTests.cs | 76 +- .../Gateway/Workers/WorkerClientTests.cs | 94 ++- 69 files changed, 2233 insertions(+), 409 deletions(-) create mode 100644 src/MxGateway.Server/Dashboard/DashboardConnectionStringDisplay.cs create mode 100644 src/MxGateway.Server/Sessions/SessionLeaseMonitorHostedService.cs create mode 100644 src/MxGateway.Tests/Gateway/Dashboard/DashboardConnectionStringDisplayTests.cs create mode 100644 src/MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryGrpcServiceTests.cs diff --git a/clients/dotnet/MxGateway.Client.Tests/FakeGalaxyRepositoryTransport.cs b/clients/dotnet/MxGateway.Client.Tests/FakeGalaxyRepositoryTransport.cs index 8053aba..5146d30 100644 --- a/clients/dotnet/MxGateway.Client.Tests/FakeGalaxyRepositoryTransport.cs +++ b/clients/dotnet/MxGateway.Client.Tests/FakeGalaxyRepositoryTransport.cs @@ -21,6 +21,8 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio public DiscoverHierarchyReply DiscoverHierarchyReply { get; set; } = new(); + public Queue DiscoverHierarchyReplies { get; } = new(); + public Queue TestConnectionExceptions { get; } = new(); public Queue GetLastDeployTimeExceptions { get; } = new(); @@ -63,7 +65,10 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio throw exception; } - return Task.FromResult(DiscoverHierarchyReply); + return Task.FromResult( + DiscoverHierarchyReplies.TryDequeue(out DiscoverHierarchyReply? reply) + ? reply + : DiscoverHierarchyReply); } public List<(WatchDeployEventsRequest Request, CallOptions CallOptions)> WatchDeployEventsCalls { get; } = []; diff --git a/clients/dotnet/MxGateway.Client.Tests/GalaxyRepositoryClientTests.cs b/clients/dotnet/MxGateway.Client.Tests/GalaxyRepositoryClientTests.cs index 4ffd875..f55effe 100644 --- a/clients/dotnet/MxGateway.Client.Tests/GalaxyRepositoryClientTests.cs +++ b/clients/dotnet/MxGateway.Client.Tests/GalaxyRepositoryClientTests.cs @@ -68,8 +68,10 @@ public sealed class GalaxyRepositoryClientTests public async Task DiscoverHierarchyAsync_ReturnsObjectsFromReply() { FakeGalaxyRepositoryTransport transport = CreateTransport(); - transport.DiscoverHierarchyReply = new DiscoverHierarchyReply + transport.DiscoverHierarchyReplies.Enqueue(new DiscoverHierarchyReply { + NextPageToken = "page-2", + TotalObjectCount = 2, Objects = { new GalaxyObject @@ -91,12 +93,29 @@ public sealed class GalaxyRepositoryClientTests }, }, }, - }; + }); + transport.DiscoverHierarchyReplies.Enqueue(new DiscoverHierarchyReply + { + TotalObjectCount = 2, + Objects = + { + new GalaxyObject + { + GobjectId = 13, + TagName = "DelmiaReceiver_002", + }, + }, + }); await using GalaxyRepositoryClient client = CreateClient(transport); IReadOnlyList objects = await client.DiscoverHierarchyAsync(); - GalaxyObject obj = Assert.Single(objects); + Assert.Equal(2, objects.Count); + Assert.Equal(2, transport.DiscoverHierarchyCalls.Count); + Assert.Equal(5000, transport.DiscoverHierarchyCalls[0].Request.PageSize); + Assert.Equal("", transport.DiscoverHierarchyCalls[0].Request.PageToken); + Assert.Equal("page-2", transport.DiscoverHierarchyCalls[1].Request.PageToken); + GalaxyObject obj = objects[0]; Assert.Equal(12, obj.GobjectId); Assert.Equal("DelmiaReceiver_001", obj.TagName); GalaxyAttribute attribute = Assert.Single(obj.Attributes); diff --git a/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientCliTests.cs b/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientCliTests.cs index 5eda13d..80552d9 100644 --- a/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientCliTests.cs +++ b/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientCliTests.cs @@ -16,7 +16,7 @@ public sealed class MxGatewayClientCliTests var exitCode = MxGatewayClientCli.Run(["version"], output, error); Assert.Equal(0, exitCode); - Assert.Contains("gateway-protocol=1", output.ToString()); + Assert.Contains("gateway-protocol=2", output.ToString()); Assert.Contains("worker-protocol=1", output.ToString()); Assert.Equal(string.Empty, error.ToString()); } @@ -30,7 +30,7 @@ public sealed class MxGatewayClientCliTests int exitCode = await MxGatewayClientCli.RunAsync(["version", "--json"], output, error); Assert.Equal(0, exitCode); - Assert.Contains("\"gatewayProtocolVersion\":1", output.ToString()); + Assert.Contains("\"gatewayProtocolVersion\":2", output.ToString()); Assert.Equal(string.Empty, error.ToString()); } diff --git a/clients/dotnet/MxGateway.Client/GalaxyRepositoryClient.cs b/clients/dotnet/MxGateway.Client/GalaxyRepositoryClient.cs index f9568cc..4ec132c 100644 --- a/clients/dotnet/MxGateway.Client/GalaxyRepositoryClient.cs +++ b/clients/dotnet/MxGateway.Client/GalaxyRepositoryClient.cs @@ -18,6 +18,8 @@ namespace MxGateway.Client; /// public sealed class GalaxyRepositoryClient : IAsyncDisposable { + private const int DiscoverHierarchyPageSize = 5000; + private readonly GrpcChannel? _channel; private readonly IGalaxyRepositoryClientTransport _transport; private readonly ResiliencePipeline _safeUnaryRetryPipeline; @@ -68,6 +70,8 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable { HttpHandler = handler, LoggerFactory = options.LoggerFactory, + MaxReceiveMessageSize = options.MaxGrpcMessageBytes, + MaxSendMessageSize = options.MaxGrpcMessageBytes, }); return new GalaxyRepositoryClient( @@ -141,12 +145,25 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable /// public async Task> DiscoverHierarchyAsync(CancellationToken cancellationToken = default) { - DiscoverHierarchyReply reply = await DiscoverHierarchyRawAsync( - new DiscoverHierarchyRequest(), - cancellationToken) - .ConfigureAwait(false); + List objects = []; + string pageToken = string.Empty; + do + { + DiscoverHierarchyReply reply = await DiscoverHierarchyRawAsync( + new DiscoverHierarchyRequest + { + PageSize = DiscoverHierarchyPageSize, + PageToken = pageToken, + }, + cancellationToken) + .ConfigureAwait(false); - return reply.Objects; + objects.AddRange(reply.Objects); + pageToken = reply.NextPageToken; + } + while (!string.IsNullOrWhiteSpace(pageToken)); + + return objects; } public Task DiscoverHierarchyRawAsync( diff --git a/clients/dotnet/MxGateway.Client/MxGatewayClient.cs b/clients/dotnet/MxGateway.Client/MxGatewayClient.cs index 1ca109b..0edb7c5 100644 --- a/clients/dotnet/MxGateway.Client/MxGatewayClient.cs +++ b/clients/dotnet/MxGateway.Client/MxGatewayClient.cs @@ -64,6 +64,8 @@ public sealed class MxGatewayClient : IAsyncDisposable { HttpHandler = handler, LoggerFactory = options.LoggerFactory, + MaxReceiveMessageSize = options.MaxGrpcMessageBytes, + MaxSendMessageSize = options.MaxGrpcMessageBytes, }); return new MxGatewayClient( diff --git a/clients/dotnet/MxGateway.Client/MxGatewayClientOptions.cs b/clients/dotnet/MxGateway.Client/MxGatewayClientOptions.cs index 760fa8d..3407534 100644 --- a/clients/dotnet/MxGateway.Client/MxGatewayClientOptions.cs +++ b/clients/dotnet/MxGateway.Client/MxGatewayClientOptions.cs @@ -23,6 +23,8 @@ public sealed class MxGatewayClientOptions public TimeSpan? StreamTimeout { get; init; } + public int MaxGrpcMessageBytes { get; init; } = 16 * 1024 * 1024; + public MxGatewayClientRetryOptions Retry { get; init; } = new(); public ILoggerFactory? LoggerFactory { get; init; } @@ -66,6 +68,13 @@ public sealed class MxGatewayClientOptions "The stream timeout must be greater than zero when configured."); } + if (MaxGrpcMessageBytes <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(MaxGrpcMessageBytes), + "The maximum gRPC message size must be greater than zero."); + } + if (UseTls && Endpoint.Scheme != Uri.UriSchemeHttps) { throw new ArgumentException( diff --git a/clients/go/internal/generated/galaxy_repository.pb.go b/clients/go/internal/generated/galaxy_repository.pb.go index 05776aa..20e9ea8 100644 --- a/clients/go/internal/generated/galaxy_repository.pb.go +++ b/clients/go/internal/generated/galaxy_repository.pb.go @@ -191,7 +191,12 @@ func (x *GetLastDeployTimeReply) GetTimeOfLastDeploy() *timestamppb.Timestamp { } type DiscoverHierarchyRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` + state protoimpl.MessageState `protogen:"open.v1"` + // Maximum number of objects to return. The server applies its default when + // unset and rejects non-positive values. + PageSize int32 `protobuf:"varint,1,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"` + // Opaque token returned by a previous DiscoverHierarchy response. + PageToken string `protobuf:"bytes,2,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -226,11 +231,29 @@ func (*DiscoverHierarchyRequest) Descriptor() ([]byte, []int) { return file_galaxy_repository_proto_rawDescGZIP(), []int{4} } +func (x *DiscoverHierarchyRequest) GetPageSize() int32 { + if x != nil { + return x.PageSize + } + return 0 +} + +func (x *DiscoverHierarchyRequest) GetPageToken() string { + if x != nil { + return x.PageToken + } + return "" +} + type DiscoverHierarchyReply struct { - state protoimpl.MessageState `protogen:"open.v1"` - Objects []*GalaxyObject `protobuf:"bytes,1,rep,name=objects,proto3" json:"objects,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Objects []*GalaxyObject `protobuf:"bytes,1,rep,name=objects,proto3" json:"objects,omitempty"` + // Non-empty when another page is available. + NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"` + // Total number of objects in the cached hierarchy at the time of the call. + TotalObjectCount int32 `protobuf:"varint,3,opt,name=total_object_count,json=totalObjectCount,proto3" json:"total_object_count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *DiscoverHierarchyReply) Reset() { @@ -270,6 +293,20 @@ func (x *DiscoverHierarchyReply) GetObjects() []*GalaxyObject { return nil } +func (x *DiscoverHierarchyReply) GetNextPageToken() string { + if x != nil { + return x.NextPageToken + } + return "" +} + +func (x *DiscoverHierarchyReply) GetTotalObjectCount() int32 { + if x != nil { + return x.TotalObjectCount + } + return 0 +} + type WatchDeployEventsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // Optional. When set, the bootstrap event is suppressed if the cached deploy @@ -654,10 +691,15 @@ const file_galaxy_repository_proto_rawDesc = "" + "\x18GetLastDeployTimeRequest\"}\n" + "\x16GetLastDeployTimeReply\x12\x18\n" + "\apresent\x18\x01 \x01(\bR\apresent\x12I\n" + - "\x13time_of_last_deploy\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\x10timeOfLastDeploy\"\x1a\n" + - "\x18DiscoverHierarchyRequest\"V\n" + + "\x13time_of_last_deploy\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\x10timeOfLastDeploy\"V\n" + + "\x18DiscoverHierarchyRequest\x12\x1b\n" + + "\tpage_size\x18\x01 \x01(\x05R\bpageSize\x12\x1d\n" + + "\n" + + "page_token\x18\x02 \x01(\tR\tpageToken\"\xac\x01\n" + "\x16DiscoverHierarchyReply\x12<\n" + - "\aobjects\x18\x01 \x03(\v2\".galaxy_repository.v1.GalaxyObjectR\aobjects\"i\n" + + "\aobjects\x18\x01 \x03(\v2\".galaxy_repository.v1.GalaxyObjectR\aobjects\x12&\n" + + "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\x12,\n" + + "\x12total_object_count\x18\x03 \x01(\x05R\x10totalObjectCount\"i\n" + "\x18WatchDeployEventsRequest\x12M\n" + "\x15last_seen_deploy_time\x18\x01 \x01(\v2\x1a.google.protobuf.TimestampR\x12lastSeenDeployTime\"\xbb\x02\n" + "\vDeployEvent\x12\x1a\n" + diff --git a/clients/go/mxgateway/client.go b/clients/go/mxgateway/client.go index 439d655..11229d2 100644 --- a/clients/go/mxgateway/client.go +++ b/clients/go/mxgateway/client.go @@ -16,6 +16,7 @@ import ( const ( defaultDialTimeout = 10 * time.Second defaultCallTimeout = 30 * time.Second + defaultMaxGrpcMessageBytes = 16 * 1024 * 1024 ) // Client owns a gateway gRPC connection and exposes session-oriented helpers. @@ -50,6 +51,10 @@ func Dial(ctx context.Context, opts Options) (*Client, error) { grpc.WithTransportCredentials(transportCredentials), grpc.WithUnaryInterceptor(unaryAuthInterceptor(opts.APIKey)), grpc.WithStreamInterceptor(streamAuthInterceptor(opts.APIKey)), + grpc.WithDefaultCallOptions( + grpc.MaxCallRecvMsgSize(resolveMaxGrpcMessageBytes(opts)), + grpc.MaxCallSendMsgSize(resolveMaxGrpcMessageBytes(opts)), + ), grpc.WithBlock(), } dialOptions = append(dialOptions, opts.DialOptions...) @@ -62,6 +67,13 @@ func Dial(ctx context.Context, opts Options) (*Client, error) { return NewClient(conn, opts), nil } +func resolveMaxGrpcMessageBytes(opts Options) int { + if opts.MaxGrpcMessageBytes > 0 { + return opts.MaxGrpcMessageBytes + } + return defaultMaxGrpcMessageBytes +} + // NewClient wraps an existing gRPC connection. The caller owns closing conn // unless it calls Close on the returned Client. func NewClient(conn *grpc.ClientConn, opts Options) *Client { diff --git a/clients/go/mxgateway/galaxy.go b/clients/go/mxgateway/galaxy.go index 949bb6c..d75d46a 100644 --- a/clients/go/mxgateway/galaxy.go +++ b/clients/go/mxgateway/galaxy.go @@ -13,6 +13,8 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" ) +const discoverHierarchyPageSize = 5000 + // RawGalaxyRepositoryClient is the generated gRPC client interface for the // Galaxy Repository service exposed for callers that need direct contract // access. @@ -70,6 +72,10 @@ func DialGalaxy(ctx context.Context, opts Options) (*GalaxyClient, error) { grpc.WithTransportCredentials(transportCredentials), grpc.WithUnaryInterceptor(unaryAuthInterceptor(opts.APIKey)), grpc.WithStreamInterceptor(streamAuthInterceptor(opts.APIKey)), + grpc.WithDefaultCallOptions( + grpc.MaxCallRecvMsgSize(resolveMaxGrpcMessageBytes(opts)), + grpc.MaxCallSendMsgSize(resolveMaxGrpcMessageBytes(opts)), + ), grpc.WithBlock(), } dialOptions = append(dialOptions, opts.DialOptions...) @@ -141,11 +147,23 @@ func (c *GalaxyClient) DiscoverHierarchy(ctx context.Context) ([]*GalaxyObject, callCtx, cancel := c.callContext(ctx) defer cancel() - reply, err := c.raw.DiscoverHierarchy(callCtx, &pb.DiscoverHierarchyRequest{}) - if err != nil { - return nil, &GatewayError{Op: "galaxy discover hierarchy", Err: err} + var objects []*GalaxyObject + pageToken := "" + for { + reply, err := c.raw.DiscoverHierarchy(callCtx, &pb.DiscoverHierarchyRequest{ + PageSize: discoverHierarchyPageSize, + PageToken: pageToken, + }) + if err != nil { + return nil, &GatewayError{Op: "galaxy discover hierarchy", Err: err} + } + objects = append(objects, reply.GetObjects()...) + pageToken = reply.GetNextPageToken() + if pageToken == "" { + break + } } - return reply.GetObjects(), nil + return objects, nil } // WatchDeployEventsRaw starts the generated WatchDeployEvents stream for callers diff --git a/clients/go/mxgateway/galaxy_test.go b/clients/go/mxgateway/galaxy_test.go index bffe014..be4124f 100644 --- a/clients/go/mxgateway/galaxy_test.go +++ b/clients/go/mxgateway/galaxy_test.go @@ -95,7 +95,9 @@ func TestGalaxyGetLastDeployTimeReturnsAbsentWhenTimestampNil(t *testing.T) { func TestGalaxyDiscoverHierarchyReturnsObjects(t *testing.T) { fake := &fakeGalaxyServer{ - discoverReply: &pb.DiscoverHierarchyReply{ + discoverReplies: []*pb.DiscoverHierarchyReply{{ + NextPageToken: "page-2", + TotalObjectCount: 2, Objects: []*pb.GalaxyObject{ { GobjectId: 1, @@ -114,6 +116,10 @@ func TestGalaxyDiscoverHierarchyReturnsObjects(t *testing.T) { }, }, }, + }, + }, { + TotalObjectCount: 2, + Objects: []*pb.GalaxyObject{ { GobjectId: 2, TagName: "TestMachine_002", @@ -121,7 +127,7 @@ func TestGalaxyDiscoverHierarchyReturnsObjects(t *testing.T) { ParentGobjectId: 1, }, }, - }, + }}, } client, cleanup := newGalaxyBufconnClient(t, fake) defer cleanup() @@ -133,6 +139,15 @@ func TestGalaxyDiscoverHierarchyReturnsObjects(t *testing.T) { if len(objects) != 2 { t.Fatalf("len(objects) = %d, want 2", len(objects)) } + if len(fake.discoverRequests) != 2 { + t.Fatalf("len(discoverRequests) = %d, want 2", len(fake.discoverRequests)) + } + if fake.discoverRequests[0].GetPageSize() != 5000 || fake.discoverRequests[0].GetPageToken() != "" { + t.Fatalf("first request = %+v", fake.discoverRequests[0]) + } + if fake.discoverRequests[1].GetPageToken() != "page-2" { + t.Fatalf("second page_token = %q, want page-2", fake.discoverRequests[1].GetPageToken()) + } if objects[0].GetTagName() != "TestMachine_001" { t.Fatalf("objects[0].TagName = %q", objects[0].GetTagName()) } @@ -375,6 +390,8 @@ type fakeGalaxyServer struct { failTest bool deployReply *pb.GetLastDeployTimeReply discoverReply *pb.DiscoverHierarchyReply + discoverReplies []*pb.DiscoverHierarchyReply + discoverRequests []*pb.DiscoverHierarchyRequest watchEvents []*pb.DeployEvent watchRequest *pb.WatchDeployEventsRequest watchSendInterval time.Duration @@ -400,6 +417,12 @@ func (s *fakeGalaxyServer) GetLastDeployTime(ctx context.Context, req *pb.GetLas } func (s *fakeGalaxyServer) DiscoverHierarchy(ctx context.Context, req *pb.DiscoverHierarchyRequest) (*pb.DiscoverHierarchyReply, error) { + s.discoverRequests = append(s.discoverRequests, req) + if len(s.discoverReplies) > 0 { + reply := s.discoverReplies[0] + s.discoverReplies = s.discoverReplies[1:] + return reply, nil + } if s.discoverReply != nil { return s.discoverReply, nil } diff --git a/clients/go/mxgateway/options.go b/clients/go/mxgateway/options.go index 782634d..9c84ba8 100644 --- a/clients/go/mxgateway/options.go +++ b/clients/go/mxgateway/options.go @@ -18,6 +18,7 @@ type Options struct { ServerNameOverride string DialTimeout time.Duration CallTimeout time.Duration + MaxGrpcMessageBytes int TLSConfig *tls.Config TransportCredentials credentials.TransportCredentials DialOptions []grpc.DialOption diff --git a/clients/go/mxgateway/version.go b/clients/go/mxgateway/version.go index 66fd475..923b20d 100644 --- a/clients/go/mxgateway/version.go +++ b/clients/go/mxgateway/version.go @@ -7,7 +7,7 @@ const ( // GatewayProtocolVersion matches GatewayContractInfo.GatewayProtocolVersion // in the shared .NET contracts. - GatewayProtocolVersion uint32 = 1 + GatewayProtocolVersion uint32 = 2 // WorkerProtocolVersion matches GatewayContractInfo.WorkerProtocolVersion // and is exposed for fake-worker and parity tests. diff --git a/clients/java/mxgateway-cli/src/test/java/com/dohertylan/mxgateway/cli/MxGatewayCliTests.java b/clients/java/mxgateway-cli/src/test/java/com/dohertylan/mxgateway/cli/MxGatewayCliTests.java index 6f047fb..97023f6 100644 --- a/clients/java/mxgateway-cli/src/test/java/com/dohertylan/mxgateway/cli/MxGatewayCliTests.java +++ b/clients/java/mxgateway-cli/src/test/java/com/dohertylan/mxgateway/cli/MxGatewayCliTests.java @@ -32,7 +32,7 @@ final class MxGatewayCliTests { assertEquals(0, run.exitCode()); assertEquals("", run.errors()); assertTrue(run.output().contains("mxgateway-java 0.1.0")); - assertTrue(run.output().contains("gatewayProtocolVersion=1")); + assertTrue(run.output().contains("gatewayProtocolVersion=2")); assertTrue(run.output().contains("workerProtocolVersion=1")); } @@ -42,7 +42,7 @@ final class MxGatewayCliTests { assertEquals(0, run.exitCode()); assertTrue(run.output().contains("\"clientVersion\":\"0.1.0\"")); - assertTrue(run.output().contains("\"gatewayProtocolVersion\":1")); + assertTrue(run.output().contains("\"gatewayProtocolVersion\":2")); } @Test diff --git a/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/DeployEventStream.java b/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/DeployEventStream.java index 5d09b89..41d41b6 100644 --- a/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/DeployEventStream.java +++ b/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/DeployEventStream.java @@ -11,6 +11,7 @@ import java.util.NoSuchElementException; import java.util.Objects; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; +import java.util.concurrent.atomic.AtomicBoolean; /** * Iterator-style adaptor over the {@code WatchDeployEvents} server-streaming @@ -22,8 +23,8 @@ public final class DeployEventStream implements Iterator, AutoClose private static final Object END = new Object(); private final BlockingQueue queue; + private final AtomicBoolean closed = new AtomicBoolean(); private volatile ClientCallStreamObserver requestStream; - private volatile boolean closed; private Object next; DeployEventStream(int capacity) { @@ -35,6 +36,9 @@ public final class DeployEventStream implements Iterator, AutoClose @Override public void beforeStart(ClientCallStreamObserver requestStream) { DeployEventStream.this.requestStream = requestStream; + if (closed.get()) { + requestStream.cancel("client cancelled deploy event stream", null); + } } @Override @@ -44,7 +48,7 @@ public final class DeployEventStream implements Iterator, AutoClose @Override public void onError(Throwable error) { - if (Status.fromThrowable(error).getCode() == Status.Code.CANCELLED && closed) { + if (Status.fromThrowable(error).getCode() == Status.Code.CANCELLED && closed.get()) { offer(END); return; } @@ -90,7 +94,7 @@ public final class DeployEventStream implements Iterator, AutoClose @Override public void close() { - closed = true; + closed.set(true); ClientCallStreamObserver stream = requestStream; if (stream != null) { stream.cancel("client cancelled deploy event stream", null); diff --git a/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/GalaxyRepositoryClient.java b/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/GalaxyRepositoryClient.java index 9e142d4..d1295bd 100644 --- a/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/GalaxyRepositoryClient.java +++ b/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/GalaxyRepositoryClient.java @@ -36,6 +36,8 @@ import javax.net.ssl.SSLException; * {@link MxGatewayClient}. */ public final class GalaxyRepositoryClient implements AutoCloseable { + private static final int DISCOVER_HIERARCHY_PAGE_SIZE = 5000; + private final ManagedChannel ownedChannel; private final MxGatewayClientOptions options; private final GalaxyRepositoryGrpc.GalaxyRepositoryBlockingStub blockingStub; @@ -130,9 +132,17 @@ public final class GalaxyRepositoryClient implements AutoCloseable { */ public List discoverHierarchy() { try { - DiscoverHierarchyReply reply = - rawBlockingStub().discoverHierarchy(DiscoverHierarchyRequest.getDefaultInstance()); - return reply.getObjectsList(); + java.util.ArrayList objects = new java.util.ArrayList<>(); + String pageToken = ""; + do { + DiscoverHierarchyReply reply = rawBlockingStub().discoverHierarchy(DiscoverHierarchyRequest.newBuilder() + .setPageSize(DISCOVER_HIERARCHY_PAGE_SIZE) + .setPageToken(pageToken) + .build()); + objects.addAll(reply.getObjectsList()); + pageToken = reply.getNextPageToken(); + } while (!pageToken.isBlank()); + return objects; } catch (RuntimeException error) { if (error instanceof MxGatewayException) { throw error; @@ -142,8 +152,7 @@ public final class GalaxyRepositoryClient implements AutoCloseable { } public CompletableFuture> discoverHierarchyAsync() { - return toCompletable(rawFutureStub().discoverHierarchy(DiscoverHierarchyRequest.getDefaultInstance())) - .thenApply(DiscoverHierarchyReply::getObjectsList); + return discoverHierarchyPageAsync("", new java.util.ArrayList<>()); } /** @@ -226,7 +235,7 @@ public final class GalaxyRepositoryClient implements AutoCloseable { private static ManagedChannel createChannel(MxGatewayClientOptions options) { NettyChannelBuilder builder = NettyChannelBuilder.forTarget(options.endpoint()) - .maxInboundMessageSize(16 * 1024 * 1024); + .maxInboundMessageSize(options.maxGrpcMessageBytes()); if (!options.connectTimeout().isNegative()) { builder.withOption( io.grpc.netty.shaded.io.netty.channel.ChannelOption.CONNECT_TIMEOUT_MILLIS, @@ -258,6 +267,21 @@ public final class GalaxyRepositoryClient implements AutoCloseable { return stub.withDeadlineAfter(options.callTimeout().toNanos(), TimeUnit.NANOSECONDS); } + private CompletableFuture> discoverHierarchyPageAsync( + String pageToken, java.util.ArrayList objects) { + DiscoverHierarchyRequest request = DiscoverHierarchyRequest.newBuilder() + .setPageSize(DISCOVER_HIERARCHY_PAGE_SIZE) + .setPageToken(pageToken) + .build(); + return toCompletable(rawFutureStub().discoverHierarchy(request)).thenCompose(reply -> { + objects.addAll(reply.getObjectsList()); + if (reply.getNextPageToken().isBlank()) { + return CompletableFuture.completedFuture(objects); + } + return discoverHierarchyPageAsync(reply.getNextPageToken(), objects); + }); + } + private static CompletableFuture toCompletable(com.google.common.util.concurrent.ListenableFuture source) { CompletableFuture target = new CompletableFuture<>(); Futures.addCallback( diff --git a/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayClient.java b/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayClient.java index e8dd2e3..ccc5aa1 100644 --- a/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayClient.java +++ b/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayClient.java @@ -169,7 +169,7 @@ public final class MxGatewayClient implements AutoCloseable { private static ManagedChannel createChannel(MxGatewayClientOptions options) { NettyChannelBuilder builder = NettyChannelBuilder.forTarget(options.endpoint()) - .maxInboundMessageSize(16 * 1024 * 1024); + .maxInboundMessageSize(options.maxGrpcMessageBytes()); if (!options.connectTimeout().isNegative()) { builder.withOption( io.grpc.netty.shaded.io.netty.channel.ChannelOption.CONNECT_TIMEOUT_MILLIS, diff --git a/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayClientOptions.java b/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayClientOptions.java index 083403a..e786e77 100644 --- a/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayClientOptions.java +++ b/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayClientOptions.java @@ -7,6 +7,7 @@ import java.util.Objects; public final class MxGatewayClientOptions { private static final Duration DEFAULT_CONNECT_TIMEOUT = Duration.ofSeconds(10); private static final Duration DEFAULT_CALL_TIMEOUT = Duration.ofSeconds(30); + private static final int DEFAULT_MAX_GRPC_MESSAGE_BYTES = 16 * 1024 * 1024; private final String endpoint; private final String apiKey; @@ -16,6 +17,7 @@ public final class MxGatewayClientOptions { private final Duration connectTimeout; private final Duration callTimeout; private final Duration streamTimeout; + private final int maxGrpcMessageBytes; private MxGatewayClientOptions(Builder builder) { endpoint = requireText(builder.endpoint, "endpoint"); @@ -26,6 +28,9 @@ public final class MxGatewayClientOptions { connectTimeout = builder.connectTimeout == null ? DEFAULT_CONNECT_TIMEOUT : builder.connectTimeout; callTimeout = builder.callTimeout == null ? DEFAULT_CALL_TIMEOUT : builder.callTimeout; streamTimeout = builder.streamTimeout; + maxGrpcMessageBytes = builder.maxGrpcMessageBytes <= 0 + ? DEFAULT_MAX_GRPC_MESSAGE_BYTES + : builder.maxGrpcMessageBytes; } public static Builder builder() { @@ -68,6 +73,10 @@ public final class MxGatewayClientOptions { return streamTimeout; } + public int maxGrpcMessageBytes() { + return maxGrpcMessageBytes; + } + @Override public String toString() { return "MxGatewayClientOptions{" @@ -90,6 +99,8 @@ public final class MxGatewayClientOptions { + callTimeout + ", streamTimeout=" + streamTimeout + + ", maxGrpcMessageBytes=" + + maxGrpcMessageBytes + '}'; } @@ -109,6 +120,7 @@ public final class MxGatewayClientOptions { private Duration connectTimeout; private Duration callTimeout; private Duration streamTimeout; + private int maxGrpcMessageBytes; private Builder() { } @@ -153,6 +165,11 @@ public final class MxGatewayClientOptions { return this; } + public Builder maxGrpcMessageBytes(int value) { + maxGrpcMessageBytes = value; + return this; + } + public MxGatewayClientOptions build() { return new MxGatewayClientOptions(this); } diff --git a/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayClientVersion.java b/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayClientVersion.java index d94791d..4bd795e 100644 --- a/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayClientVersion.java +++ b/clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayClientVersion.java @@ -1,7 +1,7 @@ package com.dohertylan.mxgateway.client; public final class MxGatewayClientVersion { - private static final int GATEWAY_PROTOCOL_VERSION = 1; + private static final int GATEWAY_PROTOCOL_VERSION = 2; private static final int WORKER_PROTOCOL_VERSION = 1; private static final String CLIENT_VERSION = "0.1.0"; diff --git a/clients/java/mxgateway-client/src/test/java/com/dohertylan/mxgateway/client/GalaxyRepositoryClientTests.java b/clients/java/mxgateway-client/src/test/java/com/dohertylan/mxgateway/client/GalaxyRepositoryClientTests.java index 3ee0a73..fdd0461 100644 --- a/clients/java/mxgateway-client/src/test/java/com/dohertylan/mxgateway/client/GalaxyRepositoryClientTests.java +++ b/clients/java/mxgateway-client/src/test/java/com/dohertylan/mxgateway/client/GalaxyRepositoryClientTests.java @@ -25,6 +25,8 @@ import io.grpc.ServerCallHandler; import io.grpc.ServerInterceptor; import io.grpc.inprocess.InProcessChannelBuilder; import io.grpc.inprocess.InProcessServerBuilder; +import io.grpc.stub.ClientCallStreamObserver; +import io.grpc.stub.ClientResponseObserver; import io.grpc.stub.StreamObserver; import java.time.Duration; import java.time.Instant; @@ -100,31 +102,44 @@ final class GalaxyRepositoryClientTests { @Test void discoverHierarchyReturnsObjectsAndAttributes() throws Exception { - AtomicReference seenRequest = new AtomicReference<>(); + AtomicReference firstRequest = new AtomicReference<>(); + AtomicReference secondRequest = new AtomicReference<>(); TestService service = new TestService() { @Override public void discoverHierarchy( DiscoverHierarchyRequest request, StreamObserver responseObserver) { - seenRequest.set(request); - responseObserver.onNext(DiscoverHierarchyReply.newBuilder() - .addObjects(GalaxyObject.newBuilder() - .setGobjectId(7) - .setTagName("Pump_001") - .setContainedName("Pump") - .setBrowseName("Pump") - .setParentGobjectId(1) - .setIsArea(false) - .setCategoryId(3) - .setHostedByGobjectId(0) - .addTemplateChain("$Pump") - .addAttributes(GalaxyAttribute.newBuilder() - .setAttributeName("Speed") - .setFullTagReference("Pump_001.Speed") - .setMxDataType(5) - .setDataTypeName("MxFloat") - .setIsArray(false) - .setIsHistorized(true))) - .build()); + if (request.getPageToken().isEmpty()) { + firstRequest.set(request); + responseObserver.onNext(DiscoverHierarchyReply.newBuilder() + .setNextPageToken("page-2") + .setTotalObjectCount(2) + .addObjects(GalaxyObject.newBuilder() + .setGobjectId(7) + .setTagName("Pump_001") + .setContainedName("Pump") + .setBrowseName("Pump") + .setParentGobjectId(1) + .setIsArea(false) + .setCategoryId(3) + .setHostedByGobjectId(0) + .addTemplateChain("$Pump") + .addAttributes(GalaxyAttribute.newBuilder() + .setAttributeName("Speed") + .setFullTagReference("Pump_001.Speed") + .setMxDataType(5) + .setDataTypeName("MxFloat") + .setIsArray(false) + .setIsHistorized(true))) + .build()); + } else { + secondRequest.set(request); + responseObserver.onNext(DiscoverHierarchyReply.newBuilder() + .setTotalObjectCount(2) + .addObjects(GalaxyObject.newBuilder() + .setGobjectId(8) + .setTagName("Pump_002")) + .build()); + } responseObserver.onCompleted(); } }; @@ -132,7 +147,10 @@ final class GalaxyRepositoryClientTests { try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>()); GalaxyRepositoryClient client = g.client("")) { List objects = client.discoverHierarchy(); - assertEquals(1, objects.size()); + assertEquals(2, objects.size()); + assertEquals(5000, firstRequest.get().getPageSize()); + assertEquals("", firstRequest.get().getPageToken()); + assertEquals("page-2", secondRequest.get().getPageToken()); GalaxyObject only = objects.get(0); assertEquals(7, only.getGobjectId()); assertEquals("Pump_001", only.getTagName()); @@ -142,6 +160,20 @@ final class GalaxyRepositoryClientTests { } } + @Test + void deployEventStreamCloseBeforeBeforeStartCancelsStream() { + DeployEventStream stream = new DeployEventStream(4); + ClientResponseObserver observer = stream.observer(); + RecordingClientCallStreamObserver requestStream = new RecordingClientCallStreamObserver(); + + stream.close(); + observer.beforeStart(requestStream); + + assertTrue(requestStream.cancelled); + assertEquals("client cancelled deploy event stream", requestStream.cancelMessage); + assertFalse(stream.hasNext()); + } + @Test void watchDeployEventsReceivesEventsInOrder() throws Exception { DeployEvent first = DeployEvent.newBuilder() @@ -281,6 +313,51 @@ final class GalaxyRepositoryClientTests { } } + private static final class RecordingClientCallStreamObserver + extends ClientCallStreamObserver { + private boolean cancelled; + private String cancelMessage; + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setOnReadyHandler(Runnable onReadyHandler) { + } + + @Override + public void disableAutoInboundFlowControl() { + } + + @Override + public void request(int count) { + } + + @Override + public void setMessageCompression(boolean enable) { + } + + @Override + public void cancel(String message, Throwable cause) { + cancelled = true; + cancelMessage = message; + } + + @Override + public void onNext(WatchDeployEventsRequest value) { + } + + @Override + public void onError(Throwable error) { + } + + @Override + public void onCompleted() { + } + } + private record InProcessGalaxy(Server server, ManagedChannel channel) implements AutoCloseable { static InProcessGalaxy start( GalaxyRepositoryGrpc.GalaxyRepositoryImplBase service, AtomicReference authorization) diff --git a/clients/java/src/main/generated/main/java/galaxy_repository/v1/GalaxyRepositoryOuterClass.java b/clients/java/src/main/generated/main/java/galaxy_repository/v1/GalaxyRepositoryOuterClass.java index 5fc4aa6..9a93cdd 100644 --- a/clients/java/src/main/generated/main/java/galaxy_repository/v1/GalaxyRepositoryOuterClass.java +++ b/clients/java/src/main/generated/main/java/galaxy_repository/v1/GalaxyRepositoryOuterClass.java @@ -1750,7 +1750,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera * .google.protobuf.Timestamp time_of_last_deploy = 2; */ private com.google.protobuf.SingleFieldBuilder< - com.google.protobuf.Timestamp, com.google.protobuf.Timestamp.Builder, com.google.protobuf.TimestampOrBuilder> + com.google.protobuf.Timestamp, com.google.protobuf.Timestamp.Builder, com.google.protobuf.TimestampOrBuilder> internalGetTimeOfLastDeployFieldBuilder() { if (timeOfLastDeployBuilder_ == null) { timeOfLastDeployBuilder_ = new com.google.protobuf.SingleFieldBuilder< @@ -1817,6 +1817,37 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera public interface DiscoverHierarchyRequestOrBuilder extends // @@protoc_insertion_point(interface_extends:galaxy_repository.v1.DiscoverHierarchyRequest) com.google.protobuf.MessageOrBuilder { + + /** + *
+     * Maximum number of objects to return. The server applies its default when
+     * unset and rejects non-positive values.
+     * 
+ * + * int32 page_size = 1; + * @return The pageSize. + */ + int getPageSize(); + + /** + *
+     * Opaque token returned by a previous DiscoverHierarchy response.
+     * 
+ * + * string page_token = 2; + * @return The pageToken. + */ + java.lang.String getPageToken(); + /** + *
+     * Opaque token returned by a previous DiscoverHierarchy response.
+     * 
+ * + * string page_token = 2; + * @return The bytes for pageToken. + */ + com.google.protobuf.ByteString + getPageTokenBytes(); } /** * Protobuf type {@code galaxy_repository.v1.DiscoverHierarchyRequest} @@ -1840,6 +1871,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera super(builder); } private DiscoverHierarchyRequest() { + pageToken_ = ""; } public static final com.google.protobuf.Descriptors.Descriptor @@ -1855,6 +1887,69 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest.class, galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest.Builder.class); } + public static final int PAGE_SIZE_FIELD_NUMBER = 1; + private int pageSize_ = 0; + /** + *
+     * Maximum number of objects to return. The server applies its default when
+     * unset and rejects non-positive values.
+     * 
+ * + * int32 page_size = 1; + * @return The pageSize. + */ + @java.lang.Override + public int getPageSize() { + return pageSize_; + } + + public static final int PAGE_TOKEN_FIELD_NUMBER = 2; + @SuppressWarnings("serial") + private volatile java.lang.Object pageToken_ = ""; + /** + *
+     * Opaque token returned by a previous DiscoverHierarchy response.
+     * 
+ * + * string page_token = 2; + * @return The pageToken. + */ + @java.lang.Override + public java.lang.String getPageToken() { + java.lang.Object ref = pageToken_; + if (ref instanceof java.lang.String) { + return (java.lang.String) ref; + } else { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + pageToken_ = s; + return s; + } + } + /** + *
+     * Opaque token returned by a previous DiscoverHierarchy response.
+     * 
+ * + * string page_token = 2; + * @return The bytes for pageToken. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getPageTokenBytes() { + java.lang.Object ref = pageToken_; + if (ref instanceof java.lang.String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + pageToken_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + private byte memoizedIsInitialized = -1; @java.lang.Override public final boolean isInitialized() { @@ -1869,6 +1964,12 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera @java.lang.Override public void writeTo(com.google.protobuf.CodedOutputStream output) throws java.io.IOException { + if (pageSize_ != 0) { + output.writeInt32(1, pageSize_); + } + if (!com.google.protobuf.GeneratedMessage.isStringEmpty(pageToken_)) { + com.google.protobuf.GeneratedMessage.writeString(output, 2, pageToken_); + } getUnknownFields().writeTo(output); } @@ -1878,6 +1979,13 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera if (size != -1) return size; size = 0; + if (pageSize_ != 0) { + size += com.google.protobuf.CodedOutputStream + .computeInt32Size(1, pageSize_); + } + if (!com.google.protobuf.GeneratedMessage.isStringEmpty(pageToken_)) { + size += com.google.protobuf.GeneratedMessage.computeStringSize(2, pageToken_); + } size += getUnknownFields().getSerializedSize(); memoizedSize = size; return size; @@ -1893,6 +2001,10 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera } galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest other = (galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest) obj; + if (getPageSize() + != other.getPageSize()) return false; + if (!getPageToken() + .equals(other.getPageToken())) return false; if (!getUnknownFields().equals(other.getUnknownFields())) return false; return true; } @@ -1904,6 +2016,10 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera } int hash = 41; hash = (19 * hash) + getDescriptor().hashCode(); + hash = (37 * hash) + PAGE_SIZE_FIELD_NUMBER; + hash = (53 * hash) + getPageSize(); + hash = (37 * hash) + PAGE_TOKEN_FIELD_NUMBER; + hash = (53 * hash) + getPageToken().hashCode(); hash = (29 * hash) + getUnknownFields().hashCode(); memoizedHashCode = hash; return hash; @@ -2034,6 +2150,9 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera @java.lang.Override public Builder clear() { super.clear(); + bitField0_ = 0; + pageSize_ = 0; + pageToken_ = ""; return this; } @@ -2060,10 +2179,21 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera @java.lang.Override public galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest buildPartial() { galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest result = new galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest(this); + if (bitField0_ != 0) { buildPartial0(result); } onBuilt(); return result; } + private void buildPartial0(galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest result) { + int from_bitField0_ = bitField0_; + if (((from_bitField0_ & 0x00000001) != 0)) { + result.pageSize_ = pageSize_; + } + if (((from_bitField0_ & 0x00000002) != 0)) { + result.pageToken_ = pageToken_; + } + } + @java.lang.Override public Builder mergeFrom(com.google.protobuf.Message other) { if (other instanceof galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest) { @@ -2076,6 +2206,14 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera public Builder mergeFrom(galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest other) { if (other == galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest.getDefaultInstance()) return this; + if (other.getPageSize() != 0) { + setPageSize(other.getPageSize()); + } + if (!other.getPageToken().isEmpty()) { + pageToken_ = other.pageToken_; + bitField0_ |= 0x00000002; + onChanged(); + } this.mergeUnknownFields(other.getUnknownFields()); onChanged(); return this; @@ -2102,6 +2240,16 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera case 0: done = true; break; + case 8: { + pageSize_ = input.readInt32(); + bitField0_ |= 0x00000001; + break; + } // case 8 + case 18: { + pageToken_ = input.readStringRequireUtf8(); + bitField0_ |= 0x00000002; + break; + } // case 18 default: { if (!super.parseUnknownField(input, extensionRegistry, tag)) { done = true; // was an endgroup tag @@ -2117,6 +2265,146 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera } // finally return this; } + private int bitField0_; + + private int pageSize_ ; + /** + *
+       * Maximum number of objects to return. The server applies its default when
+       * unset and rejects non-positive values.
+       * 
+ * + * int32 page_size = 1; + * @return The pageSize. + */ + @java.lang.Override + public int getPageSize() { + return pageSize_; + } + /** + *
+       * Maximum number of objects to return. The server applies its default when
+       * unset and rejects non-positive values.
+       * 
+ * + * int32 page_size = 1; + * @param value The pageSize to set. + * @return This builder for chaining. + */ + public Builder setPageSize(int value) { + + pageSize_ = value; + bitField0_ |= 0x00000001; + onChanged(); + return this; + } + /** + *
+       * Maximum number of objects to return. The server applies its default when
+       * unset and rejects non-positive values.
+       * 
+ * + * int32 page_size = 1; + * @return This builder for chaining. + */ + public Builder clearPageSize() { + bitField0_ = (bitField0_ & ~0x00000001); + pageSize_ = 0; + onChanged(); + return this; + } + + private java.lang.Object pageToken_ = ""; + /** + *
+       * Opaque token returned by a previous DiscoverHierarchy response.
+       * 
+ * + * string page_token = 2; + * @return The pageToken. + */ + public java.lang.String getPageToken() { + java.lang.Object ref = pageToken_; + if (!(ref instanceof java.lang.String)) { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + pageToken_ = s; + return s; + } else { + return (java.lang.String) ref; + } + } + /** + *
+       * Opaque token returned by a previous DiscoverHierarchy response.
+       * 
+ * + * string page_token = 2; + * @return The bytes for pageToken. + */ + public com.google.protobuf.ByteString + getPageTokenBytes() { + java.lang.Object ref = pageToken_; + if (ref instanceof String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + pageToken_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + *
+       * Opaque token returned by a previous DiscoverHierarchy response.
+       * 
+ * + * string page_token = 2; + * @param value The pageToken to set. + * @return This builder for chaining. + */ + public Builder setPageToken( + java.lang.String value) { + if (value == null) { throw new NullPointerException(); } + pageToken_ = value; + bitField0_ |= 0x00000002; + onChanged(); + return this; + } + /** + *
+       * Opaque token returned by a previous DiscoverHierarchy response.
+       * 
+ * + * string page_token = 2; + * @return This builder for chaining. + */ + public Builder clearPageToken() { + pageToken_ = getDefaultInstance().getPageToken(); + bitField0_ = (bitField0_ & ~0x00000002); + onChanged(); + return this; + } + /** + *
+       * Opaque token returned by a previous DiscoverHierarchy response.
+       * 
+ * + * string page_token = 2; + * @param value The bytes for pageToken to set. + * @return This builder for chaining. + */ + public Builder setPageTokenBytes( + com.google.protobuf.ByteString value) { + if (value == null) { throw new NullPointerException(); } + checkByteStringIsUtf8(value); + pageToken_ = value; + bitField0_ |= 0x00000002; + onChanged(); + return this; + } // @@protoc_insertion_point(builder_scope:galaxy_repository.v1.DiscoverHierarchyRequest) } @@ -2176,7 +2464,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera /** * repeated .galaxy_repository.v1.GalaxyObject objects = 1; */ - java.util.List + java.util.List getObjectsList(); /** * repeated .galaxy_repository.v1.GalaxyObject objects = 1; @@ -2189,13 +2477,43 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera /** * repeated .galaxy_repository.v1.GalaxyObject objects = 1; */ - java.util.List + java.util.List getObjectsOrBuilderList(); /** * repeated .galaxy_repository.v1.GalaxyObject objects = 1; */ galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObjectOrBuilder getObjectsOrBuilder( int index); + + /** + *
+     * Non-empty when another page is available.
+     * 
+ * + * string next_page_token = 2; + * @return The nextPageToken. + */ + java.lang.String getNextPageToken(); + /** + *
+     * Non-empty when another page is available.
+     * 
+ * + * string next_page_token = 2; + * @return The bytes for nextPageToken. + */ + com.google.protobuf.ByteString + getNextPageTokenBytes(); + + /** + *
+     * Total number of objects in the cached hierarchy at the time of the call.
+     * 
+ * + * int32 total_object_count = 3; + * @return The totalObjectCount. + */ + int getTotalObjectCount(); } /** * Protobuf type {@code galaxy_repository.v1.DiscoverHierarchyReply} @@ -2220,6 +2538,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera } private DiscoverHierarchyReply() { objects_ = java.util.Collections.emptyList(); + nextPageToken_ = ""; } public static final com.google.protobuf.Descriptors.Descriptor @@ -2249,7 +2568,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera * repeated .galaxy_repository.v1.GalaxyObject objects = 1; */ @java.lang.Override - public java.util.List + public java.util.List getObjectsOrBuilderList() { return objects_; } @@ -2276,6 +2595,68 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera return objects_.get(index); } + public static final int NEXT_PAGE_TOKEN_FIELD_NUMBER = 2; + @SuppressWarnings("serial") + private volatile java.lang.Object nextPageToken_ = ""; + /** + *
+     * Non-empty when another page is available.
+     * 
+ * + * string next_page_token = 2; + * @return The nextPageToken. + */ + @java.lang.Override + public java.lang.String getNextPageToken() { + java.lang.Object ref = nextPageToken_; + if (ref instanceof java.lang.String) { + return (java.lang.String) ref; + } else { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + nextPageToken_ = s; + return s; + } + } + /** + *
+     * Non-empty when another page is available.
+     * 
+ * + * string next_page_token = 2; + * @return The bytes for nextPageToken. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getNextPageTokenBytes() { + java.lang.Object ref = nextPageToken_; + if (ref instanceof java.lang.String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + nextPageToken_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + + public static final int TOTAL_OBJECT_COUNT_FIELD_NUMBER = 3; + private int totalObjectCount_ = 0; + /** + *
+     * Total number of objects in the cached hierarchy at the time of the call.
+     * 
+ * + * int32 total_object_count = 3; + * @return The totalObjectCount. + */ + @java.lang.Override + public int getTotalObjectCount() { + return totalObjectCount_; + } + private byte memoizedIsInitialized = -1; @java.lang.Override public final boolean isInitialized() { @@ -2293,6 +2674,12 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera for (int i = 0; i < objects_.size(); i++) { output.writeMessage(1, objects_.get(i)); } + if (!com.google.protobuf.GeneratedMessage.isStringEmpty(nextPageToken_)) { + com.google.protobuf.GeneratedMessage.writeString(output, 2, nextPageToken_); + } + if (totalObjectCount_ != 0) { + output.writeInt32(3, totalObjectCount_); + } getUnknownFields().writeTo(output); } @@ -2306,6 +2693,13 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera size += com.google.protobuf.CodedOutputStream .computeMessageSize(1, objects_.get(i)); } + if (!com.google.protobuf.GeneratedMessage.isStringEmpty(nextPageToken_)) { + size += com.google.protobuf.GeneratedMessage.computeStringSize(2, nextPageToken_); + } + if (totalObjectCount_ != 0) { + size += com.google.protobuf.CodedOutputStream + .computeInt32Size(3, totalObjectCount_); + } size += getUnknownFields().getSerializedSize(); memoizedSize = size; return size; @@ -2323,6 +2717,10 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera if (!getObjectsList() .equals(other.getObjectsList())) return false; + if (!getNextPageToken() + .equals(other.getNextPageToken())) return false; + if (getTotalObjectCount() + != other.getTotalObjectCount()) return false; if (!getUnknownFields().equals(other.getUnknownFields())) return false; return true; } @@ -2338,6 +2736,10 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera hash = (37 * hash) + OBJECTS_FIELD_NUMBER; hash = (53 * hash) + getObjectsList().hashCode(); } + hash = (37 * hash) + NEXT_PAGE_TOKEN_FIELD_NUMBER; + hash = (53 * hash) + getNextPageToken().hashCode(); + hash = (37 * hash) + TOTAL_OBJECT_COUNT_FIELD_NUMBER; + hash = (53 * hash) + getTotalObjectCount(); hash = (29 * hash) + getUnknownFields().hashCode(); memoizedHashCode = hash; return hash; @@ -2476,6 +2878,8 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera objectsBuilder_.clear(); } bitField0_ = (bitField0_ & ~0x00000001); + nextPageToken_ = ""; + totalObjectCount_ = 0; return this; } @@ -2522,6 +2926,12 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera private void buildPartial0(galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply result) { int from_bitField0_ = bitField0_; + if (((from_bitField0_ & 0x00000002) != 0)) { + result.nextPageToken_ = nextPageToken_; + } + if (((from_bitField0_ & 0x00000004) != 0)) { + result.totalObjectCount_ = totalObjectCount_; + } } @java.lang.Override @@ -2554,7 +2964,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera objectsBuilder_ = null; objects_ = other.objects_; bitField0_ = (bitField0_ & ~0x00000001); - objectsBuilder_ = + objectsBuilder_ = com.google.protobuf.GeneratedMessage.alwaysUseFieldBuilders ? internalGetObjectsFieldBuilder() : null; } else { @@ -2562,6 +2972,14 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera } } } + if (!other.getNextPageToken().isEmpty()) { + nextPageToken_ = other.nextPageToken_; + bitField0_ |= 0x00000002; + onChanged(); + } + if (other.getTotalObjectCount() != 0) { + setTotalObjectCount(other.getTotalObjectCount()); + } this.mergeUnknownFields(other.getUnknownFields()); onChanged(); return this; @@ -2601,6 +3019,16 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera } break; } // case 10 + case 18: { + nextPageToken_ = input.readStringRequireUtf8(); + bitField0_ |= 0x00000002; + break; + } // case 18 + case 24: { + totalObjectCount_ = input.readInt32(); + bitField0_ |= 0x00000004; + break; + } // case 24 default: { if (!super.parseUnknownField(input, extensionRegistry, tag)) { done = true; // was an endgroup tag @@ -2813,7 +3241,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera /** * repeated .galaxy_repository.v1.GalaxyObject objects = 1; */ - public java.util.List + public java.util.List getObjectsOrBuilderList() { if (objectsBuilder_ != null) { return objectsBuilder_.getMessageOrBuilderList(); @@ -2839,12 +3267,12 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera /** * repeated .galaxy_repository.v1.GalaxyObject objects = 1; */ - public java.util.List + public java.util.List getObjectsBuilderList() { return internalGetObjectsFieldBuilder().getBuilderList(); } private com.google.protobuf.RepeatedFieldBuilder< - galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject, galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject.Builder, galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObjectOrBuilder> + galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject, galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject.Builder, galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObjectOrBuilder> internalGetObjectsFieldBuilder() { if (objectsBuilder_ == null) { objectsBuilder_ = new com.google.protobuf.RepeatedFieldBuilder< @@ -2858,6 +3286,142 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera return objectsBuilder_; } + private java.lang.Object nextPageToken_ = ""; + /** + *
+       * Non-empty when another page is available.
+       * 
+ * + * string next_page_token = 2; + * @return The nextPageToken. + */ + public java.lang.String getNextPageToken() { + java.lang.Object ref = nextPageToken_; + if (!(ref instanceof java.lang.String)) { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + nextPageToken_ = s; + return s; + } else { + return (java.lang.String) ref; + } + } + /** + *
+       * Non-empty when another page is available.
+       * 
+ * + * string next_page_token = 2; + * @return The bytes for nextPageToken. + */ + public com.google.protobuf.ByteString + getNextPageTokenBytes() { + java.lang.Object ref = nextPageToken_; + if (ref instanceof String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + nextPageToken_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + *
+       * Non-empty when another page is available.
+       * 
+ * + * string next_page_token = 2; + * @param value The nextPageToken to set. + * @return This builder for chaining. + */ + public Builder setNextPageToken( + java.lang.String value) { + if (value == null) { throw new NullPointerException(); } + nextPageToken_ = value; + bitField0_ |= 0x00000002; + onChanged(); + return this; + } + /** + *
+       * Non-empty when another page is available.
+       * 
+ * + * string next_page_token = 2; + * @return This builder for chaining. + */ + public Builder clearNextPageToken() { + nextPageToken_ = getDefaultInstance().getNextPageToken(); + bitField0_ = (bitField0_ & ~0x00000002); + onChanged(); + return this; + } + /** + *
+       * Non-empty when another page is available.
+       * 
+ * + * string next_page_token = 2; + * @param value The bytes for nextPageToken to set. + * @return This builder for chaining. + */ + public Builder setNextPageTokenBytes( + com.google.protobuf.ByteString value) { + if (value == null) { throw new NullPointerException(); } + checkByteStringIsUtf8(value); + nextPageToken_ = value; + bitField0_ |= 0x00000002; + onChanged(); + return this; + } + + private int totalObjectCount_ ; + /** + *
+       * Total number of objects in the cached hierarchy at the time of the call.
+       * 
+ * + * int32 total_object_count = 3; + * @return The totalObjectCount. + */ + @java.lang.Override + public int getTotalObjectCount() { + return totalObjectCount_; + } + /** + *
+       * Total number of objects in the cached hierarchy at the time of the call.
+       * 
+ * + * int32 total_object_count = 3; + * @param value The totalObjectCount to set. + * @return This builder for chaining. + */ + public Builder setTotalObjectCount(int value) { + + totalObjectCount_ = value; + bitField0_ |= 0x00000004; + onChanged(); + return this; + } + /** + *
+       * Total number of objects in the cached hierarchy at the time of the call.
+       * 
+ * + * int32 total_object_count = 3; + * @return This builder for chaining. + */ + public Builder clearTotalObjectCount() { + bitField0_ = (bitField0_ & ~0x00000004); + totalObjectCount_ = 0; + onChanged(); + return this; + } + // @@protoc_insertion_point(builder_scope:galaxy_repository.v1.DiscoverHierarchyReply) } @@ -3490,7 +4054,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera * .google.protobuf.Timestamp last_seen_deploy_time = 1; */ private com.google.protobuf.SingleFieldBuilder< - com.google.protobuf.Timestamp, com.google.protobuf.Timestamp.Builder, com.google.protobuf.TimestampOrBuilder> + com.google.protobuf.Timestamp, com.google.protobuf.Timestamp.Builder, com.google.protobuf.TimestampOrBuilder> internalGetLastSeenDeployTimeFieldBuilder() { if (lastSeenDeployTimeBuilder_ == null) { lastSeenDeployTimeBuilder_ = new com.google.protobuf.SingleFieldBuilder< @@ -4437,7 +5001,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera * .google.protobuf.Timestamp observed_at = 2; */ private com.google.protobuf.SingleFieldBuilder< - com.google.protobuf.Timestamp, com.google.protobuf.Timestamp.Builder, com.google.protobuf.TimestampOrBuilder> + com.google.protobuf.Timestamp, com.google.protobuf.Timestamp.Builder, com.google.protobuf.TimestampOrBuilder> internalGetObservedAtFieldBuilder() { if (observedAtBuilder_ == null) { observedAtBuilder_ = new com.google.protobuf.SingleFieldBuilder< @@ -4594,7 +5158,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera * .google.protobuf.Timestamp time_of_last_deploy = 3; */ private com.google.protobuf.SingleFieldBuilder< - com.google.protobuf.Timestamp, com.google.protobuf.Timestamp.Builder, com.google.protobuf.TimestampOrBuilder> + com.google.protobuf.Timestamp, com.google.protobuf.Timestamp.Builder, com.google.protobuf.TimestampOrBuilder> internalGetTimeOfLastDeployFieldBuilder() { if (timeOfLastDeployBuilder_ == null) { timeOfLastDeployBuilder_ = new com.google.protobuf.SingleFieldBuilder< @@ -4852,7 +5416,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera /** * repeated .galaxy_repository.v1.GalaxyAttribute attributes = 10; */ - java.util.List + java.util.List getAttributesList(); /** * repeated .galaxy_repository.v1.GalaxyAttribute attributes = 10; @@ -4865,7 +5429,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera /** * repeated .galaxy_repository.v1.GalaxyAttribute attributes = 10; */ - java.util.List + java.util.List getAttributesOrBuilderList(); /** * repeated .galaxy_repository.v1.GalaxyAttribute attributes = 10; @@ -4940,7 +5504,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera if (ref instanceof java.lang.String) { return (java.lang.String) ref; } else { - com.google.protobuf.ByteString bs = + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; java.lang.String s = bs.toStringUtf8(); tagName_ = s; @@ -4956,7 +5520,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera getTagNameBytes() { java.lang.Object ref = tagName_; if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); tagName_ = b; @@ -4979,7 +5543,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera if (ref instanceof java.lang.String) { return (java.lang.String) ref; } else { - com.google.protobuf.ByteString bs = + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; java.lang.String s = bs.toStringUtf8(); containedName_ = s; @@ -4995,7 +5559,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera getContainedNameBytes() { java.lang.Object ref = containedName_; if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); containedName_ = b; @@ -5018,7 +5582,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera if (ref instanceof java.lang.String) { return (java.lang.String) ref; } else { - com.google.protobuf.ByteString bs = + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; java.lang.String s = bs.toStringUtf8(); browseName_ = s; @@ -5034,7 +5598,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera getBrowseNameBytes() { java.lang.Object ref = browseName_; if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); browseName_ = b; @@ -5139,7 +5703,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera * repeated .galaxy_repository.v1.GalaxyAttribute attributes = 10; */ @java.lang.Override - public java.util.List + public java.util.List getAttributesOrBuilderList() { return attributes_; } @@ -5625,7 +6189,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera attributesBuilder_ = null; attributes_ = other.attributes_; bitField0_ = (bitField0_ & ~0x00000200); - attributesBuilder_ = + attributesBuilder_ = com.google.protobuf.GeneratedMessage.alwaysUseFieldBuilders ? internalGetAttributesFieldBuilder() : null; } else { @@ -5792,7 +6356,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera getTagNameBytes() { java.lang.Object ref = tagName_; if (ref instanceof String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); tagName_ = b; @@ -5864,7 +6428,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera getContainedNameBytes() { java.lang.Object ref = containedName_; if (ref instanceof String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); containedName_ = b; @@ -5936,7 +6500,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera getBrowseNameBytes() { java.lang.Object ref = browseName_; if (ref instanceof String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); browseName_ = b; @@ -6417,7 +6981,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera /** * repeated .galaxy_repository.v1.GalaxyAttribute attributes = 10; */ - public java.util.List + public java.util.List getAttributesOrBuilderList() { if (attributesBuilder_ != null) { return attributesBuilder_.getMessageOrBuilderList(); @@ -6443,12 +7007,12 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera /** * repeated .galaxy_repository.v1.GalaxyAttribute attributes = 10; */ - public java.util.List + public java.util.List getAttributesBuilderList() { return internalGetAttributesFieldBuilder().getBuilderList(); } private com.google.protobuf.RepeatedFieldBuilder< - galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttribute, galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttribute.Builder, galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttributeOrBuilder> + galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttribute, galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttribute.Builder, galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttributeOrBuilder> internalGetAttributesFieldBuilder() { if (attributesBuilder_ == null) { attributesBuilder_ = new com.google.protobuf.RepeatedFieldBuilder< @@ -6654,7 +7218,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera if (ref instanceof java.lang.String) { return (java.lang.String) ref; } else { - com.google.protobuf.ByteString bs = + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; java.lang.String s = bs.toStringUtf8(); attributeName_ = s; @@ -6670,7 +7234,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera getAttributeNameBytes() { java.lang.Object ref = attributeName_; if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); attributeName_ = b; @@ -6693,7 +7257,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera if (ref instanceof java.lang.String) { return (java.lang.String) ref; } else { - com.google.protobuf.ByteString bs = + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; java.lang.String s = bs.toStringUtf8(); fullTagReference_ = s; @@ -6709,7 +7273,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera getFullTagReferenceBytes() { java.lang.Object ref = fullTagReference_; if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); fullTagReference_ = b; @@ -6743,7 +7307,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera if (ref instanceof java.lang.String) { return (java.lang.String) ref; } else { - com.google.protobuf.ByteString bs = + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; java.lang.String s = bs.toStringUtf8(); dataTypeName_ = s; @@ -6759,7 +7323,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera getDataTypeNameBytes() { java.lang.Object ref = dataTypeName_; if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); dataTypeName_ = b; @@ -7401,7 +7965,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera getAttributeNameBytes() { java.lang.Object ref = attributeName_; if (ref instanceof String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); attributeName_ = b; @@ -7473,7 +8037,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera getFullTagReferenceBytes() { java.lang.Object ref = fullTagReference_; if (ref instanceof String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); fullTagReference_ = b; @@ -7577,7 +8141,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera getDataTypeNameBytes() { java.lang.Object ref = dataTypeName_; if (ref instanceof String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); dataTypeName_ = b; @@ -7901,52 +8465,52 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera private static final com.google.protobuf.Descriptors.Descriptor internal_static_galaxy_repository_v1_TestConnectionRequest_descriptor; - private static final + private static final com.google.protobuf.GeneratedMessage.FieldAccessorTable internal_static_galaxy_repository_v1_TestConnectionRequest_fieldAccessorTable; private static final com.google.protobuf.Descriptors.Descriptor internal_static_galaxy_repository_v1_TestConnectionReply_descriptor; - private static final + private static final com.google.protobuf.GeneratedMessage.FieldAccessorTable internal_static_galaxy_repository_v1_TestConnectionReply_fieldAccessorTable; private static final com.google.protobuf.Descriptors.Descriptor internal_static_galaxy_repository_v1_GetLastDeployTimeRequest_descriptor; - private static final + private static final com.google.protobuf.GeneratedMessage.FieldAccessorTable internal_static_galaxy_repository_v1_GetLastDeployTimeRequest_fieldAccessorTable; private static final com.google.protobuf.Descriptors.Descriptor internal_static_galaxy_repository_v1_GetLastDeployTimeReply_descriptor; - private static final + private static final com.google.protobuf.GeneratedMessage.FieldAccessorTable internal_static_galaxy_repository_v1_GetLastDeployTimeReply_fieldAccessorTable; private static final com.google.protobuf.Descriptors.Descriptor internal_static_galaxy_repository_v1_DiscoverHierarchyRequest_descriptor; - private static final + private static final com.google.protobuf.GeneratedMessage.FieldAccessorTable internal_static_galaxy_repository_v1_DiscoverHierarchyRequest_fieldAccessorTable; private static final com.google.protobuf.Descriptors.Descriptor internal_static_galaxy_repository_v1_DiscoverHierarchyReply_descriptor; - private static final + private static final com.google.protobuf.GeneratedMessage.FieldAccessorTable internal_static_galaxy_repository_v1_DiscoverHierarchyReply_fieldAccessorTable; private static final com.google.protobuf.Descriptors.Descriptor internal_static_galaxy_repository_v1_WatchDeployEventsRequest_descriptor; - private static final + private static final com.google.protobuf.GeneratedMessage.FieldAccessorTable internal_static_galaxy_repository_v1_WatchDeployEventsRequest_fieldAccessorTable; private static final com.google.protobuf.Descriptors.Descriptor internal_static_galaxy_repository_v1_DeployEvent_descriptor; - private static final + private static final com.google.protobuf.GeneratedMessage.FieldAccessorTable internal_static_galaxy_repository_v1_DeployEvent_fieldAccessorTable; private static final com.google.protobuf.Descriptors.Descriptor internal_static_galaxy_repository_v1_GalaxyObject_descriptor; - private static final + private static final com.google.protobuf.GeneratedMessage.FieldAccessorTable internal_static_galaxy_repository_v1_GalaxyObject_fieldAccessorTable; private static final com.google.protobuf.Descriptors.Descriptor internal_static_galaxy_repository_v1_GalaxyAttribute_descriptor; - private static final + private static final com.google.protobuf.GeneratedMessage.FieldAccessorTable internal_static_galaxy_repository_v1_GalaxyAttribute_fieldAccessorTable; @@ -7964,45 +8528,47 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera "ionReply\022\n\n\002ok\030\001 \001(\010\"\032\n\030GetLastDeployTim" + "eRequest\"b\n\026GetLastDeployTimeReply\022\017\n\007pr" + "esent\030\001 \001(\010\0227\n\023time_of_last_deploy\030\002 \001(\013" + - "2\032.google.protobuf.Timestamp\"\032\n\030Discover" + - "HierarchyRequest\"M\n\026DiscoverHierarchyRep" + + "2\032.google.protobuf.Timestamp\"A\n\030Discover" + + "HierarchyRequest\022\021\n\tpage_size\030\001 \001(\005\022\022\n\np" + + "age_token\030\002 \001(\t\"\202\001\n\026DiscoverHierarchyRep" + "ly\0223\n\007objects\030\001 \003(\0132\".galaxy_repository." + - "v1.GalaxyObject\"U\n\030WatchDeployEventsRequ" + - "est\0229\n\025last_seen_deploy_time\030\001 \001(\0132\032.goo" + - "gle.protobuf.Timestamp\"\335\001\n\013DeployEvent\022\020" + - "\n\010sequence\030\001 \001(\004\022/\n\013observed_at\030\002 \001(\0132\032." + - "google.protobuf.Timestamp\0227\n\023time_of_las" + - "t_deploy\030\003 \001(\0132\032.google.protobuf.Timesta" + - "mp\022#\n\033time_of_last_deploy_present\030\004 \001(\010\022" + - "\024\n\014object_count\030\005 \001(\005\022\027\n\017attribute_count" + - "\030\006 \001(\005\"\223\002\n\014GalaxyObject\022\022\n\ngobject_id\030\001 " + - "\001(\005\022\020\n\010tag_name\030\002 \001(\t\022\026\n\016contained_name\030" + - "\003 \001(\t\022\023\n\013browse_name\030\004 \001(\t\022\031\n\021parent_gob" + - "ject_id\030\005 \001(\005\022\017\n\007is_area\030\006 \001(\010\022\023\n\013catego" + - "ry_id\030\007 \001(\005\022\034\n\024hosted_by_gobject_id\030\010 \001(" + - "\005\022\026\n\016template_chain\030\t \003(\t\0229\n\nattributes\030" + - "\n \003(\0132%.galaxy_repository.v1.GalaxyAttri" + - "bute\"\250\002\n\017GalaxyAttribute\022\026\n\016attribute_na" + - "me\030\001 \001(\t\022\032\n\022full_tag_reference\030\002 \001(\t\022\024\n\014" + - "mx_data_type\030\003 \001(\005\022\026\n\016data_type_name\030\004 \001" + - "(\t\022\020\n\010is_array\030\005 \001(\010\022\027\n\017array_dimension\030" + - "\006 \001(\005\022\037\n\027array_dimension_present\030\007 \001(\010\022\035" + - "\n\025mx_attribute_category\030\010 \001(\005\022\037\n\027securit" + - "y_classification\030\t \001(\005\022\025\n\ris_historized\030" + - "\n \001(\010\022\020\n\010is_alarm\030\013 \001(\0102\314\003\n\020GalaxyReposi" + - "tory\022h\n\016TestConnection\022+.galaxy_reposito" + - "ry.v1.TestConnectionRequest\032).galaxy_rep" + - "ository.v1.TestConnectionReply\022q\n\021GetLas" + - "tDeployTime\022..galaxy_repository.v1.GetLa" + - "stDeployTimeRequest\032,.galaxy_repository." + - "v1.GetLastDeployTimeReply\022q\n\021DiscoverHie" + - "rarchy\022..galaxy_repository.v1.DiscoverHi" + - "erarchyRequest\032,.galaxy_repository.v1.Di" + - "scoverHierarchyReply\022h\n\021WatchDeployEvent" + - "s\022..galaxy_repository.v1.WatchDeployEven" + - "tsRequest\032!.galaxy_repository.v1.DeployE" + - "vent0\001B#\252\002 MxGateway.Contracts.Proto.Gal" + - "axyb\006proto3" + "v1.GalaxyObject\022\027\n\017next_page_token\030\002 \001(\t" + + "\022\032\n\022total_object_count\030\003 \001(\005\"U\n\030WatchDep" + + "loyEventsRequest\0229\n\025last_seen_deploy_tim" + + "e\030\001 \001(\0132\032.google.protobuf.Timestamp\"\335\001\n\013" + + "DeployEvent\022\020\n\010sequence\030\001 \001(\004\022/\n\013observe" + + "d_at\030\002 \001(\0132\032.google.protobuf.Timestamp\0227" + + "\n\023time_of_last_deploy\030\003 \001(\0132\032.google.pro" + + "tobuf.Timestamp\022#\n\033time_of_last_deploy_p" + + "resent\030\004 \001(\010\022\024\n\014object_count\030\005 \001(\005\022\027\n\017at" + + "tribute_count\030\006 \001(\005\"\223\002\n\014GalaxyObject\022\022\n\n" + + "gobject_id\030\001 \001(\005\022\020\n\010tag_name\030\002 \001(\t\022\026\n\016co" + + "ntained_name\030\003 \001(\t\022\023\n\013browse_name\030\004 \001(\t\022" + + "\031\n\021parent_gobject_id\030\005 \001(\005\022\017\n\007is_area\030\006 " + + "\001(\010\022\023\n\013category_id\030\007 \001(\005\022\034\n\024hosted_by_go" + + "bject_id\030\010 \001(\005\022\026\n\016template_chain\030\t \003(\t\0229" + + "\n\nattributes\030\n \003(\0132%.galaxy_repository.v" + + "1.GalaxyAttribute\"\250\002\n\017GalaxyAttribute\022\026\n" + + "\016attribute_name\030\001 \001(\t\022\032\n\022full_tag_refere" + + "nce\030\002 \001(\t\022\024\n\014mx_data_type\030\003 \001(\005\022\026\n\016data_" + + "type_name\030\004 \001(\t\022\020\n\010is_array\030\005 \001(\010\022\027\n\017arr" + + "ay_dimension\030\006 \001(\005\022\037\n\027array_dimension_pr" + + "esent\030\007 \001(\010\022\035\n\025mx_attribute_category\030\010 \001" + + "(\005\022\037\n\027security_classification\030\t \001(\005\022\025\n\ri" + + "s_historized\030\n \001(\010\022\020\n\010is_alarm\030\013 \001(\0102\314\003\n" + + "\020GalaxyRepository\022h\n\016TestConnection\022+.ga" + + "laxy_repository.v1.TestConnectionRequest" + + "\032).galaxy_repository.v1.TestConnectionRe" + + "ply\022q\n\021GetLastDeployTime\022..galaxy_reposi" + + "tory.v1.GetLastDeployTimeRequest\032,.galax" + + "y_repository.v1.GetLastDeployTimeReply\022q" + + "\n\021DiscoverHierarchy\022..galaxy_repository." + + "v1.DiscoverHierarchyRequest\032,.galaxy_rep" + + "ository.v1.DiscoverHierarchyReply\022h\n\021Wat" + + "chDeployEvents\022..galaxy_repository.v1.Wa" + + "tchDeployEventsRequest\032!.galaxy_reposito" + + "ry.v1.DeployEvent0\001B#\252\002 MxGateway.Contra" + + "cts.Proto.Galaxyb\006proto3" }; descriptor = com.google.protobuf.Descriptors.FileDescriptor .internalBuildGeneratedFileFrom(descriptorData, @@ -8038,13 +8604,13 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera internal_static_galaxy_repository_v1_DiscoverHierarchyRequest_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( internal_static_galaxy_repository_v1_DiscoverHierarchyRequest_descriptor, - new java.lang.String[] { }); + new java.lang.String[] { "PageSize", "PageToken", }); internal_static_galaxy_repository_v1_DiscoverHierarchyReply_descriptor = getDescriptor().getMessageType(5); internal_static_galaxy_repository_v1_DiscoverHierarchyReply_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( internal_static_galaxy_repository_v1_DiscoverHierarchyReply_descriptor, - new java.lang.String[] { "Objects", }); + new java.lang.String[] { "Objects", "NextPageToken", "TotalObjectCount", }); internal_static_galaxy_repository_v1_WatchDeployEventsRequest_descriptor = getDescriptor().getMessageType(6); internal_static_galaxy_repository_v1_WatchDeployEventsRequest_fieldAccessorTable = new diff --git a/clients/proto/descriptors/mxaccessgw-client-v1.protoset b/clients/proto/descriptors/mxaccessgw-client-v1.protoset index f04fccc938727860ba52a42758a9699afe16eafe..fba3c48c70a781c496aee79718dcd527d964bfe5 100644 GIT binary patch delta 2513 zcmZuy%Wm676qSaQIHcRApV zFSlR)_Ifm(-u&TBe#-8%%)0Z1TWWgWxVf}QU(Xad9jpV}{gx$zf&Zmj%P~aFqDh$Z4|a$1<`a z12c3!^^Z?)j)iG;?^wDsx{p}IJINYp1NTL2{pfVqwY7oz&mrrnNw}K&6}3>ft8^N zGBkn_BTP5U&&Nmu>gu+Z<*W#evRr;ztkj*aS}q2IvT8Q>Sd^a&N7Nhcndi5fV%BR` zd<+_X?zs)8Q8{^z%x2xka7pVlT8}cWnx(0}*%>3W9_{IcWK7T4`mNO6NWKiRZt(%D z10!WfzA<)LQUG*9(Y=7OKpnkd90YGHHy9&S9~-)4eJZOUG|u|-BVrjkxu^Buxs(zb z`oRgbCbI~yDV#IMt2*Use1i<58;q+P08t?`r+TE(W4tdcSX;6Qv$Y8}n4cFL{ zqHyJR^|qRz930)TS!Y7EXoQg@!)vL1q{H{ac(cnB@99u*>^E~uq& zsq%ta3J_=uSPDHU&Y<$5njQeO#e_y0FPiZvkTuj6F@D5DktL-TXC(4c!XuHF%q}&G zMwv=1&LHwK8&DwcHEBmrVii`V&mv-=t>XI=UCywzHFi;j4A9mjEu>6(0&6_lHxPJh zgF^ARtrgi7#pAeZQGK5{Xh#m$IiWF@TXQD{)(q&b|w6%3MqCzH$tj9${X5vEY z9no{=%tV#-zPO)>D(lyWrM*mB-%OPwdr{UVOh{KV$zEG(-vAhDOYIE^iCb#lKq$AR z)@_m%wpkbr3mX@I_Bt>kr<>AV2N#D_JJ{=xMIRxPk-ZL+cI2)@ z-mglMyAH)gMdP8ZmDKOgl!huLJTg>?NB=v-z$@W@7p)wII%1J$OlG3pQ8FcY2>9=&kKFiFjbo;$&2f`fBwr$Z79!ec0XA*#$hy29USN1XKl@s0RdDYi2&63}}f9QH~xS zC6Ch?=UxTv5vu;2!oJG(YvbSl{iU-;vrdXfV5~JC2Lz+FuiMV3u=#rO)OT5cwXP=3 z2sQ=Vw8y`G__oz1<%D`Ac{5Hs)I6B%Z^MYrV$ z8v!Z;f^19G$^fC;s?UfH%x!AQQN^L4<5g}ezoCLEkZm_+-6)mKpC#Y;Q7W*WshYD> zJjxyN5&+okNE?bup6QOVt`-jAw=*>@IvnlNv>c_-?aC^Yo9(Ww0tm8Qtb+5U2(#@- z_W+P}qKs$U(V-QLwahwb-|JwqC(}~IL*9!z9`c^9Nh=vMWmt+Z_Sjj@O~=8vsom%HDuLJd}L{!R}DjtvL!uRQiU2pgW2>zPd-+e?D*q-O=2sS^51lciS&kF#8>{w!=8Jxr}IbA1Qt#nL|*GX)X<8>0-#I#N+SaQ5hB_^6U zUZ=%pQjX(wYA2;j5YJ*cPS;uNlGAl2SGd&f4xhzx*v2_6izUbFJeK2lofkgo+QsX9 zaY@P{UKiv)LYg>U7qJ|t>q6ZTOHS9t!lKAjX|qfDFVHSfmr=)ox>Ww_k2B~l@%8sB zN1%q}GowkP+%R^@*%%gKV4R>~{kF)A%|Ehw6_nuU=iP F{}1!PgsA`k diff --git a/clients/proto/fixtures/behavior/manifest.json b/clients/proto/fixtures/behavior/manifest.json index 7585f94..ff802a0 100644 --- a/clients/proto/fixtures/behavior/manifest.json +++ b/clients/proto/fixtures/behavior/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "fixtureSet": "mxaccess-gateway-client-behavior", "contractName": "mxaccess-gateway", - "gatewayProtocolVersion": 1, + "gatewayProtocolVersion": 2, "workerProtocolVersion": 1, "protoInputManifest": "clients/proto/proto-inputs.json", "fixtures": [ diff --git a/clients/proto/fixtures/golden/open-session-reply.ok.json b/clients/proto/fixtures/golden/open-session-reply.ok.json index 8d5bb49..e242b6d 100644 --- a/clients/proto/fixtures/golden/open-session-reply.ok.json +++ b/clients/proto/fixtures/golden/open-session-reply.ok.json @@ -3,7 +3,7 @@ "backendName": "mxaccess-worker", "workerProcessId": 1234, "workerProtocolVersion": 1, - "gatewayProtocolVersion": 1, + "gatewayProtocolVersion": 2, "capabilities": [ "unary-open-session", "unary-close-session", diff --git a/clients/proto/fixtures/parity/parity-fixture-matrix.json b/clients/proto/fixtures/parity/parity-fixture-matrix.json index 14b9fd0..e4f0a5e 100644 --- a/clients/proto/fixtures/parity/parity-fixture-matrix.json +++ b/clients/proto/fixtures/parity/parity-fixture-matrix.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "fixtureSet": "mxaccess-gateway-parity-fixture-matrix", "contractName": "mxaccess-gateway", - "gatewayProtocolVersion": 1, + "gatewayProtocolVersion": 2, "workerProtocolVersion": 1, "sourceCaptureRoot": "C:/Users/dohertj2/Desktop/mxaccess/captures", "sourceDocs": [ diff --git a/clients/proto/proto-inputs.json b/clients/proto/proto-inputs.json index c349f63..9d424b0 100644 --- a/clients/proto/proto-inputs.json +++ b/clients/proto/proto-inputs.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "contractName": "mxaccess-gateway", - "gatewayProtocolVersion": 1, + "gatewayProtocolVersion": 2, "workerProtocolVersion": 1, "protoRoot": "src/MxGateway.Contracts/Protos", "sourceFiles": [ diff --git a/clients/python/src/mxgateway/galaxy.py b/clients/python/src/mxgateway/galaxy.py index 5495279..bcf632d 100644 --- a/clients/python/src/mxgateway/galaxy.py +++ b/clients/python/src/mxgateway/galaxy.py @@ -23,6 +23,8 @@ from .generated import galaxy_repository_pb2 as galaxy_pb from .generated import galaxy_repository_pb2_grpc as galaxy_pb_grpc from .options import ClientOptions, create_channel +_DISCOVER_HIERARCHY_PAGE_SIZE = 5000 + class GalaxyRepositoryClient: """Async client for the Galaxy Repository gRPC service.""" @@ -112,12 +114,21 @@ class GalaxyRepositoryClient: async def discover_hierarchy(self) -> list[galaxy_pb.GalaxyObject]: """Return the deployed Galaxy object hierarchy as raw proto messages.""" - reply = await self._unary( - "discover hierarchy", - self.raw_stub.DiscoverHierarchy, - galaxy_pb.DiscoverHierarchyRequest(), - ) - return list(reply.objects) + objects: list[galaxy_pb.GalaxyObject] = [] + page_token = "" + while True: + reply = await self._unary( + "discover hierarchy", + self.raw_stub.DiscoverHierarchy, + galaxy_pb.DiscoverHierarchyRequest( + page_size=_DISCOVER_HIERARCHY_PAGE_SIZE, + page_token=page_token, + ), + ) + objects.extend(reply.objects) + page_token = reply.next_page_token + if not page_token: + return objects def watch_deploy_events( self, diff --git a/clients/python/src/mxgateway/generated/galaxy_repository_pb2.py b/clients/python/src/mxgateway/generated/galaxy_repository_pb2.py index 8abaf20..fda53a6 100644 --- a/clients/python/src/mxgateway/generated/galaxy_repository_pb2.py +++ b/clients/python/src/mxgateway/generated/galaxy_repository_pb2.py @@ -25,7 +25,7 @@ _sym_db = _symbol_database.Default() from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x17galaxy_repository.proto\x12\x14galaxy_repository.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\x17\n\x15TestConnectionRequest\"!\n\x13TestConnectionReply\x12\n\n\x02ok\x18\x01 \x01(\x08\"\x1a\n\x18GetLastDeployTimeRequest\"b\n\x16GetLastDeployTimeReply\x12\x0f\n\x07present\x18\x01 \x01(\x08\x12\x37\n\x13time_of_last_deploy\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\x1a\n\x18\x44iscoverHierarchyRequest\"M\n\x16\x44iscoverHierarchyReply\x12\x33\n\x07objects\x18\x01 \x03(\x0b\x32\".galaxy_repository.v1.GalaxyObject\"U\n\x18WatchDeployEventsRequest\x12\x39\n\x15last_seen_deploy_time\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xdd\x01\n\x0b\x44\x65ployEvent\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12/\n\x0bobserved_at\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x37\n\x13time_of_last_deploy\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12#\n\x1btime_of_last_deploy_present\x18\x04 \x01(\x08\x12\x14\n\x0cobject_count\x18\x05 \x01(\x05\x12\x17\n\x0f\x61ttribute_count\x18\x06 \x01(\x05\"\x93\x02\n\x0cGalaxyObject\x12\x12\n\ngobject_id\x18\x01 \x01(\x05\x12\x10\n\x08tag_name\x18\x02 \x01(\t\x12\x16\n\x0e\x63ontained_name\x18\x03 \x01(\t\x12\x13\n\x0b\x62rowse_name\x18\x04 \x01(\t\x12\x19\n\x11parent_gobject_id\x18\x05 \x01(\x05\x12\x0f\n\x07is_area\x18\x06 \x01(\x08\x12\x13\n\x0b\x63\x61tegory_id\x18\x07 \x01(\x05\x12\x1c\n\x14hosted_by_gobject_id\x18\x08 \x01(\x05\x12\x16\n\x0etemplate_chain\x18\t \x03(\t\x12\x39\n\nattributes\x18\n \x03(\x0b\x32%.galaxy_repository.v1.GalaxyAttribute\"\xa8\x02\n\x0fGalaxyAttribute\x12\x16\n\x0e\x61ttribute_name\x18\x01 \x01(\t\x12\x1a\n\x12\x66ull_tag_reference\x18\x02 \x01(\t\x12\x14\n\x0cmx_data_type\x18\x03 \x01(\x05\x12\x16\n\x0e\x64\x61ta_type_name\x18\x04 \x01(\t\x12\x10\n\x08is_array\x18\x05 \x01(\x08\x12\x17\n\x0f\x61rray_dimension\x18\x06 \x01(\x05\x12\x1f\n\x17\x61rray_dimension_present\x18\x07 \x01(\x08\x12\x1d\n\x15mx_attribute_category\x18\x08 \x01(\x05\x12\x1f\n\x17security_classification\x18\t \x01(\x05\x12\x15\n\ris_historized\x18\n \x01(\x08\x12\x10\n\x08is_alarm\x18\x0b \x01(\x08\x32\xcc\x03\n\x10GalaxyRepository\x12h\n\x0eTestConnection\x12+.galaxy_repository.v1.TestConnectionRequest\x1a).galaxy_repository.v1.TestConnectionReply\x12q\n\x11GetLastDeployTime\x12..galaxy_repository.v1.GetLastDeployTimeRequest\x1a,.galaxy_repository.v1.GetLastDeployTimeReply\x12q\n\x11\x44iscoverHierarchy\x12..galaxy_repository.v1.DiscoverHierarchyRequest\x1a,.galaxy_repository.v1.DiscoverHierarchyReply\x12h\n\x11WatchDeployEvents\x12..galaxy_repository.v1.WatchDeployEventsRequest\x1a!.galaxy_repository.v1.DeployEvent0\x01\x42#\xaa\x02 MxGateway.Contracts.Proto.Galaxyb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x17galaxy_repository.proto\x12\x14galaxy_repository.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\x17\n\x15TestConnectionRequest\"!\n\x13TestConnectionReply\x12\n\n\x02ok\x18\x01 \x01(\x08\"\x1a\n\x18GetLastDeployTimeRequest\"b\n\x16GetLastDeployTimeReply\x12\x0f\n\x07present\x18\x01 \x01(\x08\x12\x37\n\x13time_of_last_deploy\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"A\n\x18\x44iscoverHierarchyRequest\x12\x11\n\tpage_size\x18\x01 \x01(\x05\x12\x12\n\npage_token\x18\x02 \x01(\t\"\x82\x01\n\x16\x44iscoverHierarchyReply\x12\x33\n\x07objects\x18\x01 \x03(\x0b\x32\".galaxy_repository.v1.GalaxyObject\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\t\x12\x1a\n\x12total_object_count\x18\x03 \x01(\x05\"U\n\x18WatchDeployEventsRequest\x12\x39\n\x15last_seen_deploy_time\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xdd\x01\n\x0b\x44\x65ployEvent\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12/\n\x0bobserved_at\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x37\n\x13time_of_last_deploy\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12#\n\x1btime_of_last_deploy_present\x18\x04 \x01(\x08\x12\x14\n\x0cobject_count\x18\x05 \x01(\x05\x12\x17\n\x0f\x61ttribute_count\x18\x06 \x01(\x05\"\x93\x02\n\x0cGalaxyObject\x12\x12\n\ngobject_id\x18\x01 \x01(\x05\x12\x10\n\x08tag_name\x18\x02 \x01(\t\x12\x16\n\x0e\x63ontained_name\x18\x03 \x01(\t\x12\x13\n\x0b\x62rowse_name\x18\x04 \x01(\t\x12\x19\n\x11parent_gobject_id\x18\x05 \x01(\x05\x12\x0f\n\x07is_area\x18\x06 \x01(\x08\x12\x13\n\x0b\x63\x61tegory_id\x18\x07 \x01(\x05\x12\x1c\n\x14hosted_by_gobject_id\x18\x08 \x01(\x05\x12\x16\n\x0etemplate_chain\x18\t \x03(\t\x12\x39\n\nattributes\x18\n \x03(\x0b\x32%.galaxy_repository.v1.GalaxyAttribute\"\xa8\x02\n\x0fGalaxyAttribute\x12\x16\n\x0e\x61ttribute_name\x18\x01 \x01(\t\x12\x1a\n\x12\x66ull_tag_reference\x18\x02 \x01(\t\x12\x14\n\x0cmx_data_type\x18\x03 \x01(\x05\x12\x16\n\x0e\x64\x61ta_type_name\x18\x04 \x01(\t\x12\x10\n\x08is_array\x18\x05 \x01(\x08\x12\x17\n\x0f\x61rray_dimension\x18\x06 \x01(\x05\x12\x1f\n\x17\x61rray_dimension_present\x18\x07 \x01(\x08\x12\x1d\n\x15mx_attribute_category\x18\x08 \x01(\x05\x12\x1f\n\x17security_classification\x18\t \x01(\x05\x12\x15\n\ris_historized\x18\n \x01(\x08\x12\x10\n\x08is_alarm\x18\x0b \x01(\x08\x32\xcc\x03\n\x10GalaxyRepository\x12h\n\x0eTestConnection\x12+.galaxy_repository.v1.TestConnectionRequest\x1a).galaxy_repository.v1.TestConnectionReply\x12q\n\x11GetLastDeployTime\x12..galaxy_repository.v1.GetLastDeployTimeRequest\x1a,.galaxy_repository.v1.GetLastDeployTimeReply\x12q\n\x11\x44iscoverHierarchy\x12..galaxy_repository.v1.DiscoverHierarchyRequest\x1a,.galaxy_repository.v1.DiscoverHierarchyReply\x12h\n\x11WatchDeployEvents\x12..galaxy_repository.v1.WatchDeployEventsRequest\x1a!.galaxy_repository.v1.DeployEvent0\x01\x42#\xaa\x02 MxGateway.Contracts.Proto.Galaxyb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -42,17 +42,17 @@ if not _descriptor._USE_C_DESCRIPTORS: _globals['_GETLASTDEPLOYTIMEREPLY']._serialized_start=170 _globals['_GETLASTDEPLOYTIMEREPLY']._serialized_end=268 _globals['_DISCOVERHIERARCHYREQUEST']._serialized_start=270 - _globals['_DISCOVERHIERARCHYREQUEST']._serialized_end=296 - _globals['_DISCOVERHIERARCHYREPLY']._serialized_start=298 - _globals['_DISCOVERHIERARCHYREPLY']._serialized_end=375 - _globals['_WATCHDEPLOYEVENTSREQUEST']._serialized_start=377 - _globals['_WATCHDEPLOYEVENTSREQUEST']._serialized_end=462 - _globals['_DEPLOYEVENT']._serialized_start=465 - _globals['_DEPLOYEVENT']._serialized_end=686 - _globals['_GALAXYOBJECT']._serialized_start=689 - _globals['_GALAXYOBJECT']._serialized_end=964 - _globals['_GALAXYATTRIBUTE']._serialized_start=967 - _globals['_GALAXYATTRIBUTE']._serialized_end=1263 - _globals['_GALAXYREPOSITORY']._serialized_start=1266 - _globals['_GALAXYREPOSITORY']._serialized_end=1726 + _globals['_DISCOVERHIERARCHYREQUEST']._serialized_end=335 + _globals['_DISCOVERHIERARCHYREPLY']._serialized_start=338 + _globals['_DISCOVERHIERARCHYREPLY']._serialized_end=468 + _globals['_WATCHDEPLOYEVENTSREQUEST']._serialized_start=470 + _globals['_WATCHDEPLOYEVENTSREQUEST']._serialized_end=555 + _globals['_DEPLOYEVENT']._serialized_start=558 + _globals['_DEPLOYEVENT']._serialized_end=779 + _globals['_GALAXYOBJECT']._serialized_start=782 + _globals['_GALAXYOBJECT']._serialized_end=1057 + _globals['_GALAXYATTRIBUTE']._serialized_start=1060 + _globals['_GALAXYATTRIBUTE']._serialized_end=1356 + _globals['_GALAXYREPOSITORY']._serialized_start=1359 + _globals['_GALAXYREPOSITORY']._serialized_end=1819 # @@protoc_insertion_point(module_scope) diff --git a/clients/python/src/mxgateway/options.py b/clients/python/src/mxgateway/options.py index 446330d..8db3b6d 100644 --- a/clients/python/src/mxgateway/options.py +++ b/clients/python/src/mxgateway/options.py @@ -21,6 +21,7 @@ class ClientOptions: server_name_override: str | None = None call_timeout: float | None = 30.0 stream_timeout: float | None = None + max_grpc_message_bytes: int = 16 * 1024 * 1024 def __post_init__(self) -> None: if not self.endpoint: @@ -32,6 +33,8 @@ class ClientOptions: raise ValueError("call_timeout must be greater than zero") if self.stream_timeout is not None and self.stream_timeout <= 0: raise ValueError("stream_timeout must be greater than zero") + if self.max_grpc_message_bytes <= 0: + raise ValueError("max_grpc_message_bytes must be greater than zero") def __repr__(self) -> str: api_key = REDACTED if self.api_key else None @@ -41,14 +44,18 @@ class ClientOptions: f"ca_file={self.ca_file!r}, " f"server_name_override={self.server_name_override!r}, " f"call_timeout={self.call_timeout!r}, " - f"stream_timeout={self.stream_timeout!r})" + f"stream_timeout={self.stream_timeout!r}, " + f"max_grpc_message_bytes={self.max_grpc_message_bytes!r})" ) def create_channel(options: ClientOptions) -> grpc.aio.Channel: """Create a plaintext or TLS `grpc.aio` channel from client options.""" - channel_options: list[tuple[str, str]] = [] + channel_options: list[tuple[str, str | int]] = [ + ("grpc.max_receive_message_length", options.max_grpc_message_bytes), + ("grpc.max_send_message_length", options.max_grpc_message_bytes), + ] if options.server_name_override: channel_options.append(("grpc.ssl_target_name_override", options.server_name_override)) diff --git a/clients/python/tests/test_auth_options.py b/clients/python/tests/test_auth_options.py index 522e017..34bd2bf 100644 --- a/clients/python/tests/test_auth_options.py +++ b/clients/python/tests/test_auth_options.py @@ -61,7 +61,15 @@ def test_create_channel_uses_plaintext_channel(monkeypatch: pytest.MonkeyPatch) channel = create_channel(ClientOptions(endpoint="localhost:5000", plaintext=True)) assert channel == "plain-channel" - assert calls == [("localhost:5000", [])] + assert calls == [ + ( + "localhost:5000", + [ + ("grpc.max_receive_message_length", 16 * 1024 * 1024), + ("grpc.max_send_message_length", 16 * 1024 * 1024), + ], + ), + ] def test_create_channel_uses_tls_channel(monkeypatch: pytest.MonkeyPatch) -> None: @@ -95,9 +103,13 @@ def test_create_channel_uses_tls_channel(monkeypatch: pytest.MonkeyPatch) -> Non assert channel == "tls-channel" assert calls == [ - ( - "gateway.example:5001", - "creds", - [("grpc.ssl_target_name_override", "gateway.test")], - ), - ] + ( + "gateway.example:5001", + "creds", + [ + ("grpc.max_receive_message_length", 16 * 1024 * 1024), + ("grpc.max_send_message_length", 16 * 1024 * 1024), + ("grpc.ssl_target_name_override", "gateway.test"), + ], + ), + ] diff --git a/clients/python/tests/test_galaxy.py b/clients/python/tests/test_galaxy.py index f176195..977e2ad 100644 --- a/clients/python/tests/test_galaxy.py +++ b/clients/python/tests/test_galaxy.py @@ -98,6 +98,8 @@ async def test_discover_hierarchy_returns_proto_objects() -> None: stub = FakeGalaxyStub() stub.discover_hierarchy.replies = [ galaxy_pb.DiscoverHierarchyReply( + next_page_token="page-2", + total_object_count=2, objects=[ galaxy_pb.GalaxyObject( gobject_id=1, @@ -106,6 +108,11 @@ async def test_discover_hierarchy_returns_proto_objects() -> None: browse_name="TestMachine_001", is_area=True, ), + ], + ), + galaxy_pb.DiscoverHierarchyReply( + total_object_count=2, + objects=[ galaxy_pb.GalaxyObject( gobject_id=2, tag_name="DelmiaReceiver_001", @@ -133,6 +140,10 @@ async def test_discover_hierarchy_returns_proto_objects() -> None: assert isinstance(objects, list) assert len(objects) == 2 + assert len(stub.discover_hierarchy.requests) == 2 + assert stub.discover_hierarchy.requests[0].page_size == 5000 + assert stub.discover_hierarchy.requests[0].page_token == "" + assert stub.discover_hierarchy.requests[1].page_token == "page-2" assert objects[0].tag_name == "TestMachine_001" assert objects[1].attributes[0].full_tag_reference == "DelmiaReceiver_001.DownloadPath" diff --git a/clients/rust/crates/mxgw-cli/src/main.rs b/clients/rust/crates/mxgw-cli/src/main.rs index c3de660..4bbcf49 100644 --- a/clients/rust/crates/mxgw-cli/src/main.rs +++ b/clients/rust/crates/mxgw-cli/src/main.rs @@ -1038,7 +1038,7 @@ mod tests { fn version_json_output_has_protocol_versions() { let value = super::version_json(); - assert_eq!(value["gatewayProtocolVersion"], 1); + assert_eq!(value["gatewayProtocolVersion"], 2); assert_eq!(value["workerProtocolVersion"], 1); } diff --git a/clients/rust/src/client.rs b/clients/rust/src/client.rs index 2bfdb85..1eb3ea3 100644 --- a/clients/rust/src/client.rs +++ b/clients/rust/src/client.rs @@ -54,9 +54,12 @@ impl GatewayClient { let channel = endpoint.connect().await?; let interceptor = AuthInterceptor::new(options.api_key().cloned()); + let max_grpc_message_bytes = options.max_grpc_message_bytes(); Ok(Self { - inner: MxAccessGatewayClient::with_interceptor(channel, interceptor), + inner: MxAccessGatewayClient::with_interceptor(channel, interceptor) + .max_decoding_message_size(max_grpc_message_bytes) + .max_encoding_message_size(max_grpc_message_bytes), call_timeout: options.call_timeout(), stream_timeout: options.stream_timeout(), }) diff --git a/clients/rust/src/galaxy.rs b/clients/rust/src/galaxy.rs index df3cdf0..72ae5ae 100644 --- a/clients/rust/src/galaxy.rs +++ b/clients/rust/src/galaxy.rs @@ -21,6 +21,8 @@ use crate::generated::galaxy_repository::v1::{ }; use crate::options::ClientOptions; +const DISCOVER_HIERARCHY_PAGE_SIZE: i32 = 5000; + /// Convenience alias for the generated Galaxy client wrapped in the /// authentication interceptor. pub type RawGalaxyClient = GalaxyRepositoryClient>; @@ -77,9 +79,12 @@ impl GalaxyClient { let channel = endpoint.connect().await?; let interceptor = AuthInterceptor::new(options.api_key().cloned()); + let max_grpc_message_bytes = options.max_grpc_message_bytes(); Ok(Self { - inner: GalaxyRepositoryClient::with_interceptor(channel, interceptor), + inner: GalaxyRepositoryClient::with_interceptor(channel, interceptor) + .max_decoding_message_size(max_grpc_message_bytes) + .max_encoding_message_size(max_grpc_message_bytes), call_timeout: options.call_timeout(), stream_timeout: options.stream_timeout(), }) @@ -89,8 +94,11 @@ impl GalaxyClient { /// channel. Tests use this to wire up an in-memory transport. pub fn from_channel(channel: Channel, options: &ClientOptions) -> Self { let interceptor = AuthInterceptor::new(options.api_key().cloned()); + let max_grpc_message_bytes = options.max_grpc_message_bytes(); Self { - inner: GalaxyRepositoryClient::with_interceptor(channel, interceptor), + inner: GalaxyRepositoryClient::with_interceptor(channel, interceptor) + .max_decoding_message_size(max_grpc_message_bytes) + .max_encoding_message_size(max_grpc_message_bytes), call_timeout: options.call_timeout(), stream_timeout: options.stream_timeout(), } @@ -135,11 +143,23 @@ impl GalaxyClient { /// Walk the deployed object hierarchy. Each [`GalaxyObject`] contains /// the object's identifying names plus its dynamic attributes. pub async fn discover_hierarchy(&mut self) -> Result, Error> { - let response = self - .inner - .discover_hierarchy(self.unary_request(DiscoverHierarchyRequest {})) - .await?; - Ok(response.into_inner().objects) + let mut objects = Vec::new(); + let mut page_token = String::new(); + loop { + let response = self + .inner + .discover_hierarchy(self.unary_request(DiscoverHierarchyRequest { + page_size: DISCOVER_HIERARCHY_PAGE_SIZE, + page_token, + })) + .await?; + let reply = response.into_inner(); + objects.extend(reply.objects); + page_token = reply.next_page_token; + if page_token.is_empty() { + return Ok(objects); + } + } } /// Subscribe to the server-streamed deploy-event feed. @@ -217,6 +237,8 @@ mod tests { present: Mutex, last_deploy: Mutex>, objects: Mutex>, + discover_requests: Mutex>, + discover_replies: Mutex>, watch_requests: Mutex>, watch_events: Mutex>, watch_senders: Mutex>, @@ -256,10 +278,21 @@ mod tests { async fn discover_hierarchy( &self, - _request: Request, + request: Request, ) -> Result, Status> { + self.state + .discover_requests + .lock() + .unwrap() + .push(request.into_inner()); + if let Some(reply) = self.state.discover_replies.lock().unwrap().pop_front() { + return Ok(Response::new(reply)); + } + Ok(Response::new(DiscoverHierarchyReply { objects: self.state.objects.lock().unwrap().clone(), + next_page_token: String::new(), + total_object_count: self.state.objects.lock().unwrap().len() as i32, })) } @@ -409,30 +442,58 @@ mod tests { #[tokio::test] async fn discover_hierarchy_returns_objects_with_attributes() { let state = Arc::new(FakeState::default()); - *state.objects.lock().unwrap() = vec![GalaxyObject { - gobject_id: 42, - tag_name: "DelmiaReceiver_001".to_owned(), - contained_name: "DelmiaReceiver".to_owned(), - browse_name: "TestMachine_001/DelmiaReceiver".to_owned(), - parent_gobject_id: 7, - is_area: false, - category_id: 3, - hosted_by_gobject_id: 1, - template_chain: vec!["$UserDefined".to_owned(), "$DelmiaReceiver".to_owned()], - attributes: vec![GalaxyAttribute { - attribute_name: "DownloadPath".to_owned(), - full_tag_reference: "DelmiaReceiver_001.DownloadPath".to_owned(), - mx_data_type: 8, - data_type_name: "MxString".to_owned(), - is_array: false, - array_dimension: 0, - array_dimension_present: false, - mx_attribute_category: 2, - security_classification: 1, - is_historized: false, - is_alarm: false, - }], - }]; + state + .discover_replies + .lock() + .unwrap() + .push_back(DiscoverHierarchyReply { + objects: vec![GalaxyObject { + gobject_id: 42, + tag_name: "DelmiaReceiver_001".to_owned(), + contained_name: "DelmiaReceiver".to_owned(), + browse_name: "TestMachine_001/DelmiaReceiver".to_owned(), + parent_gobject_id: 7, + is_area: false, + category_id: 3, + hosted_by_gobject_id: 1, + template_chain: vec!["$UserDefined".to_owned(), "$DelmiaReceiver".to_owned()], + attributes: vec![GalaxyAttribute { + attribute_name: "DownloadPath".to_owned(), + full_tag_reference: "DelmiaReceiver_001.DownloadPath".to_owned(), + mx_data_type: 8, + data_type_name: "MxString".to_owned(), + is_array: false, + array_dimension: 0, + array_dimension_present: false, + mx_attribute_category: 2, + security_classification: 1, + is_historized: false, + is_alarm: false, + }], + }], + next_page_token: "page-2".to_owned(), + total_object_count: 2, + }); + state + .discover_replies + .lock() + .unwrap() + .push_back(DiscoverHierarchyReply { + objects: vec![GalaxyObject { + gobject_id: 43, + tag_name: "DelmiaReceiver_002".to_owned(), + contained_name: String::new(), + browse_name: String::new(), + parent_gobject_id: 0, + is_area: false, + category_id: 0, + hosted_by_gobject_id: 0, + template_chain: Vec::new(), + attributes: Vec::new(), + }], + next_page_token: String::new(), + total_object_count: 2, + }); let endpoint = spawn_fake(state.clone()).await; let mut client = GalaxyClient::connect(ClientOptions::new(endpoint)) @@ -441,7 +502,12 @@ mod tests { let objects = client.discover_hierarchy().await.unwrap(); - assert_eq!(objects.len(), 1); + assert_eq!(objects.len(), 2); + let requests = state.discover_requests.lock().unwrap(); + assert_eq!(requests.len(), 2); + assert_eq!(requests[0].page_size, 5000); + assert_eq!(requests[0].page_token, ""); + assert_eq!(requests[1].page_token, "page-2"); assert_eq!(objects[0].tag_name, "DelmiaReceiver_001"); assert_eq!(objects[0].attributes.len(), 1); assert_eq!(objects[0].attributes[0].attribute_name, "DownloadPath"); diff --git a/clients/rust/src/options.rs b/clients/rust/src/options.rs index 4246335..3d5a9f6 100644 --- a/clients/rust/src/options.rs +++ b/clients/rust/src/options.rs @@ -4,6 +4,8 @@ use std::time::Duration; use crate::auth::ApiKey; +const DEFAULT_MAX_GRPC_MESSAGE_BYTES: usize = 16 * 1024 * 1024; + #[derive(Clone)] pub struct ClientOptions { endpoint: String, @@ -14,6 +16,7 @@ pub struct ClientOptions { connect_timeout: Duration, call_timeout: Duration, stream_timeout: Option, + max_grpc_message_bytes: usize, } impl ClientOptions { @@ -27,6 +30,7 @@ impl ClientOptions { connect_timeout: Duration::from_secs(10), call_timeout: Duration::from_secs(30), stream_timeout: None, + max_grpc_message_bytes: DEFAULT_MAX_GRPC_MESSAGE_BYTES, } } @@ -65,6 +69,11 @@ impl ClientOptions { self } + pub fn with_max_grpc_message_bytes(mut self, max_grpc_message_bytes: usize) -> Self { + self.max_grpc_message_bytes = max_grpc_message_bytes; + self + } + pub fn endpoint(&self) -> &str { &self.endpoint } @@ -96,6 +105,10 @@ impl ClientOptions { pub fn stream_timeout(&self) -> Option { self.stream_timeout } + + pub fn max_grpc_message_bytes(&self) -> usize { + self.max_grpc_message_bytes + } } impl Default for ClientOptions { @@ -116,6 +129,7 @@ impl fmt::Debug for ClientOptions { .field("connect_timeout", &self.connect_timeout) .field("call_timeout", &self.call_timeout) .field("stream_timeout", &self.stream_timeout) + .field("max_grpc_message_bytes", &self.max_grpc_message_bytes) .finish() } } diff --git a/clients/rust/src/version.rs b/clients/rust/src/version.rs index aee4d39..ce6f56a 100644 --- a/clients/rust/src/version.rs +++ b/clients/rust/src/version.rs @@ -1,3 +1,3 @@ pub const CLIENT_VERSION: &str = "0.1.0-dev"; -pub const GATEWAY_PROTOCOL_VERSION: u32 = 1; +pub const GATEWAY_PROTOCOL_VERSION: u32 = 2; pub const WORKER_PROTOCOL_VERSION: u32 = 1; diff --git a/docs/GalaxyRepository.md b/docs/GalaxyRepository.md index 6141f2b..a438f6c 100644 --- a/docs/GalaxyRepository.md +++ b/docs/GalaxyRepository.md @@ -32,13 +32,14 @@ The service is defined in |-----|---------| | `TestConnection` | Connectivity probe. Returns `{ ok: bool }` after a `SELECT 1`. Does not throw on SQL failure — returns `ok = false`. Always hits SQL directly so it remains a true health check. | | `GetLastDeployTime` | Returns the cached `galaxy.time_of_last_deploy`. Served from the shared hierarchy cache; refreshed in the background. | -| `DiscoverHierarchy` | Returns the full deployed hierarchy plus every object's dynamic attributes. **Served from cache** — see [Hierarchy Cache](#hierarchy-cache). | +| `DiscoverHierarchy` | Returns one page of the deployed hierarchy plus each returned object's dynamic attributes. **Served from cache** — see [Hierarchy Cache](#hierarchy-cache). | | `WatchDeployEvents` | **Server-streaming.** The server emits the current state immediately on subscribe (so clients can bootstrap without waiting), then emits one event per detected deploy change. See [Deploy Notifications](#deploy-notifications). | -`DiscoverHierarchy` is intentionally a single unary RPC rather than a stream: -the row set is small (thousands of objects, low tens-of-thousands of -attributes for typical Galaxies) and clients almost always want the whole tree -at once. +`DiscoverHierarchy` is a paged unary RPC. The raw request accepts `page_size` +and `page_token`; the server defaults omitted page size to 1000 objects and +caps every page at 5000 objects. Invalid page tokens and negative page sizes +return `InvalidArgument`. Official high-level clients preserve the older +"return the full hierarchy" behavior by looping pages internally. ## Hierarchy Cache @@ -56,12 +57,14 @@ Refresh strategy is **deploy-time gated**: 3. If the deploy timestamp is unchanged, the heavy hierarchy + attributes queries are **skipped**. The cache simply marks `LastSuccessAt`. 4. If the deploy timestamp changed (or no data has loaded yet), the cache - pulls hierarchy + attributes, materializes a `DiscoverHierarchyReply` - once, replaces the entry atomically, and publishes a deploy event. + pulls hierarchy + attributes, materializes a Galaxy object list plus a + dashboard summary once, replaces the entry atomically, and publishes a + deploy event. -Materializing the reply at refresh time means subsequent `DiscoverHierarchy` -calls return a pre-built proto message — no per-request projection, no -per-request allocations beyond the gRPC serializer's frame. +Materializing objects and dashboard summaries at refresh time means subsequent +`DiscoverHierarchy` calls page over an immutable object list. The dashboard +uses the precomputed summary and does not rescan raw SQL rowsets on each +snapshot. When SQL is unreachable, the cache retains the previous data and flips `Status` to `Stale` (or `Unavailable` if no data was ever loaded). A @@ -139,6 +142,17 @@ message GalaxyAttribute { bool is_historized = 10; bool is_alarm = 11; } + +message DiscoverHierarchyRequest { + int32 page_size = 1; // omitted/0 uses the server default of 1000 + string page_token = 2; // opaque offset token returned by the previous page +} + +message DiscoverHierarchyReply { + repeated GalaxyObject objects = 1; + string next_page_token = 2; + int32 total_object_count = 3; +} ``` ### Contained Name vs Tag Name @@ -176,7 +190,8 @@ GalaxyHierarchyRefreshService (BackgroundService) -> GalaxyRepository.GetLastDeployTimeAsync (cheap, every tick) -> GalaxyRepository.GetHierarchyAsync (only on deploy change) -> GalaxyRepository.GetAttributesAsync (only on deploy change) - -> GalaxyProtoMapper.MapObject (materialize DiscoverHierarchyReply once) + -> GalaxyProtoMapper.MapObject (materialize GalaxyObject list once) + -> DashboardGalaxySummary (precompute dashboard counts once) -> IGalaxyDeployNotifier.Publish (only on deploy change) ``` @@ -189,8 +204,9 @@ Component breakdown: recursive CTEs and pick the most-derived attribute override per object. - `GalaxyHierarchyCache` (`src/MxGateway.Server/Galaxy/GalaxyHierarchyCache.cs`) holds the most - recent immutable `GalaxyHierarchyCacheEntry` (rows + materialized proto - reply + counts + status). All gRPC clients share the same entry. + recent immutable `GalaxyHierarchyCacheEntry` (materialized objects + + precomputed dashboard summary + counts + status). All gRPC clients share the + same entry. - `GalaxyHierarchyRefreshService` (`src/MxGateway.Server/Galaxy/GalaxyHierarchyRefreshService.cs`) is a hosted `BackgroundService` that drives `RefreshAsync` on the configured @@ -220,6 +236,11 @@ Security`), but production deployments that use SQL authentication should set the override via environment variable rather than committing credentials to `appsettings.json`. +The dashboard parses this connection string and displays only non-secret +fields: server, database, integrated security, encrypt, and trust-server- +certificate. It never displays user id, password, access token, or arbitrary +unparsed connection string text. + ## Authorization All four Galaxy RPCs (including `WatchDeployEvents`) require the diff --git a/docs/GatewayConfiguration.md b/docs/GatewayConfiguration.md index d57bac5..0028115 100644 --- a/docs/GatewayConfiguration.md +++ b/docs/GatewayConfiguration.md @@ -35,6 +35,8 @@ paths, timeouts, queue sizes, enum values, or protocol values are invalid. "DefaultCommandTimeoutSeconds": 30, "MaxSessions": 64, "MaxPendingCommandsPerSession": 128, + "DefaultLeaseSeconds": 1800, + "LeaseSweepIntervalSeconds": 30, "AllowMultipleEventSubscribers": false }, "Events": { @@ -52,7 +54,8 @@ paths, timeouts, queue sizes, enum values, or protocol values are invalid. "ShowTagValues": false }, "Protocol": { - "WorkerProtocolVersion": 1 + "WorkerProtocolVersion": 1, + "MaxGrpcMessageBytes": 16777216 }, "Galaxy": { "ConnectionString": "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;", @@ -107,6 +110,8 @@ to avoid accidental large allocations from malformed or oversized frames. | `MxGateway:Sessions:DefaultCommandTimeoutSeconds` | `30` | Default timeout used while the gateway waits for a worker command reply when an open-session request does not provide a positive command timeout. | | `MxGateway:Sessions:MaxSessions` | `64` | Maximum number of concurrently open gateway sessions. Session opens reserve a slot atomically before worker creation. | | `MxGateway:Sessions:MaxPendingCommandsPerSession` | `128` | Maximum number of pending worker commands for one session. Excess commands fail fast instead of queueing indefinitely. | +| `MxGateway:Sessions:DefaultLeaseSeconds` | `1800` | Initial session lease and refresh duration. Unary client activity extends the lease by this duration. | +| `MxGateway:Sessions:LeaseSweepIntervalSeconds` | `30` | Hosted monitor interval for closing expired leases. Active event-stream subscribers keep a session from expiring while the stream remains attached. | | `MxGateway:Sessions:AllowMultipleEventSubscribers` | `false` | Controls whether multiple `StreamEvents` subscribers may attach to one session. `true` is rejected until event fan-out is implemented. | All numeric session options must be greater than zero. The current event stream @@ -146,6 +151,7 @@ and `RecentSessionLimit` must be greater than or equal to zero. | Option | Default | Description | |--------|---------|-------------| | `MxGateway:Protocol:WorkerProtocolVersion` | `1` | Worker IPC protocol version expected by the gateway and worker. This must match `GatewayContractInfo.WorkerProtocolVersion`. | +| `MxGateway:Protocol:MaxGrpcMessageBytes` | `16777216` | Public gRPC max send and receive message size in bytes. The same default is used by official clients. The validator allows values from `1024` through `268435456`. | The protocol option is exposed for diagnostics and explicit deployment configuration, not for compatibility negotiation. A mismatch fails validation diff --git a/docs/Grpc.md b/docs/Grpc.md index e72f45e..2606408 100644 --- a/docs/Grpc.md +++ b/docs/Grpc.md @@ -31,6 +31,11 @@ A second gRPC service, `GalaxyRepositoryGrpcService`, is mapped alongside it. It `MxAccessGatewayService` derives from the generated `MxAccessGateway.MxAccessGatewayBase` and implements every RPC declared in `mxaccess_gateway.proto`. The proto contract itself is documented in [Contracts](./Contracts.md); this section covers only what the server-side handler does on top of that contract. +Public gRPC send and receive message sizes are configured from +`MxGateway:Protocol:MaxGrpcMessageBytes` (default 16 MiB). Official clients use +the same default so paged Galaxy browse replies and larger MXAccess payloads +fail consistently instead of depending on language-specific gRPC defaults. + ### `OpenSession` `OpenSession` validates the request, asks `ISessionManager` to open a session under the caller's identity, and returns a reply that advertises both protocol versions and the capabilities the gateway supports. Capability strings are static because the gateway has a fixed feature set per build; clients use them as a forward-compatibility hint rather than runtime negotiation. diff --git a/docs/Sessions.md b/docs/Sessions.md index b4cbf9d..dde52c8 100644 --- a/docs/Sessions.md +++ b/docs/Sessions.md @@ -178,9 +178,9 @@ The order — fault, deregister, dispose, release slot, record metric, log, reth While `Ready`, callers reach the worker through `SessionManager.InvokeAsync` or `ReadEventsAsync`. Both delegate to `GatewaySession`, which checks the state under lock and updates `LastClientActivityAt` on every invocation. `GatewaySession` also exposes typed bulk helpers (`AddItemBulkAsync`, `SubscribeBulkAsync`, etc.) that wrap `WorkerCommand` round-trips and translate non-`Ok` `ProtocolStatus` replies into `SessionManagerException` with `SessionNotReady`. -Event streaming uses `AttachEventSubscriber` which returns a disposable lease. When `allowMultipleSubscribers` is false the second attach throws `EventSubscriberAlreadyActive`; this prevents two gRPC streams from racing on the same worker event channel. +Event streaming uses `AttachEventSubscriber` which returns a disposable lease. When `allowMultipleSubscribers` is false the second attach throws `EventSubscriberAlreadyActive`; this prevents two gRPC streams from racing on the same worker event channel. Active event subscribers keep the session lease from expiring until the stream is disposed. -`ExtendLease` and `IsLeaseExpired` cooperate with `SessionManager.CloseExpiredLeasesAsync`, which iterates a registry snapshot and closes any session whose lease has expired with `LeaseExpiredReason`. +Sessions open with `MxGateway:Sessions:DefaultLeaseSeconds` (default 1800) added to the open timestamp. Unary client activity refreshes the lease by the same duration. `ExtendLease` and `IsLeaseExpired` cooperate with `SessionManager.CloseExpiredLeasesAsync`, which iterates a registry snapshot and closes any session whose lease has expired with `LeaseExpiredReason`. `SessionLeaseMonitorHostedService` runs that sweep every `MxGateway:Sessions:LeaseSweepIntervalSeconds` seconds (default 30). ### Close diff --git a/src/MxGateway.Contracts/GatewayContractInfo.cs b/src/MxGateway.Contracts/GatewayContractInfo.cs index cfc34d9..a6b6c92 100644 --- a/src/MxGateway.Contracts/GatewayContractInfo.cs +++ b/src/MxGateway.Contracts/GatewayContractInfo.cs @@ -6,7 +6,7 @@ namespace MxGateway.Contracts; /// public static class GatewayContractInfo { - public const uint GatewayProtocolVersion = 1; + public const uint GatewayProtocolVersion = 2; public const uint WorkerProtocolVersion = 1; diff --git a/src/MxGateway.Contracts/Generated/GalaxyRepository.cs b/src/MxGateway.Contracts/Generated/GalaxyRepository.cs index 517e7f9..a2ada0d 100644 --- a/src/MxGateway.Contracts/Generated/GalaxyRepository.cs +++ b/src/MxGateway.Contracts/Generated/GalaxyRepository.cs @@ -29,41 +29,43 @@ namespace MxGateway.Contracts.Proto.Galaxy { "bm5lY3Rpb25SZXF1ZXN0IiEKE1Rlc3RDb25uZWN0aW9uUmVwbHkSCgoCb2sY", "ASABKAgiGgoYR2V0TGFzdERlcGxveVRpbWVSZXF1ZXN0ImIKFkdldExhc3RE", "ZXBsb3lUaW1lUmVwbHkSDwoHcHJlc2VudBgBIAEoCBI3ChN0aW1lX29mX2xh", - "c3RfZGVwbG95GAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCIa", - "ChhEaXNjb3ZlckhpZXJhcmNoeVJlcXVlc3QiTQoWRGlzY292ZXJIaWVyYXJj", - "aHlSZXBseRIzCgdvYmplY3RzGAEgAygLMiIuZ2FsYXh5X3JlcG9zaXRvcnku", - "djEuR2FsYXh5T2JqZWN0IlUKGFdhdGNoRGVwbG95RXZlbnRzUmVxdWVzdBI5", - "ChVsYXN0X3NlZW5fZGVwbG95X3RpbWUYASABKAsyGi5nb29nbGUucHJvdG9i", - "dWYuVGltZXN0YW1wIt0BCgtEZXBsb3lFdmVudBIQCghzZXF1ZW5jZRgBIAEo", - "BBIvCgtvYnNlcnZlZF9hdBgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1l", - "c3RhbXASNwoTdGltZV9vZl9sYXN0X2RlcGxveRgDIAEoCzIaLmdvb2dsZS5w", - "cm90b2J1Zi5UaW1lc3RhbXASIwobdGltZV9vZl9sYXN0X2RlcGxveV9wcmVz", - "ZW50GAQgASgIEhQKDG9iamVjdF9jb3VudBgFIAEoBRIXCg9hdHRyaWJ1dGVf", - "Y291bnQYBiABKAUikwIKDEdhbGF4eU9iamVjdBISCgpnb2JqZWN0X2lkGAEg", - "ASgFEhAKCHRhZ19uYW1lGAIgASgJEhYKDmNvbnRhaW5lZF9uYW1lGAMgASgJ", - "EhMKC2Jyb3dzZV9uYW1lGAQgASgJEhkKEXBhcmVudF9nb2JqZWN0X2lkGAUg", - "ASgFEg8KB2lzX2FyZWEYBiABKAgSEwoLY2F0ZWdvcnlfaWQYByABKAUSHAoU", - "aG9zdGVkX2J5X2dvYmplY3RfaWQYCCABKAUSFgoOdGVtcGxhdGVfY2hhaW4Y", - "CSADKAkSOQoKYXR0cmlidXRlcxgKIAMoCzIlLmdhbGF4eV9yZXBvc2l0b3J5", - "LnYxLkdhbGF4eUF0dHJpYnV0ZSKoAgoPR2FsYXh5QXR0cmlidXRlEhYKDmF0", - "dHJpYnV0ZV9uYW1lGAEgASgJEhoKEmZ1bGxfdGFnX3JlZmVyZW5jZRgCIAEo", - "CRIUCgxteF9kYXRhX3R5cGUYAyABKAUSFgoOZGF0YV90eXBlX25hbWUYBCAB", - "KAkSEAoIaXNfYXJyYXkYBSABKAgSFwoPYXJyYXlfZGltZW5zaW9uGAYgASgF", - "Eh8KF2FycmF5X2RpbWVuc2lvbl9wcmVzZW50GAcgASgIEh0KFW14X2F0dHJp", - "YnV0ZV9jYXRlZ29yeRgIIAEoBRIfChdzZWN1cml0eV9jbGFzc2lmaWNhdGlv", - "bhgJIAEoBRIVCg1pc19oaXN0b3JpemVkGAogASgIEhAKCGlzX2FsYXJtGAsg", - "ASgIMswDChBHYWxheHlSZXBvc2l0b3J5EmgKDlRlc3RDb25uZWN0aW9uEisu", - "Z2FsYXh5X3JlcG9zaXRvcnkudjEuVGVzdENvbm5lY3Rpb25SZXF1ZXN0Giku", - "Z2FsYXh5X3JlcG9zaXRvcnkudjEuVGVzdENvbm5lY3Rpb25SZXBseRJxChFH", - "ZXRMYXN0RGVwbG95VGltZRIuLmdhbGF4eV9yZXBvc2l0b3J5LnYxLkdldExh", - "c3REZXBsb3lUaW1lUmVxdWVzdBosLmdhbGF4eV9yZXBvc2l0b3J5LnYxLkdl", - "dExhc3REZXBsb3lUaW1lUmVwbHkScQoRRGlzY292ZXJIaWVyYXJjaHkSLi5n", - "YWxheHlfcmVwb3NpdG9yeS52MS5EaXNjb3ZlckhpZXJhcmNoeVJlcXVlc3Qa", - "LC5nYWxheHlfcmVwb3NpdG9yeS52MS5EaXNjb3ZlckhpZXJhcmNoeVJlcGx5", - "EmgKEVdhdGNoRGVwbG95RXZlbnRzEi4uZ2FsYXh5X3JlcG9zaXRvcnkudjEu", - "V2F0Y2hEZXBsb3lFdmVudHNSZXF1ZXN0GiEuZ2FsYXh5X3JlcG9zaXRvcnku", - "djEuRGVwbG95RXZlbnQwAUIjqgIgTXhHYXRld2F5LkNvbnRyYWN0cy5Qcm90", - "by5HYWxheHliBnByb3RvMw==")); + "c3RfZGVwbG95GAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCJB", + "ChhEaXNjb3ZlckhpZXJhcmNoeVJlcXVlc3QSEQoJcGFnZV9zaXplGAEgASgF", + "EhIKCnBhZ2VfdG9rZW4YAiABKAkiggEKFkRpc2NvdmVySGllcmFyY2h5UmVw", + "bHkSMwoHb2JqZWN0cxgBIAMoCzIiLmdhbGF4eV9yZXBvc2l0b3J5LnYxLkdh", + "bGF4eU9iamVjdBIXCg9uZXh0X3BhZ2VfdG9rZW4YAiABKAkSGgoSdG90YWxf", + "b2JqZWN0X2NvdW50GAMgASgFIlUKGFdhdGNoRGVwbG95RXZlbnRzUmVxdWVz", + "dBI5ChVsYXN0X3NlZW5fZGVwbG95X3RpbWUYASABKAsyGi5nb29nbGUucHJv", + "dG9idWYuVGltZXN0YW1wIt0BCgtEZXBsb3lFdmVudBIQCghzZXF1ZW5jZRgB", + "IAEoBBIvCgtvYnNlcnZlZF9hdBgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5U", + "aW1lc3RhbXASNwoTdGltZV9vZl9sYXN0X2RlcGxveRgDIAEoCzIaLmdvb2ds", + "ZS5wcm90b2J1Zi5UaW1lc3RhbXASIwobdGltZV9vZl9sYXN0X2RlcGxveV9w", + "cmVzZW50GAQgASgIEhQKDG9iamVjdF9jb3VudBgFIAEoBRIXCg9hdHRyaWJ1", + "dGVfY291bnQYBiABKAUikwIKDEdhbGF4eU9iamVjdBISCgpnb2JqZWN0X2lk", + "GAEgASgFEhAKCHRhZ19uYW1lGAIgASgJEhYKDmNvbnRhaW5lZF9uYW1lGAMg", + "ASgJEhMKC2Jyb3dzZV9uYW1lGAQgASgJEhkKEXBhcmVudF9nb2JqZWN0X2lk", + "GAUgASgFEg8KB2lzX2FyZWEYBiABKAgSEwoLY2F0ZWdvcnlfaWQYByABKAUS", + "HAoUaG9zdGVkX2J5X2dvYmplY3RfaWQYCCABKAUSFgoOdGVtcGxhdGVfY2hh", + "aW4YCSADKAkSOQoKYXR0cmlidXRlcxgKIAMoCzIlLmdhbGF4eV9yZXBvc2l0", + "b3J5LnYxLkdhbGF4eUF0dHJpYnV0ZSKoAgoPR2FsYXh5QXR0cmlidXRlEhYK", + "DmF0dHJpYnV0ZV9uYW1lGAEgASgJEhoKEmZ1bGxfdGFnX3JlZmVyZW5jZRgC", + "IAEoCRIUCgxteF9kYXRhX3R5cGUYAyABKAUSFgoOZGF0YV90eXBlX25hbWUY", + "BCABKAkSEAoIaXNfYXJyYXkYBSABKAgSFwoPYXJyYXlfZGltZW5zaW9uGAYg", + "ASgFEh8KF2FycmF5X2RpbWVuc2lvbl9wcmVzZW50GAcgASgIEh0KFW14X2F0", + "dHJpYnV0ZV9jYXRlZ29yeRgIIAEoBRIfChdzZWN1cml0eV9jbGFzc2lmaWNh", + "dGlvbhgJIAEoBRIVCg1pc19oaXN0b3JpemVkGAogASgIEhAKCGlzX2FsYXJt", + "GAsgASgIMswDChBHYWxheHlSZXBvc2l0b3J5EmgKDlRlc3RDb25uZWN0aW9u", + "EisuZ2FsYXh5X3JlcG9zaXRvcnkudjEuVGVzdENvbm5lY3Rpb25SZXF1ZXN0", + "GikuZ2FsYXh5X3JlcG9zaXRvcnkudjEuVGVzdENvbm5lY3Rpb25SZXBseRJx", + "ChFHZXRMYXN0RGVwbG95VGltZRIuLmdhbGF4eV9yZXBvc2l0b3J5LnYxLkdl", + "dExhc3REZXBsb3lUaW1lUmVxdWVzdBosLmdhbGF4eV9yZXBvc2l0b3J5LnYx", + "LkdldExhc3REZXBsb3lUaW1lUmVwbHkScQoRRGlzY292ZXJIaWVyYXJjaHkS", + "Li5nYWxheHlfcmVwb3NpdG9yeS52MS5EaXNjb3ZlckhpZXJhcmNoeVJlcXVl", + "c3QaLC5nYWxheHlfcmVwb3NpdG9yeS52MS5EaXNjb3ZlckhpZXJhcmNoeVJl", + "cGx5EmgKEVdhdGNoRGVwbG95RXZlbnRzEi4uZ2FsYXh5X3JlcG9zaXRvcnku", + "djEuV2F0Y2hEZXBsb3lFdmVudHNSZXF1ZXN0GiEuZ2FsYXh5X3JlcG9zaXRv", + "cnkudjEuRGVwbG95RXZlbnQwAUIjqgIgTXhHYXRld2F5LkNvbnRyYWN0cy5Q", + "cm90by5HYWxheHliBnByb3RvMw==")); descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData, new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, }, new pbr::GeneratedClrTypeInfo(null, null, new pbr::GeneratedClrTypeInfo[] { @@ -71,8 +73,8 @@ namespace MxGateway.Contracts.Proto.Galaxy { new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Galaxy.TestConnectionReply), global::MxGateway.Contracts.Proto.Galaxy.TestConnectionReply.Parser, new[]{ "Ok" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeRequest), global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeRequest.Parser, null, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeReply), global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeReply.Parser, new[]{ "Present", "TimeOfLastDeploy" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest), global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest.Parser, null, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyReply), global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyReply.Parser, new[]{ "Objects" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest), global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest.Parser, new[]{ "PageSize", "PageToken" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyReply), global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyReply.Parser, new[]{ "Objects", "NextPageToken", "TotalObjectCount" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Galaxy.WatchDeployEventsRequest), global::MxGateway.Contracts.Proto.Galaxy.WatchDeployEventsRequest.Parser, new[]{ "LastSeenDeployTime" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Galaxy.DeployEvent), global::MxGateway.Contracts.Proto.Galaxy.DeployEvent.Parser, new[]{ "Sequence", "ObservedAt", "TimeOfLastDeploy", "TimeOfLastDeployPresent", "ObjectCount", "AttributeCount" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Galaxy.GalaxyObject), global::MxGateway.Contracts.Proto.Galaxy.GalaxyObject.Parser, new[]{ "GobjectId", "TagName", "ContainedName", "BrowseName", "ParentGobjectId", "IsArea", "CategoryId", "HostedByGobjectId", "TemplateChain", "Attributes" }, null, null, null, null), @@ -882,6 +884,8 @@ namespace MxGateway.Contracts.Proto.Galaxy { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public DiscoverHierarchyRequest(DiscoverHierarchyRequest other) : this() { + pageSize_ = other.pageSize_; + pageToken_ = other.pageToken_; _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); } @@ -891,6 +895,37 @@ namespace MxGateway.Contracts.Proto.Galaxy { return new DiscoverHierarchyRequest(this); } + /// Field number for the "page_size" field. + public const int PageSizeFieldNumber = 1; + private int pageSize_; + /// + /// Maximum number of objects to return. The server applies its default when + /// unset and rejects non-positive values. + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int PageSize { + get { return pageSize_; } + set { + pageSize_ = value; + } + } + + /// Field number for the "page_token" field. + public const int PageTokenFieldNumber = 2; + private string pageToken_ = ""; + /// + /// Opaque token returned by a previous DiscoverHierarchy response. + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string PageToken { + get { return pageToken_; } + set { + pageToken_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override bool Equals(object other) { @@ -906,6 +941,8 @@ namespace MxGateway.Contracts.Proto.Galaxy { if (ReferenceEquals(other, this)) { return true; } + if (PageSize != other.PageSize) return false; + if (PageToken != other.PageToken) return false; return Equals(_unknownFields, other._unknownFields); } @@ -913,6 +950,8 @@ namespace MxGateway.Contracts.Proto.Galaxy { [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override int GetHashCode() { int hash = 1; + if (PageSize != 0) hash ^= PageSize.GetHashCode(); + if (PageToken.Length != 0) hash ^= PageToken.GetHashCode(); if (_unknownFields != null) { hash ^= _unknownFields.GetHashCode(); } @@ -931,6 +970,14 @@ namespace MxGateway.Contracts.Proto.Galaxy { #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE output.WriteRawMessage(this); #else + if (PageSize != 0) { + output.WriteRawTag(8); + output.WriteInt32(PageSize); + } + if (PageToken.Length != 0) { + output.WriteRawTag(18); + output.WriteString(PageToken); + } if (_unknownFields != null) { _unknownFields.WriteTo(output); } @@ -941,6 +988,14 @@ namespace MxGateway.Contracts.Proto.Galaxy { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { + if (PageSize != 0) { + output.WriteRawTag(8); + output.WriteInt32(PageSize); + } + if (PageToken.Length != 0) { + output.WriteRawTag(18); + output.WriteString(PageToken); + } if (_unknownFields != null) { _unknownFields.WriteTo(ref output); } @@ -951,6 +1006,12 @@ namespace MxGateway.Contracts.Proto.Galaxy { [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public int CalculateSize() { int size = 0; + if (PageSize != 0) { + size += 1 + pb::CodedOutputStream.ComputeInt32Size(PageSize); + } + if (PageToken.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(PageToken); + } if (_unknownFields != null) { size += _unknownFields.CalculateSize(); } @@ -963,6 +1024,12 @@ namespace MxGateway.Contracts.Proto.Galaxy { if (other == null) { return; } + if (other.PageSize != 0) { + PageSize = other.PageSize; + } + if (other.PageToken.Length != 0) { + PageToken = other.PageToken; + } _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); } @@ -982,6 +1049,14 @@ namespace MxGateway.Contracts.Proto.Galaxy { default: _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); break; + case 8: { + PageSize = input.ReadInt32(); + break; + } + case 18: { + PageToken = input.ReadString(); + break; + } } } #endif @@ -1001,6 +1076,14 @@ namespace MxGateway.Contracts.Proto.Galaxy { default: _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); break; + case 8: { + PageSize = input.ReadInt32(); + break; + } + case 18: { + PageToken = input.ReadString(); + break; + } } } } @@ -1044,6 +1127,8 @@ namespace MxGateway.Contracts.Proto.Galaxy { [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public DiscoverHierarchyReply(DiscoverHierarchyReply other) : this() { objects_ = other.objects_.Clone(); + nextPageToken_ = other.nextPageToken_; + totalObjectCount_ = other.totalObjectCount_; _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); } @@ -1064,6 +1149,36 @@ namespace MxGateway.Contracts.Proto.Galaxy { get { return objects_; } } + /// Field number for the "next_page_token" field. + public const int NextPageTokenFieldNumber = 2; + private string nextPageToken_ = ""; + /// + /// Non-empty when another page is available. + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string NextPageToken { + get { return nextPageToken_; } + set { + nextPageToken_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "total_object_count" field. + public const int TotalObjectCountFieldNumber = 3; + private int totalObjectCount_; + /// + /// Total number of objects in the cached hierarchy at the time of the call. + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int TotalObjectCount { + get { return totalObjectCount_; } + set { + totalObjectCount_ = value; + } + } + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override bool Equals(object other) { @@ -1080,6 +1195,8 @@ namespace MxGateway.Contracts.Proto.Galaxy { return true; } if(!objects_.Equals(other.objects_)) return false; + if (NextPageToken != other.NextPageToken) return false; + if (TotalObjectCount != other.TotalObjectCount) return false; return Equals(_unknownFields, other._unknownFields); } @@ -1088,6 +1205,8 @@ namespace MxGateway.Contracts.Proto.Galaxy { public override int GetHashCode() { int hash = 1; hash ^= objects_.GetHashCode(); + if (NextPageToken.Length != 0) hash ^= NextPageToken.GetHashCode(); + if (TotalObjectCount != 0) hash ^= TotalObjectCount.GetHashCode(); if (_unknownFields != null) { hash ^= _unknownFields.GetHashCode(); } @@ -1107,6 +1226,14 @@ namespace MxGateway.Contracts.Proto.Galaxy { output.WriteRawMessage(this); #else objects_.WriteTo(output, _repeated_objects_codec); + if (NextPageToken.Length != 0) { + output.WriteRawTag(18); + output.WriteString(NextPageToken); + } + if (TotalObjectCount != 0) { + output.WriteRawTag(24); + output.WriteInt32(TotalObjectCount); + } if (_unknownFields != null) { _unknownFields.WriteTo(output); } @@ -1118,6 +1245,14 @@ namespace MxGateway.Contracts.Proto.Galaxy { [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { objects_.WriteTo(ref output, _repeated_objects_codec); + if (NextPageToken.Length != 0) { + output.WriteRawTag(18); + output.WriteString(NextPageToken); + } + if (TotalObjectCount != 0) { + output.WriteRawTag(24); + output.WriteInt32(TotalObjectCount); + } if (_unknownFields != null) { _unknownFields.WriteTo(ref output); } @@ -1129,6 +1264,12 @@ namespace MxGateway.Contracts.Proto.Galaxy { public int CalculateSize() { int size = 0; size += objects_.CalculateSize(_repeated_objects_codec); + if (NextPageToken.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(NextPageToken); + } + if (TotalObjectCount != 0) { + size += 1 + pb::CodedOutputStream.ComputeInt32Size(TotalObjectCount); + } if (_unknownFields != null) { size += _unknownFields.CalculateSize(); } @@ -1142,6 +1283,12 @@ namespace MxGateway.Contracts.Proto.Galaxy { return; } objects_.Add(other.objects_); + if (other.NextPageToken.Length != 0) { + NextPageToken = other.NextPageToken; + } + if (other.TotalObjectCount != 0) { + TotalObjectCount = other.TotalObjectCount; + } _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); } @@ -1165,6 +1312,14 @@ namespace MxGateway.Contracts.Proto.Galaxy { objects_.AddEntriesFrom(input, _repeated_objects_codec); break; } + case 18: { + NextPageToken = input.ReadString(); + break; + } + case 24: { + TotalObjectCount = input.ReadInt32(); + break; + } } } #endif @@ -1188,6 +1343,14 @@ namespace MxGateway.Contracts.Proto.Galaxy { objects_.AddEntriesFrom(ref input, _repeated_objects_codec); break; } + case 18: { + NextPageToken = input.ReadString(); + break; + } + case 24: { + TotalObjectCount = input.ReadInt32(); + break; + } } } } diff --git a/src/MxGateway.Contracts/Protos/galaxy_repository.proto b/src/MxGateway.Contracts/Protos/galaxy_repository.proto index c4a87f5..1ae9b9d 100644 --- a/src/MxGateway.Contracts/Protos/galaxy_repository.proto +++ b/src/MxGateway.Contracts/Protos/galaxy_repository.proto @@ -37,10 +37,20 @@ message GetLastDeployTimeReply { google.protobuf.Timestamp time_of_last_deploy = 2; } -message DiscoverHierarchyRequest {} +message DiscoverHierarchyRequest { + // Maximum number of objects to return. The server applies its default when + // unset and rejects non-positive values. + int32 page_size = 1; + // Opaque token returned by a previous DiscoverHierarchy response. + string page_token = 2; +} message DiscoverHierarchyReply { repeated GalaxyObject objects = 1; + // Non-empty when another page is available. + string next_page_token = 2; + // Total number of objects in the cached hierarchy at the time of the call. + int32 total_object_count = 3; } message WatchDeployEventsRequest { diff --git a/src/MxGateway.Server/Configuration/EffectiveProtocolConfiguration.cs b/src/MxGateway.Server/Configuration/EffectiveProtocolConfiguration.cs index 00b0d7e..c54e184 100644 --- a/src/MxGateway.Server/Configuration/EffectiveProtocolConfiguration.cs +++ b/src/MxGateway.Server/Configuration/EffectiveProtocolConfiguration.cs @@ -1,3 +1,5 @@ namespace MxGateway.Server.Configuration; -public sealed record EffectiveProtocolConfiguration(uint WorkerProtocolVersion); +public sealed record EffectiveProtocolConfiguration( + uint WorkerProtocolVersion, + int MaxGrpcMessageBytes); diff --git a/src/MxGateway.Server/Configuration/EffectiveSessionConfiguration.cs b/src/MxGateway.Server/Configuration/EffectiveSessionConfiguration.cs index 24e9e74..9753c20 100644 --- a/src/MxGateway.Server/Configuration/EffectiveSessionConfiguration.cs +++ b/src/MxGateway.Server/Configuration/EffectiveSessionConfiguration.cs @@ -3,4 +3,7 @@ namespace MxGateway.Server.Configuration; public sealed record EffectiveSessionConfiguration( int DefaultCommandTimeoutSeconds, int MaxSessions, + int MaxPendingCommandsPerSession, + int DefaultLeaseSeconds, + int LeaseSweepIntervalSeconds, bool AllowMultipleEventSubscribers); diff --git a/src/MxGateway.Server/Configuration/GatewayConfigurationProvider.cs b/src/MxGateway.Server/Configuration/GatewayConfigurationProvider.cs index 69c97bf..aae48fa 100644 --- a/src/MxGateway.Server/Configuration/GatewayConfigurationProvider.cs +++ b/src/MxGateway.Server/Configuration/GatewayConfigurationProvider.cs @@ -28,6 +28,9 @@ public sealed class GatewayConfigurationProvider(IOptions option Sessions: new EffectiveSessionConfiguration( DefaultCommandTimeoutSeconds: value.Sessions.DefaultCommandTimeoutSeconds, MaxSessions: value.Sessions.MaxSessions, + MaxPendingCommandsPerSession: value.Sessions.MaxPendingCommandsPerSession, + DefaultLeaseSeconds: value.Sessions.DefaultLeaseSeconds, + LeaseSweepIntervalSeconds: value.Sessions.LeaseSweepIntervalSeconds, AllowMultipleEventSubscribers: value.Sessions.AllowMultipleEventSubscribers), Events: new EffectiveEventConfiguration( QueueCapacity: value.Events.QueueCapacity, @@ -41,6 +44,8 @@ public sealed class GatewayConfigurationProvider(IOptions option RecentFaultLimit: value.Dashboard.RecentFaultLimit, RecentSessionLimit: value.Dashboard.RecentSessionLimit, ShowTagValues: value.Dashboard.ShowTagValues), - Protocol: new EffectiveProtocolConfiguration(value.Protocol.WorkerProtocolVersion)); + Protocol: new EffectiveProtocolConfiguration( + value.Protocol.WorkerProtocolVersion, + value.Protocol.MaxGrpcMessageBytes)); } } diff --git a/src/MxGateway.Server/Configuration/GatewayOptionsValidator.cs b/src/MxGateway.Server/Configuration/GatewayOptionsValidator.cs index c63884d..aee0fa9 100644 --- a/src/MxGateway.Server/Configuration/GatewayOptionsValidator.cs +++ b/src/MxGateway.Server/Configuration/GatewayOptionsValidator.cs @@ -129,6 +129,14 @@ public sealed class GatewayOptionsValidator : IValidateOptions options.MaxPendingCommandsPerSession, "MxGateway:Sessions:MaxPendingCommandsPerSession must be greater than zero.", failures); + AddIfNotPositive( + options.DefaultLeaseSeconds, + "MxGateway:Sessions:DefaultLeaseSeconds must be greater than zero.", + failures); + AddIfNotPositive( + options.LeaseSweepIntervalSeconds, + "MxGateway:Sessions:LeaseSweepIntervalSeconds must be greater than zero.", + failures); if (options.AllowMultipleEventSubscribers) { @@ -179,6 +187,12 @@ public sealed class GatewayOptionsValidator : IValidateOptions failures.Add( $"MxGateway:Protocol:WorkerProtocolVersion must be {GatewayContractInfo.WorkerProtocolVersion}."); } + + if (options.MaxGrpcMessageBytes is < MinimumMaxMessageBytes or > MaximumMaxMessageBytes) + { + failures.Add( + $"MxGateway:Protocol:MaxGrpcMessageBytes must be between {MinimumMaxMessageBytes} and {MaximumMaxMessageBytes}."); + } } private static void AddIfBlank(string? value, string message, List failures) diff --git a/src/MxGateway.Server/Configuration/ProtocolOptions.cs b/src/MxGateway.Server/Configuration/ProtocolOptions.cs index 4f75ec3..303968b 100644 --- a/src/MxGateway.Server/Configuration/ProtocolOptions.cs +++ b/src/MxGateway.Server/Configuration/ProtocolOptions.cs @@ -5,4 +5,6 @@ namespace MxGateway.Server.Configuration; public sealed class ProtocolOptions { public uint WorkerProtocolVersion { get; init; } = GatewayContractInfo.WorkerProtocolVersion; + + public int MaxGrpcMessageBytes { get; init; } = 16 * 1024 * 1024; } diff --git a/src/MxGateway.Server/Configuration/SessionOptions.cs b/src/MxGateway.Server/Configuration/SessionOptions.cs index 4fdb001..8210272 100644 --- a/src/MxGateway.Server/Configuration/SessionOptions.cs +++ b/src/MxGateway.Server/Configuration/SessionOptions.cs @@ -8,5 +8,9 @@ public sealed class SessionOptions public int MaxPendingCommandsPerSession { get; init; } = 128; + public int DefaultLeaseSeconds { get; init; } = 1800; + + public int LeaseSweepIntervalSeconds { get; init; } = 30; + public bool AllowMultipleEventSubscribers { get; init; } } diff --git a/src/MxGateway.Server/Dashboard/Components/Pages/GalaxyPage.razor b/src/MxGateway.Server/Dashboard/Components/Pages/GalaxyPage.razor index a858e77..b9f64fd 100644 --- a/src/MxGateway.Server/Dashboard/Components/Pages/GalaxyPage.razor +++ b/src/MxGateway.Server/Dashboard/Components/Pages/GalaxyPage.razor @@ -190,6 +190,8 @@ else private int CommandTimeoutSeconds() => GalaxyOptions.Value.CommandTimeoutSeconds; - private string? GalaxyConnectionStringDisplay() => - DashboardRedactor.Redact(GalaxyOptions.Value.ConnectionString); + private string GalaxyConnectionStringDisplay() + { + return DashboardConnectionStringDisplay.GalaxyRepositoryConnectionString(GalaxyOptions.Value.ConnectionString); + } } diff --git a/src/MxGateway.Server/Dashboard/DashboardConnectionStringDisplay.cs b/src/MxGateway.Server/Dashboard/DashboardConnectionStringDisplay.cs new file mode 100644 index 0000000..64ce123 --- /dev/null +++ b/src/MxGateway.Server/Dashboard/DashboardConnectionStringDisplay.cs @@ -0,0 +1,28 @@ +using Microsoft.Data.SqlClient; + +namespace MxGateway.Server.Dashboard; + +public static class DashboardConnectionStringDisplay +{ + public static string GalaxyRepositoryConnectionString(string connectionString) + { + try + { + SqlConnectionStringBuilder builder = new(connectionString); + SqlConnectionStringBuilder display = new() + { + DataSource = builder.DataSource, + InitialCatalog = builder.InitialCatalog, + IntegratedSecurity = builder.IntegratedSecurity, + Encrypt = builder.Encrypt, + TrustServerCertificate = builder.TrustServerCertificate, + }; + + return display.ConnectionString; + } + catch (ArgumentException) + { + return "[invalid connection string]"; + } + } +} diff --git a/src/MxGateway.Server/Dashboard/DashboardGalaxyProjector.cs b/src/MxGateway.Server/Dashboard/DashboardGalaxyProjector.cs index 03b6bfe..575f73e 100644 --- a/src/MxGateway.Server/Dashboard/DashboardGalaxyProjector.cs +++ b/src/MxGateway.Server/Dashboard/DashboardGalaxyProjector.cs @@ -2,97 +2,11 @@ using MxGateway.Server.Galaxy; namespace MxGateway.Server.Dashboard; -/// -/// Projects a into a -/// for the Blazor pages. Top-templates and -/// per-category breakdowns are computed here rather than stored on the cache so the -/// Galaxy namespace stays free of dashboard-presentation concepts. -/// +/// Projects the precomputed Galaxy cache dashboard summary. internal static class DashboardGalaxyProjector { - private const int TopTemplatesLimit = 10; - - private static readonly IReadOnlyDictionary CategoryNamesById = new Dictionary - { - [1] = "WinPlatform", - [3] = "AppEngine", - [4] = "InTouchViewApp", - [10] = "UserDefined", - [11] = "FieldReference", - [13] = "Area", - [17] = "DIObject", - [24] = "DDESuiteLinkClient", - [26] = "OPCClient", - }; - public static DashboardGalaxySummary Project(GalaxyHierarchyCacheEntry entry) { - DashboardGalaxyStatus status = entry.Status switch - { - GalaxyCacheStatus.Healthy => DashboardGalaxyStatus.Healthy, - GalaxyCacheStatus.Stale => DashboardGalaxyStatus.Stale, - GalaxyCacheStatus.Unavailable => DashboardGalaxyStatus.Unavailable, - _ => DashboardGalaxyStatus.Unknown, - }; - - IReadOnlyList topTemplates; - IReadOnlyList objectCategories; - - if (entry.Hierarchy.Count == 0) - { - topTemplates = Array.Empty(); - objectCategories = Array.Empty(); - } - else - { - Dictionary objectsByCategory = new(); - Dictionary templateUsage = new(StringComparer.OrdinalIgnoreCase); - - foreach (GalaxyHierarchyRow row in entry.Hierarchy) - { - objectsByCategory.TryGetValue(row.CategoryId, out int categoryCount); - objectsByCategory[row.CategoryId] = categoryCount + 1; - - if (row.TemplateChain.Count > 0) - { - string immediate = row.TemplateChain[0]; - if (!string.IsNullOrWhiteSpace(immediate)) - { - templateUsage.TryGetValue(immediate, out int templateCount); - templateUsage[immediate] = templateCount + 1; - } - } - } - - topTemplates = templateUsage - .OrderByDescending(entry => entry.Value) - .ThenBy(entry => entry.Key, StringComparer.OrdinalIgnoreCase) - .Take(TopTemplatesLimit) - .Select(entry => new DashboardGalaxyTemplateUsage(entry.Key, entry.Value)) - .ToArray(); - - objectCategories = objectsByCategory - .OrderByDescending(entry => entry.Value) - .ThenBy(entry => entry.Key) - .Select(entry => new DashboardGalaxyCategoryCount( - entry.Key, - CategoryNamesById.TryGetValue(entry.Key, out string? name) ? name : $"Category {entry.Key}", - entry.Value)) - .ToArray(); - } - - return new DashboardGalaxySummary( - Status: status, - LastQueriedAt: entry.LastQueriedAt, - LastSuccessAt: entry.LastSuccessAt, - LastDeployTime: entry.LastDeployTime, - LastError: entry.LastError, - ObjectCount: entry.ObjectCount, - AreaCount: entry.AreaCount, - AttributeCount: entry.AttributeCount, - HistorizedAttributeCount: entry.HistorizedAttributeCount, - AlarmAttributeCount: entry.AlarmAttributeCount, - TopTemplates: topTemplates, - ObjectCategories: objectCategories); + return entry.DashboardSummary; } } diff --git a/src/MxGateway.Server/Galaxy/GalaxyHierarchyCache.cs b/src/MxGateway.Server/Galaxy/GalaxyHierarchyCache.cs index 3f0b7f1..1c1e544 100644 --- a/src/MxGateway.Server/Galaxy/GalaxyHierarchyCache.cs +++ b/src/MxGateway.Server/Galaxy/GalaxyHierarchyCache.cs @@ -2,6 +2,7 @@ using Google.Protobuf.WellKnownTypes; using Microsoft.Data.SqlClient; using Microsoft.Extensions.Logging; using MxGateway.Contracts.Proto.Galaxy; +using MxGateway.Server.Dashboard; using MxGateway.Server.Grpc; namespace MxGateway.Server.Galaxy; @@ -43,7 +44,16 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache { GalaxyHierarchyCacheEntry snapshot = Volatile.Read(ref _current); GalaxyCacheStatus projected = ProjectStatus(snapshot); - return projected == snapshot.Status ? snapshot : snapshot with { Status = projected }; + return projected == snapshot.Status + ? snapshot + : snapshot with + { + Status = projected, + DashboardSummary = snapshot.DashboardSummary with + { + Status = MapDashboardStatus(projected), + }, + }; } } @@ -101,11 +111,23 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache List hierarchy = hierarchyTask.Result; List attributes = attributesTask.Result; - DiscoverHierarchyReply reply = BuildReply(hierarchy, attributes); + IReadOnlyList objects = BuildObjects(hierarchy, attributes); int areaCount = hierarchy.Count(row => row.IsArea); int historized = attributes.Count(row => row.IsHistorized); int alarms = attributes.Count(row => row.IsAlarm); + DashboardGalaxySummary dashboardSummary = BuildDashboardSummary( + status: GalaxyCacheStatus.Healthy, + lastQueriedAt: queriedAt, + lastSuccessAt: queriedAt, + lastDeployTime: deployTime, + lastError: null, + hierarchy: hierarchy, + objectCount: hierarchy.Count, + areaCount: areaCount, + attributeCount: attributes.Count, + historizedAttributeCount: historized, + alarmAttributeCount: alarms); long nextSequence = previous.Sequence + 1; GalaxyHierarchyCacheEntry next = new( @@ -115,9 +137,8 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache LastSuccessAt: queriedAt, LastDeployTime: deployTime, LastError: null, - Hierarchy: hierarchy, - Attributes: attributes, - Reply: reply, + Objects: objects, + DashboardSummary: dashboardSummary, ObjectCount: hierarchy.Count, AreaCount: areaCount, AttributeCount: attributes.Count, @@ -146,13 +167,19 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache Status = previous.HasData ? GalaxyCacheStatus.Stale : GalaxyCacheStatus.Unavailable, LastQueriedAt = queriedAt, LastError = exception.Message, + DashboardSummary = previous.DashboardSummary with + { + Status = MapDashboardStatus(previous.HasData ? GalaxyCacheStatus.Stale : GalaxyCacheStatus.Unavailable), + LastQueriedAt = queriedAt, + LastError = exception.Message, + }, }; Volatile.Write(ref _current, failed); _firstLoad.TrySetResult(); } } - private static DiscoverHierarchyReply BuildReply( + private static IReadOnlyList BuildObjects( IReadOnlyList hierarchy, IReadOnlyList attributes) { @@ -160,14 +187,110 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache .GroupBy(a => a.GobjectId) .ToDictionary(g => g.Key, g => g.ToList()); - DiscoverHierarchyReply reply = new(); + List objects = new(hierarchy.Count); foreach (GalaxyHierarchyRow row in hierarchy) { - reply.Objects.Add(GalaxyProtoMapper.MapObject(row, attributesByGobjectId)); + objects.Add(GalaxyProtoMapper.MapObject(row, attributesByGobjectId)); } - return reply; + return objects; } + private static DashboardGalaxySummary BuildDashboardSummary( + GalaxyCacheStatus status, + DateTimeOffset? lastQueriedAt, + DateTimeOffset? lastSuccessAt, + DateTimeOffset? lastDeployTime, + string? lastError, + IReadOnlyList hierarchy, + int objectCount, + int areaCount, + int attributeCount, + int historizedAttributeCount, + int alarmAttributeCount) + { + IReadOnlyList topTemplates; + IReadOnlyList objectCategories; + + if (hierarchy.Count == 0) + { + topTemplates = Array.Empty(); + objectCategories = Array.Empty(); + } + else + { + Dictionary objectsByCategory = new(); + Dictionary templateUsage = new(StringComparer.OrdinalIgnoreCase); + + foreach (GalaxyHierarchyRow row in hierarchy) + { + objectsByCategory.TryGetValue(row.CategoryId, out int categoryCount); + objectsByCategory[row.CategoryId] = categoryCount + 1; + + if (row.TemplateChain.Count > 0) + { + string immediate = row.TemplateChain[0]; + if (!string.IsNullOrWhiteSpace(immediate)) + { + templateUsage.TryGetValue(immediate, out int templateCount); + templateUsage[immediate] = templateCount + 1; + } + } + } + + topTemplates = templateUsage + .OrderByDescending(entry => entry.Value) + .ThenBy(entry => entry.Key, StringComparer.OrdinalIgnoreCase) + .Take(10) + .Select(entry => new DashboardGalaxyTemplateUsage(entry.Key, entry.Value)) + .ToArray(); + + objectCategories = objectsByCategory + .OrderByDescending(entry => entry.Value) + .ThenBy(entry => entry.Key) + .Select(entry => new DashboardGalaxyCategoryCount( + entry.Key, + ResolveCategoryName(entry.Key), + entry.Value)) + .ToArray(); + } + + return new DashboardGalaxySummary( + Status: MapDashboardStatus(status), + LastQueriedAt: lastQueriedAt, + LastSuccessAt: lastSuccessAt, + LastDeployTime: lastDeployTime, + LastError: lastError, + ObjectCount: objectCount, + AreaCount: areaCount, + AttributeCount: attributeCount, + HistorizedAttributeCount: historizedAttributeCount, + AlarmAttributeCount: alarmAttributeCount, + TopTemplates: topTemplates, + ObjectCategories: objectCategories); + } + + private static DashboardGalaxyStatus MapDashboardStatus(GalaxyCacheStatus status) => status switch + { + GalaxyCacheStatus.Healthy => DashboardGalaxyStatus.Healthy, + GalaxyCacheStatus.Stale => DashboardGalaxyStatus.Stale, + GalaxyCacheStatus.Unavailable => DashboardGalaxyStatus.Unavailable, + _ => DashboardGalaxyStatus.Unknown, + }; + + private static string ResolveCategoryName(int categoryId) => categoryId switch + { + 1 => "WinPlatform", + 3 => "AppEngine", + 4 => "InTouchViewApp", + 10 => "UserDefined", + 11 => "FieldReference", + 13 => "Area", + 17 => "DIObject", + 24 => "DDESuiteLinkClient", + 26 => "OPCClient", + _ => $"Category {categoryId}", + }; + private GalaxyCacheStatus ProjectStatus(GalaxyHierarchyCacheEntry snapshot) { if (snapshot.Status is GalaxyCacheStatus.Unknown or GalaxyCacheStatus.Unavailable) diff --git a/src/MxGateway.Server/Galaxy/GalaxyHierarchyCacheEntry.cs b/src/MxGateway.Server/Galaxy/GalaxyHierarchyCacheEntry.cs index 3b7fcf2..beafe2c 100644 --- a/src/MxGateway.Server/Galaxy/GalaxyHierarchyCacheEntry.cs +++ b/src/MxGateway.Server/Galaxy/GalaxyHierarchyCacheEntry.cs @@ -1,11 +1,12 @@ using MxGateway.Contracts.Proto.Galaxy; +using MxGateway.Server.Dashboard; namespace MxGateway.Server.Galaxy; /// /// Immutable snapshot of the Galaxy Repository browse data held by -/// . Multiple gRPC clients share the same instance — -/// the materialized is produced once per refresh and reused. +/// . Multiple gRPC clients share the same +/// materialized object list and precomputed dashboard projection. /// public sealed record GalaxyHierarchyCacheEntry( GalaxyCacheStatus Status, @@ -14,9 +15,8 @@ public sealed record GalaxyHierarchyCacheEntry( DateTimeOffset? LastSuccessAt, DateTimeOffset? LastDeployTime, string? LastError, - IReadOnlyList Hierarchy, - IReadOnlyList Attributes, - DiscoverHierarchyReply? Reply, + IReadOnlyList Objects, + DashboardGalaxySummary DashboardSummary, int ObjectCount, int AreaCount, int AttributeCount, @@ -30,9 +30,8 @@ public sealed record GalaxyHierarchyCacheEntry( LastSuccessAt: null, LastDeployTime: null, LastError: null, - Hierarchy: Array.Empty(), - Attributes: Array.Empty(), - Reply: null, + Objects: Array.Empty(), + DashboardSummary: DashboardGalaxySummary.Unknown, ObjectCount: 0, AreaCount: 0, AttributeCount: 0, diff --git a/src/MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs b/src/MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs index 68ed9f1..7fd404d 100644 --- a/src/MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs +++ b/src/MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs @@ -21,6 +21,8 @@ public sealed class GalaxyRepositoryGrpcService( ILogger logger) : ProtoGalaxyRepository.GalaxyRepositoryBase { private static readonly TimeSpan FirstLoadWaitBudget = TimeSpan.FromSeconds(5); + private const int DefaultDiscoverPageSize = 1000; + private const int MaxDiscoverPageSize = 5000; public override async Task TestConnection( TestConnectionRequest request, @@ -59,16 +61,39 @@ public sealed class GalaxyRepositoryGrpcService( await WaitForCacheBootstrap(context.CancellationToken).ConfigureAwait(false); GalaxyDb.GalaxyHierarchyCacheEntry entry = cache.Current; - if (!entry.HasData || entry.Reply is null) + if (!entry.HasData) { throw new RpcException(new Status( StatusCode.Unavailable, ResolveUnavailableMessage(entry))); } - // Same materialized reply is shared across all clients — gRPC serialization is - // read-only and the entry is replaced atomically on the next refresh. - return entry.Reply; + int offset = ParsePageToken(request.PageToken); + if (offset > entry.Objects.Count) + { + throw new RpcException(new Status( + StatusCode.InvalidArgument, + "DiscoverHierarchy page_token is outside the current hierarchy.")); + } + + int pageSize = ResolvePageSize(request.PageSize); + int take = Math.Min(pageSize, entry.Objects.Count - offset); + DiscoverHierarchyReply reply = new() + { + TotalObjectCount = entry.Objects.Count, + }; + for (int index = offset; index < offset + take; index++) + { + reply.Objects.Add(entry.Objects[index].Clone()); + } + + int nextOffset = offset + take; + if (nextOffset < entry.Objects.Count) + { + reply.NextPageToken = nextOffset.ToString(System.Globalization.CultureInfo.InvariantCulture); + } + + return reply; } public override async Task WatchDeployEvents( @@ -144,6 +169,41 @@ public sealed class GalaxyRepositoryGrpcService( _ => "Galaxy cache has no data available.", }; + private static int ResolvePageSize(int requestedPageSize) + { + if (requestedPageSize < 0) + { + throw new RpcException(new Status( + StatusCode.InvalidArgument, + "DiscoverHierarchy page_size must be greater than zero when provided.")); + } + + int pageSize = requestedPageSize == 0 ? DefaultDiscoverPageSize : requestedPageSize; + return Math.Min(pageSize, MaxDiscoverPageSize); + } + + private static int ParsePageToken(string pageToken) + { + if (string.IsNullOrWhiteSpace(pageToken)) + { + return 0; + } + + if (!int.TryParse( + pageToken, + System.Globalization.NumberStyles.None, + System.Globalization.CultureInfo.InvariantCulture, + out int offset) + || offset < 0) + { + throw new RpcException(new Status( + StatusCode.InvalidArgument, + "DiscoverHierarchy page_token is invalid.")); + } + + return offset; + } + [System.Diagnostics.CodeAnalysis.SuppressMessage( "Style", "IDE0051:Remove unused private members", diff --git a/src/MxGateway.Server/Security/Authorization/GrpcAuthorizationServiceCollectionExtensions.cs b/src/MxGateway.Server/Security/Authorization/GrpcAuthorizationServiceCollectionExtensions.cs index 6de9d56..2f28b08 100644 --- a/src/MxGateway.Server/Security/Authorization/GrpcAuthorizationServiceCollectionExtensions.cs +++ b/src/MxGateway.Server/Security/Authorization/GrpcAuthorizationServiceCollectionExtensions.cs @@ -1,4 +1,6 @@ using Grpc.Core.Interceptors; +using Microsoft.Extensions.Configuration; +using MxGateway.Server.Configuration; namespace MxGateway.Server.Security.Authorization; @@ -9,6 +11,15 @@ public static class GrpcAuthorizationServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services + .AddOptions() + .Configure((grpcOptions, configuration) => + { + ProtocolOptions protocolOptions = new(); + configuration.GetSection("MxGateway:Protocol").Bind(protocolOptions); + grpcOptions.MaxReceiveMessageSize = protocolOptions.MaxGrpcMessageBytes; + grpcOptions.MaxSendMessageSize = protocolOptions.MaxGrpcMessageBytes; + }); services.AddGrpc(options => options.Interceptors.Add()); return services; diff --git a/src/MxGateway.Server/Sessions/GatewaySession.cs b/src/MxGateway.Server/Sessions/GatewaySession.cs index b0a4427..cd4682e 100644 --- a/src/MxGateway.Server/Sessions/GatewaySession.cs +++ b/src/MxGateway.Server/Sessions/GatewaySession.cs @@ -27,6 +27,35 @@ public sealed class GatewaySession TimeSpan startupTimeout, TimeSpan shutdownTimeout, DateTimeOffset openedAt) + : this( + sessionId, + backendName, + pipeName, + nonce, + clientIdentity, + clientSessionName, + clientCorrelationId, + commandTimeout, + startupTimeout, + shutdownTimeout, + TimeSpan.FromMinutes(30), + openedAt) + { + } + + public GatewaySession( + string sessionId, + string backendName, + string pipeName, + string nonce, + string? clientIdentity, + string? clientSessionName, + string? clientCorrelationId, + TimeSpan commandTimeout, + TimeSpan startupTimeout, + TimeSpan shutdownTimeout, + TimeSpan leaseDuration, + DateTimeOffset openedAt) { if (string.IsNullOrWhiteSpace(sessionId)) { @@ -58,8 +87,10 @@ public sealed class GatewaySession CommandTimeout = commandTimeout; StartupTimeout = startupTimeout; ShutdownTimeout = shutdownTimeout; + LeaseDuration = leaseDuration; OpenedAt = openedAt; _lastClientActivityAt = openedAt; + _leaseExpiresAt = openedAt + leaseDuration; } public string SessionId { get; } @@ -82,6 +113,8 @@ public sealed class GatewaySession public TimeSpan ShutdownTimeout { get; } + public TimeSpan LeaseDuration { get; } + public DateTimeOffset OpenedAt { get; } public int? WorkerProcessId => _workerClient?.ProcessId; @@ -195,6 +228,7 @@ public sealed class GatewaySession lock (_syncRoot) { _lastClientActivityAt = activityAt; + _leaseExpiresAt = activityAt + LeaseDuration; } } @@ -210,7 +244,9 @@ public sealed class GatewaySession { lock (_syncRoot) { - return _leaseExpiresAt is not null && _leaseExpiresAt <= now; + return _activeEventSubscriberCount == 0 + && _leaseExpiresAt is not null + && _leaseExpiresAt <= now; } } diff --git a/src/MxGateway.Server/Sessions/SessionLeaseMonitorHostedService.cs b/src/MxGateway.Server/Sessions/SessionLeaseMonitorHostedService.cs new file mode 100644 index 0000000..4537887 --- /dev/null +++ b/src/MxGateway.Server/Sessions/SessionLeaseMonitorHostedService.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using MxGateway.Server.Configuration; + +namespace MxGateway.Server.Sessions; + +public sealed class SessionLeaseMonitorHostedService( + ISessionManager sessionManager, + IOptions options, + ILogger logger, + TimeProvider? timeProvider = null) : BackgroundService +{ + private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + TimeSpan interval = TimeSpan.FromSeconds(Math.Max(1, options.Value.Sessions.LeaseSweepIntervalSeconds)); + using PeriodicTimer timer = new(interval, _timeProvider); + + try + { + while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false)) + { + try + { + await sessionManager + .CloseExpiredLeasesAsync(_timeProvider.GetUtcNow(), stoppingToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + return; + } + catch (Exception exception) + { + logger.LogWarning(exception, "Session lease sweep failed."); + } + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + } + } +} diff --git a/src/MxGateway.Server/Sessions/SessionManager.cs b/src/MxGateway.Server/Sessions/SessionManager.cs index 96a2715..6798236 100644 --- a/src/MxGateway.Server/Sessions/SessionManager.cs +++ b/src/MxGateway.Server/Sessions/SessionManager.cs @@ -287,6 +287,7 @@ public sealed class SessionManager : ISessionManager TimeSpan commandTimeout = ResolveCommandTimeout(request.CommandTimeout); TimeSpan startupTimeout = TimeSpan.FromSeconds(_options.Worker.StartupTimeoutSeconds); TimeSpan shutdownTimeout = TimeSpan.FromSeconds(_options.Worker.ShutdownTimeoutSeconds); + TimeSpan leaseDuration = TimeSpan.FromSeconds(_options.Sessions.DefaultLeaseSeconds); string pipeName = $"mxaccess-gateway-{Environment.ProcessId}-{sessionId}"; string nonce = CreateNonce(); DateTimeOffset openedAt = _timeProvider.GetUtcNow(); @@ -303,6 +304,7 @@ public sealed class SessionManager : ISessionManager commandTimeout, startupTimeout, shutdownTimeout, + leaseDuration, openedAt); } diff --git a/src/MxGateway.Server/Sessions/SessionServiceCollectionExtensions.cs b/src/MxGateway.Server/Sessions/SessionServiceCollectionExtensions.cs index 4cdb620..1d014f7 100644 --- a/src/MxGateway.Server/Sessions/SessionServiceCollectionExtensions.cs +++ b/src/MxGateway.Server/Sessions/SessionServiceCollectionExtensions.cs @@ -7,6 +7,7 @@ public static class SessionServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddHostedService(); services.AddHostedService(); return services; diff --git a/src/MxGateway.Server/Workers/WorkerClient.cs b/src/MxGateway.Server/Workers/WorkerClient.cs index 25ed454..122844b 100644 --- a/src/MxGateway.Server/Workers/WorkerClient.cs +++ b/src/MxGateway.Server/Workers/WorkerClient.cs @@ -231,11 +231,17 @@ public sealed class WorkerClient : IWorkerClient } WorkerClientState state = State; - if (state is WorkerClientState.Closed or WorkerClientState.Faulted) + if (state == WorkerClientState.Closed) { return; } + if (state == WorkerClientState.Faulted) + { + KillOwnedProcess("ShutdownFaulted"); + return; + } + MarkClosing(); await EnqueueAsync(CreateShutdownEnvelope(timeout, "gateway-shutdown"), cancellationToken).ConfigureAwait(false); _outboundEnvelopes.Writer.TryComplete(); @@ -263,8 +269,7 @@ public sealed class WorkerClient : IWorkerClient public void Kill(string reason) { ThrowIfDisposed(); - _connection.ProcessHandle?.Process.Kill(entireProcessTree: true); - _metrics?.WorkerKilled(reason); + KillOwnedProcess(reason); SetFaulted( WorkerClientErrorCode.WorkerFaulted, $"Worker was killed by the gateway: {reason}.", @@ -279,6 +284,7 @@ public sealed class WorkerClient : IWorkerClient } _disposed = true; + KillOwnedProcess("Dispose"); _stopCts.Cancel(); _outboundEnvelopes.Writer.TryComplete(); _events.Writer.TryComplete(); @@ -607,12 +613,39 @@ public sealed class WorkerClient : IWorkerClient _stopCts.Cancel(); _outboundEnvelopes.Writer.TryComplete(fault); _events.Writer.TryComplete(fault); + KillOwnedProcess(errorCode.ToString()); CompletePendingCommands(fault); RecordWorkerStoppedOnce(errorCode.ToString()); _metrics?.Fault(errorCode.ToString()); _logger.LogWarning(exception, "Worker client faulted for session {SessionId}: {Message}", SessionId, message); } + private void KillOwnedProcess(string reason) + { + WorkerProcessHandle? processHandle = _connection.ProcessHandle; + if (processHandle is null) + { + return; + } + + try + { + if (!processHandle.Process.HasExited) + { + processHandle.Process.Kill(entireProcessTree: true); + _metrics?.WorkerKilled(reason); + } + } + catch (Exception exception) + { + _logger.LogWarning( + exception, + "Failed to kill worker process {ProcessId} for session {SessionId}.", + processHandle.ProcessId, + SessionId); + } + } + private void RecordWorkerStoppedOnce(string reason) { bool shouldRecord; diff --git a/src/MxGateway.Server/appsettings.json b/src/MxGateway.Server/appsettings.json index f231d1a..8869894 100644 --- a/src/MxGateway.Server/appsettings.json +++ b/src/MxGateway.Server/appsettings.json @@ -25,6 +25,9 @@ "Sessions": { "DefaultCommandTimeoutSeconds": 30, "MaxSessions": 64, + "MaxPendingCommandsPerSession": 128, + "DefaultLeaseSeconds": 1800, + "LeaseSweepIntervalSeconds": 30, "AllowMultipleEventSubscribers": false }, "Events": { @@ -42,7 +45,8 @@ "ShowTagValues": false }, "Protocol": { - "WorkerProtocolVersion": 1 + "WorkerProtocolVersion": 1, + "MaxGrpcMessageBytes": 16777216 }, "Galaxy": { "ConnectionString": "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;", diff --git a/src/MxGateway.Tests/Configuration/GatewayOptionsTests.cs b/src/MxGateway.Tests/Configuration/GatewayOptionsTests.cs index aec1e83..5648e6c 100644 --- a/src/MxGateway.Tests/Configuration/GatewayOptionsTests.cs +++ b/src/MxGateway.Tests/Configuration/GatewayOptionsTests.cs @@ -30,6 +30,8 @@ public sealed class GatewayOptionsTests Assert.Equal(30, options.Sessions.DefaultCommandTimeoutSeconds); Assert.Equal(64, options.Sessions.MaxSessions); + Assert.Equal(1800, options.Sessions.DefaultLeaseSeconds); + Assert.Equal(30, options.Sessions.LeaseSweepIntervalSeconds); Assert.False(options.Sessions.AllowMultipleEventSubscribers); Assert.Equal(10_000, options.Events.QueueCapacity); @@ -45,6 +47,7 @@ public sealed class GatewayOptionsTests Assert.False(options.Dashboard.ShowTagValues); Assert.Equal(1u, options.Protocol.WorkerProtocolVersion); + Assert.Equal(16 * 1024 * 1024, options.Protocol.MaxGrpcMessageBytes); } [Fact] @@ -56,22 +59,29 @@ public sealed class GatewayOptionsTests ["MxGateway:Authentication:Mode"] = "Disabled", ["MxGateway:Worker:ExecutablePath"] = @"C:\Gateway\MxGateway.Worker.exe", ["MxGateway:Sessions:MaxSessions"] = "12", + ["MxGateway:Sessions:DefaultLeaseSeconds"] = "900", ["MxGateway:Events:QueueCapacity"] = "256", - ["MxGateway:Dashboard:Enabled"] = "false" + ["MxGateway:Dashboard:Enabled"] = "false", + ["MxGateway:Protocol:MaxGrpcMessageBytes"] = "8388608" }); Assert.Equal(AuthenticationMode.Disabled, options.Authentication.Mode); Assert.Equal(@"C:\Gateway\MxGateway.Worker.exe", options.Worker.ExecutablePath); Assert.Equal(12, options.Sessions.MaxSessions); + Assert.Equal(900, options.Sessions.DefaultLeaseSeconds); Assert.Equal(256, options.Events.QueueCapacity); Assert.False(options.Dashboard.Enabled); + Assert.Equal(8 * 1024 * 1024, options.Protocol.MaxGrpcMessageBytes); } [Theory] [InlineData("MxGateway:Worker:ExecutablePath", "worker.dll", "MxGateway:Worker:ExecutablePath must point to a .exe file.")] [InlineData("MxGateway:Worker:StartupProbeRetryAttempts", "0", "MxGateway:Worker:StartupProbeRetryAttempts must be greater than zero.")] [InlineData("MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds", "0", "MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds must be greater than zero.")] + [InlineData("MxGateway:Sessions:DefaultLeaseSeconds", "0", "MxGateway:Sessions:DefaultLeaseSeconds must be greater than zero.")] + [InlineData("MxGateway:Sessions:LeaseSweepIntervalSeconds", "0", "MxGateway:Sessions:LeaseSweepIntervalSeconds must be greater than zero.")] [InlineData("MxGateway:Events:QueueCapacity", "0", "MxGateway:Events:QueueCapacity must be greater than zero.")] + [InlineData("MxGateway:Protocol:MaxGrpcMessageBytes", "0", "MxGateway:Protocol:MaxGrpcMessageBytes must be between")] [InlineData("MxGateway:Authentication:PepperSecretName", "", "MxGateway:Authentication:PepperSecretName is required")] [InlineData("MxGateway:Dashboard:PathBase", "dashboard", "MxGateway:Dashboard:PathBase must start with '/'.")] public void Validation_InvalidConfiguration_FailsClearly(string key, string value, string expectedFailure) diff --git a/src/MxGateway.Tests/Contracts/GatewayContractInfoTests.cs b/src/MxGateway.Tests/Contracts/GatewayContractInfoTests.cs index 65456fb..5c3af2e 100644 --- a/src/MxGateway.Tests/Contracts/GatewayContractInfoTests.cs +++ b/src/MxGateway.Tests/Contracts/GatewayContractInfoTests.cs @@ -11,9 +11,9 @@ public sealed class GatewayContractInfoTests } [Fact] - public void GatewayProtocolVersion_StartsAtVersionOne() + public void GatewayProtocolVersion_IsVersionTwo() { - Assert.Equal(1u, GatewayContractInfo.GatewayProtocolVersion); + Assert.Equal(2u, GatewayContractInfo.GatewayProtocolVersion); } [Fact] diff --git a/src/MxGateway.Tests/Galaxy/GalaxyHierarchyCacheTests.cs b/src/MxGateway.Tests/Galaxy/GalaxyHierarchyCacheTests.cs index 5e3558d..fc28cb7 100644 --- a/src/MxGateway.Tests/Galaxy/GalaxyHierarchyCacheTests.cs +++ b/src/MxGateway.Tests/Galaxy/GalaxyHierarchyCacheTests.cs @@ -15,7 +15,7 @@ public sealed class GalaxyHierarchyCacheTests Assert.Equal(GalaxyCacheStatus.Unknown, entry.Status); Assert.False(entry.HasData); Assert.Equal(0, entry.ObjectCount); - Assert.Null(entry.Reply); + Assert.Empty(entry.Objects); } [Fact] diff --git a/src/MxGateway.Tests/Gateway/Dashboard/DashboardConnectionStringDisplayTests.cs b/src/MxGateway.Tests/Gateway/Dashboard/DashboardConnectionStringDisplayTests.cs new file mode 100644 index 0000000..930e281 --- /dev/null +++ b/src/MxGateway.Tests/Gateway/Dashboard/DashboardConnectionStringDisplayTests.cs @@ -0,0 +1,21 @@ +using MxGateway.Server.Dashboard; + +namespace MxGateway.Tests.Gateway.Dashboard; + +public sealed class DashboardConnectionStringDisplayTests +{ + [Fact] + public void GalaxyRepositoryConnectionString_WithSqlCredentials_OnlyKeepsNonSecretFields() + { + string display = DashboardConnectionStringDisplay.GalaxyRepositoryConnectionString( + "Server=localhost;Database=ZB;User ID=mxuser;Password=secret;Encrypt=True;Trust Server Certificate=False;"); + + Assert.Contains("Data Source=localhost", display, StringComparison.Ordinal); + Assert.Contains("Initial Catalog=ZB", display, StringComparison.Ordinal); + Assert.Contains("Encrypt=True", display, StringComparison.Ordinal); + Assert.DoesNotContain("User", display, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Password", display, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("secret", display, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("mxuser", display, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs b/src/MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs index 3117109..387bcfd 100644 --- a/src/MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs +++ b/src/MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs @@ -182,17 +182,27 @@ public sealed class DashboardSnapshotServiceTests LastQueriedAt = DateTimeOffset.Parse("2026-04-28T11:30:00Z"), LastSuccessAt = DateTimeOffset.Parse("2026-04-28T11:30:00Z"), LastDeployTime = DateTimeOffset.Parse("2026-04-28T09:00:00Z"), - Hierarchy = - [ - new GalaxyHierarchyRow { GobjectId = 1, TagName = "Pump_001", BrowseName = "Pump_001", CategoryId = 10, IsArea = false, TemplateChain = ["$Pump"] }, - new GalaxyHierarchyRow { GobjectId = 2, TagName = "Pump_002", BrowseName = "Pump_002", CategoryId = 10, IsArea = false, TemplateChain = ["$Pump"] }, - new GalaxyHierarchyRow { GobjectId = 3, TagName = "Area_A", BrowseName = "Area_A", CategoryId = 13, IsArea = true, TemplateChain = ["$Area"] }, - ], - Attributes = - [ - new GalaxyAttributeRow { GobjectId = 1, AttributeName = "Speed", IsHistorized = true }, - new GalaxyAttributeRow { GobjectId = 1, AttributeName = "Status", IsAlarm = true }, - ], + DashboardSummary = new DashboardGalaxySummary( + DashboardGalaxyStatus.Healthy, + LastQueriedAt: DateTimeOffset.Parse("2026-04-28T11:30:00Z"), + LastSuccessAt: DateTimeOffset.Parse("2026-04-28T11:30:00Z"), + LastDeployTime: DateTimeOffset.Parse("2026-04-28T09:00:00Z"), + LastError: null, + ObjectCount: 3, + AreaCount: 1, + AttributeCount: 2, + HistorizedAttributeCount: 1, + AlarmAttributeCount: 1, + TopTemplates: + [ + new DashboardGalaxyTemplateUsage("$Pump", 2), + new DashboardGalaxyTemplateUsage("$Area", 1), + ], + ObjectCategories: + [ + new DashboardGalaxyCategoryCount(10, "UserDefined", 2), + new DashboardGalaxyCategoryCount(13, "Area", 1), + ]), ObjectCount = 3, AreaCount = 1, AttributeCount = 2, diff --git a/src/MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryGrpcServiceTests.cs b/src/MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryGrpcServiceTests.cs new file mode 100644 index 0000000..a793681 --- /dev/null +++ b/src/MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryGrpcServiceTests.cs @@ -0,0 +1,170 @@ +using Grpc.Core; +using Microsoft.Extensions.Logging.Abstractions; +using MxGateway.Contracts.Proto.Galaxy; +using MxGateway.Server.Dashboard; +using MxGateway.Server.Galaxy; +using MxGateway.Server.Grpc; + +namespace MxGateway.Tests.Gateway.Grpc; + +public sealed class GalaxyRepositoryGrpcServiceTests +{ + [Fact] + public async Task DiscoverHierarchy_ReturnsRequestedPageAndTotals() + { + GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateObjects(3))); + + DiscoverHierarchyReply reply = await service.DiscoverHierarchy( + new DiscoverHierarchyRequest + { + PageSize = 2, + }, + new TestServerCallContext()); + + Assert.Equal(2, reply.Objects.Count); + Assert.Equal("Object_001", reply.Objects[0].TagName); + Assert.Equal("Object_002", reply.Objects[1].TagName); + Assert.Equal("2", reply.NextPageToken); + Assert.Equal(3, reply.TotalObjectCount); + } + + [Fact] + public async Task DiscoverHierarchy_WithNextPageToken_ReturnsRemainingObjects() + { + GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateObjects(3))); + + DiscoverHierarchyReply reply = await service.DiscoverHierarchy( + new DiscoverHierarchyRequest + { + PageSize = 2, + PageToken = "2", + }, + new TestServerCallContext()); + + GalaxyObject item = Assert.Single(reply.Objects); + Assert.Equal("Object_003", item.TagName); + Assert.Equal("", reply.NextPageToken); + Assert.Equal(3, reply.TotalObjectCount); + } + + [Theory] + [InlineData("-1", 1)] + [InlineData("not-an-offset", 1)] + [InlineData("4", 1)] + [InlineData("", -1)] + public async Task DiscoverHierarchy_WithInvalidPagingArguments_ReturnsInvalidArgument( + string pageToken, + int pageSize) + { + GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateObjects(3))); + + RpcException exception = await Assert.ThrowsAsync( + async () => await service.DiscoverHierarchy( + new DiscoverHierarchyRequest + { + PageSize = pageSize, + PageToken = pageToken, + }, + new TestServerCallContext())); + + Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); + } + + private static GalaxyRepositoryGrpcService CreateService(GalaxyHierarchyCacheEntry entry) + { + GalaxyRepositoryOptions options = new() + { + ConnectionString = "Server=localhost;Database=ZB;Integrated Security=True;Encrypt=False;", + }; + return new GalaxyRepositoryGrpcService( + new global::MxGateway.Server.Galaxy.GalaxyRepository(options), + new StubGalaxyHierarchyCache(entry), + new GalaxyDeployNotifier(), + NullLogger.Instance); + } + + private static GalaxyHierarchyCacheEntry CreateEntry(IReadOnlyList objects) + { + return GalaxyHierarchyCacheEntry.Empty with + { + Status = GalaxyCacheStatus.Healthy, + LastSuccessAt = DateTimeOffset.UtcNow, + Objects = objects, + DashboardSummary = DashboardGalaxySummary.Unknown with + { + Status = DashboardGalaxyStatus.Healthy, + ObjectCount = objects.Count, + }, + ObjectCount = objects.Count, + }; + } + + private static IReadOnlyList CreateObjects(int count) + { + return Enumerable.Range(1, count) + .Select(index => new GalaxyObject + { + GobjectId = index, + TagName = $"Object_{index:000}", + BrowseName = $"Object_{index:000}", + }) + .ToArray(); + } + + private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache + { + public GalaxyHierarchyCacheEntry Current { get; } = current; + + public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } + + private sealed class TestServerCallContext(CancellationToken cancellationToken = default) : ServerCallContext + { + private readonly Metadata requestHeaders = []; + private readonly Metadata responseTrailers = []; + private readonly Dictionary userState = []; + private Status status; + private WriteOptions? writeOptions; + + protected override string MethodCore => "/galaxy_repository.v1.GalaxyRepository/DiscoverHierarchy"; + + protected override string HostCore => "localhost"; + + protected override string PeerCore => "ipv4:127.0.0.1:5000"; + + protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1); + + protected override Metadata RequestHeadersCore => requestHeaders; + + protected override CancellationToken CancellationTokenCore => cancellationToken; + + protected override Metadata ResponseTrailersCore => responseTrailers; + + protected override Status StatusCore + { + get => status; + set => status = value; + } + + protected override WriteOptions? WriteOptionsCore + { + get => writeOptions; + set => writeOptions = value; + } + + protected override AuthContext AuthContextCore { get; } = new( + string.Empty, + new Dictionary>(StringComparer.Ordinal)); + + protected override IDictionary UserStateCore => userState; + + protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) => Task.CompletedTask; + + protected override ContextPropagationToken CreatePropagationTokenCore(ContextPropagationOptions? options) + { + throw new NotSupportedException(); + } + } +} diff --git a/src/MxGateway.Tests/Gateway/Sessions/SessionManagerTests.cs b/src/MxGateway.Tests/Gateway/Sessions/SessionManagerTests.cs index fa5c90a..7ee87a5 100644 --- a/src/MxGateway.Tests/Gateway/Sessions/SessionManagerTests.cs +++ b/src/MxGateway.Tests/Gateway/Sessions/SessionManagerTests.cs @@ -32,6 +32,21 @@ public sealed class SessionManagerTests Assert.Equal(1, metrics.GetSnapshot().SessionsOpened); } + [Fact] + public async Task OpenSessionAsync_SetsInitialDefaultLease() + { + ManualTimeProvider clock = new(DateTimeOffset.Parse("2026-04-29T10:00:00Z")); + GatewayOptions options = CreateOptions(defaultLeaseSeconds: 1800); + SessionManager manager = CreateManager( + new FakeSessionWorkerClientFactory(new FakeWorkerClient()), + options: options, + timeProvider: clock); + + GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None); + + Assert.Equal(clock.GetUtcNow() + TimeSpan.FromMinutes(30), session.LeaseExpiresAt); + } + [Fact] public async Task OpenSessionAsync_GeneratesClientCorrelationIdFromClientNameAndSessionId() { @@ -77,6 +92,32 @@ public sealed class SessionManagerTests Assert.Equal(MxCommandKind.Ping, reply.Reply.Kind); } + [Fact] + public async Task InvokeAsync_WhenSessionReady_RefreshesLease() + { + GatewaySession session = new( + "session-lease-refresh", + "mxaccess", + "mxaccess-gateway-1-session-lease-refresh", + "nonce", + "client-1", + "test-session", + "client-correlation-1", + TimeSpan.FromSeconds(30), + TimeSpan.FromSeconds(5), + TimeSpan.FromSeconds(5), + TimeSpan.FromMinutes(30), + DateTimeOffset.UtcNow - TimeSpan.FromHours(1)); + session.AttachWorkerClient(new FakeWorkerClient()); + session.MarkReady(); + DateTimeOffset? initialLease = session.LeaseExpiresAt; + + await session.InvokeAsync(CreateCommand(MxCommandKind.Ping), CancellationToken.None); + + Assert.True(session.LeaseExpiresAt > initialLease); + Assert.True(session.LeaseExpiresAt > DateTimeOffset.UtcNow); + } + [Fact] public async Task GatewaySessionSubscribeBulkAsync_ForwardsOneBulkCommandAndReturnsResults() { @@ -309,6 +350,23 @@ public sealed class SessionManagerTests Assert.Equal(0, activeClient.ShutdownCount); } + [Fact] + public async Task CloseExpiredLeasesAsync_DoesNotCloseActiveEventSubscriber() + { + FakeWorkerClient workerClient = new(); + SessionManager manager = CreateManager(new FakeSessionWorkerClientFactory(workerClient)); + GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None); + DateTimeOffset now = DateTimeOffset.UtcNow; + session.ExtendLease(now.AddSeconds(-1)); + using IDisposable eventSubscriber = session.AttachEventSubscriber(allowMultipleSubscribers: false); + + int closedCount = await manager.CloseExpiredLeasesAsync(now, CancellationToken.None); + + Assert.Equal(0, closedCount); + Assert.Equal(SessionState.Ready, session.State); + Assert.Equal(0, workerClient.ShutdownCount); + } + [Fact] public async Task ShutdownAsync_ClosesAllRegisteredSessions() { @@ -334,16 +392,20 @@ public sealed class SessionManagerTests ISessionWorkerClientFactory factory, ISessionRegistry? registry = null, GatewayMetrics? metrics = null, - GatewayOptions? options = null) + GatewayOptions? options = null, + TimeProvider? timeProvider = null) { return new SessionManager( registry ?? new SessionRegistry(), factory, Options.Create(options ?? CreateOptions()), - metrics ?? new GatewayMetrics()); + metrics ?? new GatewayMetrics(), + timeProvider); } - private static GatewayOptions CreateOptions(int maxSessions = 64) + private static GatewayOptions CreateOptions( + int maxSessions = 64, + int defaultLeaseSeconds = 1800) { return new GatewayOptions { @@ -351,6 +413,7 @@ public sealed class SessionManagerTests { DefaultCommandTimeoutSeconds = 30, MaxSessions = maxSessions, + DefaultLeaseSeconds = defaultLeaseSeconds, }, Worker = new WorkerOptions { @@ -540,4 +603,11 @@ public sealed class SessionManagerTests ShutdownReleased.TrySetResult(); } } + + private sealed class ManualTimeProvider(DateTimeOffset start) : TimeProvider + { + private DateTimeOffset _now = start; + + public override DateTimeOffset GetUtcNow() => _now; + } } diff --git a/src/MxGateway.Tests/Gateway/Workers/WorkerClientTests.cs b/src/MxGateway.Tests/Gateway/Workers/WorkerClientTests.cs index 2cc1c17..10c3fbf 100644 --- a/src/MxGateway.Tests/Gateway/Workers/WorkerClientTests.cs +++ b/src/MxGateway.Tests/Gateway/Workers/WorkerClientTests.cs @@ -137,6 +137,36 @@ public sealed class WorkerClientTests Assert.Equal(WorkerClientState.Faulted, client.State); } + [Fact] + public async Task ReadLoop_WhenClientFaults_KillsOwnedWorkerProcess() + { + await using PipePair pipePair = await PipePair.CreateAsync(); + FakeWorkerProcess process = new(); + await using WorkerClient client = CreateClient( + pipePair, + new WorkerClientOptions + { + EventChannelCapacity = 1, + HeartbeatGrace = TimeSpan.FromSeconds(30), + HeartbeatCheckInterval = TimeSpan.FromSeconds(30), + }, + processHandle: CreateProcessHandle(process)); + await CompleteHandshakeAsync(client, pipePair); + + await pipePair.WorkerWriter.WriteAsync( + CreateEventEnvelope(sequence: 11, MxEventFamily.OnDataChange)); + await pipePair.WorkerWriter.WriteAsync( + CreateEventEnvelope(sequence: 12, MxEventFamily.OnDataChange)); + + await WaitUntilAsync( + () => client.State == WorkerClientState.Faulted, + TestTimeout); + + Assert.Equal(1, process.KillCount); + Assert.True(process.KillEntireProcessTree); + Assert.True(process.HasExited); + } + [Fact] public async Task ReadLoop_WhenPipeDisconnects_FaultsClient() { @@ -191,6 +221,20 @@ public sealed class WorkerClientTests $"DisposeAsync took {elapsed.TotalMilliseconds:N0}ms."); } + [Fact] + public async Task DisposeAsync_WhenOwnedWorkerStillRuns_KillsProcessBeforeDisposing() + { + await using PipePair pipePair = await PipePair.CreateAsync(); + FakeWorkerProcess process = new(); + WorkerClient client = CreateClient(pipePair, processHandle: CreateProcessHandle(process)); + + await client.DisposeAsync().AsTask().WaitAsync(TestTimeout); + + Assert.Equal(1, process.KillCount); + Assert.True(process.KillEntireProcessTree); + Assert.True(process.Disposed); + } + [Fact] public async Task ReadLoop_WhenHeartbeatArrives_UpdatesLastHeartbeatAndWorkerProcess() { @@ -233,18 +277,28 @@ public sealed class WorkerClientTests private static WorkerClient CreateClient( PipePair pipePair, WorkerClientOptions? options = null, - GatewayMetrics? metrics = null) + GatewayMetrics? metrics = null, + WorkerProcessHandle? processHandle = null) { WorkerFrameProtocolOptions frameOptions = new(SessionId); WorkerClientConnection connection = new( SessionId, Nonce, pipePair.GatewayStream, - frameOptions); + frameOptions, + processHandle); return new WorkerClient(connection, options, metrics); } + private static WorkerProcessHandle CreateProcessHandle(FakeWorkerProcess process) + { + return new WorkerProcessHandle( + process, + new WorkerProcessCommandLine("MxGateway.Worker.exe", []), + DateTimeOffset.UtcNow); + } + private static async Task CompleteHandshakeAsync( WorkerClient client, PipePair pipePair) @@ -438,4 +492,40 @@ public sealed class WorkerClientTests await GatewayStream.DisposeAsync(); } } + + private sealed class FakeWorkerProcess : IWorkerProcess + { + private readonly TaskCompletionSource _exited = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public int Id { get; } = WorkerProcessId; + + public bool HasExited { get; private set; } + + public int? ExitCode { get; private set; } + + public int KillCount { get; private set; } + + public bool KillEntireProcessTree { get; private set; } + + public bool Disposed { get; private set; } + + public ValueTask WaitForExitAsync(CancellationToken cancellationToken) + { + return new ValueTask(_exited.Task.WaitAsync(cancellationToken)); + } + + public void Kill(bool entireProcessTree) + { + KillCount++; + KillEntireProcessTree = entireProcessTree; + HasExited = true; + ExitCode = -1; + _exited.TrySetResult(); + } + + public void Dispose() + { + Disposed = true; + } + } }