Fix runtime review findings

This commit is contained in:
Joseph Doherty
2026-04-29 10:39:49 -04:00
parent 133c83029b
commit d543679044
69 changed files with 2233 additions and 409 deletions
@@ -21,6 +21,8 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
public DiscoverHierarchyReply DiscoverHierarchyReply { get; set; } = new();
public Queue<DiscoverHierarchyReply> DiscoverHierarchyReplies { get; } = new();
public Queue<Exception> TestConnectionExceptions { get; } = new();
public Queue<Exception> 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; } = [];
@@ -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<GalaxyObject> 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);
@@ -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());
}
@@ -18,6 +18,8 @@ namespace MxGateway.Client;
/// </summary>
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
/// </summary>
public async Task<IReadOnlyList<GalaxyObject>> DiscoverHierarchyAsync(CancellationToken cancellationToken = default)
{
DiscoverHierarchyReply reply = await DiscoverHierarchyRawAsync(
new DiscoverHierarchyRequest(),
cancellationToken)
.ConfigureAwait(false);
List<GalaxyObject> 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<DiscoverHierarchyReply> DiscoverHierarchyRawAsync(
@@ -64,6 +64,8 @@ public sealed class MxGatewayClient : IAsyncDisposable
{
HttpHandler = handler,
LoggerFactory = options.LoggerFactory,
MaxReceiveMessageSize = options.MaxGrpcMessageBytes,
MaxSendMessageSize = options.MaxGrpcMessageBytes,
});
return new MxGatewayClient(
@@ -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(
@@ -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" +
+12
View File
@@ -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 {
+22 -4
View File
@@ -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
+25 -2
View File
@@ -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
}
+1
View File
@@ -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
+1 -1
View File
@@ -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.
@@ -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
@@ -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<DeployEvent>, AutoClose
private static final Object END = new Object();
private final BlockingQueue<Object> queue;
private final AtomicBoolean closed = new AtomicBoolean();
private volatile ClientCallStreamObserver<WatchDeployEventsRequest> requestStream;
private volatile boolean closed;
private Object next;
DeployEventStream(int capacity) {
@@ -35,6 +36,9 @@ public final class DeployEventStream implements Iterator<DeployEvent>, AutoClose
@Override
public void beforeStart(ClientCallStreamObserver<WatchDeployEventsRequest> 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<DeployEvent>, 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<DeployEvent>, AutoClose
@Override
public void close() {
closed = true;
closed.set(true);
ClientCallStreamObserver<WatchDeployEventsRequest> stream = requestStream;
if (stream != null) {
stream.cancel("client cancelled deploy event stream", null);
@@ -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<GalaxyObject> discoverHierarchy() {
try {
DiscoverHierarchyReply reply =
rawBlockingStub().discoverHierarchy(DiscoverHierarchyRequest.getDefaultInstance());
return reply.getObjectsList();
java.util.ArrayList<GalaxyObject> 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<List<GalaxyObject>> 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<List<GalaxyObject>> discoverHierarchyPageAsync(
String pageToken, java.util.ArrayList<GalaxyObject> 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 <T> CompletableFuture<T> toCompletable(com.google.common.util.concurrent.ListenableFuture<T> source) {
CompletableFuture<T> target = new CompletableFuture<>();
Futures.addCallback(
@@ -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,
@@ -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);
}
@@ -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";
@@ -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<DiscoverHierarchyRequest> seenRequest = new AtomicReference<>();
AtomicReference<DiscoverHierarchyRequest> firstRequest = new AtomicReference<>();
AtomicReference<DiscoverHierarchyRequest> secondRequest = new AtomicReference<>();
TestService service = new TestService() {
@Override
public void discoverHierarchy(
DiscoverHierarchyRequest request, StreamObserver<DiscoverHierarchyReply> 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<GalaxyObject> 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<WatchDeployEventsRequest, DeployEvent> 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<WatchDeployEventsRequest> {
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<String> authorization)
@@ -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": [
@@ -3,7 +3,7 @@
"backendName": "mxaccess-worker",
"workerProcessId": 1234,
"workerProtocolVersion": 1,
"gatewayProtocolVersion": 1,
"gatewayProtocolVersion": 2,
"capabilities": [
"unary-open-session",
"unary-close-session",
@@ -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": [
+1 -1
View File
@@ -1,7 +1,7 @@
{
"schemaVersion": 1,
"contractName": "mxaccess-gateway",
"gatewayProtocolVersion": 1,
"gatewayProtocolVersion": 2,
"workerProtocolVersion": 1,
"protoRoot": "src/MxGateway.Contracts/Protos",
"sourceFiles": [
+17 -6
View File
@@ -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,
@@ -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)
+9 -2
View File
@@ -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))
+19 -7
View File
@@ -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"),
],
),
]
+11
View File
@@ -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"
+1 -1
View File
@@ -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);
}
+4 -1
View File
@@ -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(),
})
+99 -33
View File
@@ -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<InterceptedService<Channel, AuthInterceptor>>;
@@ -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<Vec<GalaxyObject>, 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<bool>,
last_deploy: Mutex<Option<Timestamp>>,
objects: Mutex<Vec<GalaxyObject>>,
discover_requests: Mutex<Vec<DiscoverHierarchyRequest>>,
discover_replies: Mutex<std::collections::VecDeque<DiscoverHierarchyReply>>,
watch_requests: Mutex<Vec<WatchDeployEventsRequest>>,
watch_events: Mutex<Vec<DeployEvent>>,
watch_senders: Mutex<Vec<DeployEventTx>>,
@@ -256,10 +278,21 @@ mod tests {
async fn discover_hierarchy(
&self,
_request: Request<DiscoverHierarchyRequest>,
request: Request<DiscoverHierarchyRequest>,
) -> Result<Response<DiscoverHierarchyReply>, 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");
+14
View File
@@ -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<Duration>,
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<Duration> {
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()
}
}
+1 -1
View File
@@ -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;
+34 -13
View File
@@ -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
+7 -1
View File
@@ -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
+5
View File
@@ -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.
+2 -2
View File
@@ -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
@@ -6,7 +6,7 @@ namespace MxGateway.Contracts;
/// </summary>
public static class GatewayContractInfo
{
public const uint GatewayProtocolVersion = 1;
public const uint GatewayProtocolVersion = 2;
public const uint WorkerProtocolVersion = 1;
@@ -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);
}
/// <summary>Field number for the "page_size" field.</summary>
public const int PageSizeFieldNumber = 1;
private int pageSize_;
/// <summary>
/// Maximum number of objects to return. The server applies its default when
/// unset and rejects non-positive values.
/// </summary>
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public int PageSize {
get { return pageSize_; }
set {
pageSize_ = value;
}
}
/// <summary>Field number for the "page_token" field.</summary>
public const int PageTokenFieldNumber = 2;
private string pageToken_ = "";
/// <summary>
/// Opaque token returned by a previous DiscoverHierarchy response.
/// </summary>
[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_; }
}
/// <summary>Field number for the "next_page_token" field.</summary>
public const int NextPageTokenFieldNumber = 2;
private string nextPageToken_ = "";
/// <summary>
/// Non-empty when another page is available.
/// </summary>
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public string NextPageToken {
get { return nextPageToken_; }
set {
nextPageToken_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
}
}
/// <summary>Field number for the "total_object_count" field.</summary>
public const int TotalObjectCountFieldNumber = 3;
private int totalObjectCount_;
/// <summary>
/// Total number of objects in the cached hierarchy at the time of the call.
/// </summary>
[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;
}
}
}
}
@@ -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 {
@@ -1,3 +1,5 @@
namespace MxGateway.Server.Configuration;
public sealed record EffectiveProtocolConfiguration(uint WorkerProtocolVersion);
public sealed record EffectiveProtocolConfiguration(
uint WorkerProtocolVersion,
int MaxGrpcMessageBytes);
@@ -3,4 +3,7 @@ namespace MxGateway.Server.Configuration;
public sealed record EffectiveSessionConfiguration(
int DefaultCommandTimeoutSeconds,
int MaxSessions,
int MaxPendingCommandsPerSession,
int DefaultLeaseSeconds,
int LeaseSweepIntervalSeconds,
bool AllowMultipleEventSubscribers);
@@ -28,6 +28,9 @@ public sealed class GatewayConfigurationProvider(IOptions<GatewayOptions> 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<GatewayOptions> 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));
}
}
@@ -129,6 +129,14 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
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<GatewayOptions>
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<string> failures)
@@ -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;
}
@@ -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; }
}
@@ -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);
}
}
@@ -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]";
}
}
}
@@ -2,97 +2,11 @@ using MxGateway.Server.Galaxy;
namespace MxGateway.Server.Dashboard;
/// <summary>
/// Projects a <see cref="GalaxyHierarchyCacheEntry"/> into a
/// <see cref="DashboardGalaxySummary"/> 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.
/// </summary>
/// <summary>Projects the precomputed Galaxy cache dashboard summary.</summary>
internal static class DashboardGalaxyProjector
{
private const int TopTemplatesLimit = 10;
private static readonly IReadOnlyDictionary<int, string> CategoryNamesById = new Dictionary<int, string>
{
[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<DashboardGalaxyTemplateUsage> topTemplates;
IReadOnlyList<DashboardGalaxyCategoryCount> objectCategories;
if (entry.Hierarchy.Count == 0)
{
topTemplates = Array.Empty<DashboardGalaxyTemplateUsage>();
objectCategories = Array.Empty<DashboardGalaxyCategoryCount>();
}
else
{
Dictionary<int, int> objectsByCategory = new();
Dictionary<string, int> 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;
}
}
@@ -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<GalaxyHierarchyRow> hierarchy = hierarchyTask.Result;
List<GalaxyAttributeRow> attributes = attributesTask.Result;
DiscoverHierarchyReply reply = BuildReply(hierarchy, attributes);
IReadOnlyList<GalaxyObject> 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<GalaxyObject> BuildObjects(
IReadOnlyList<GalaxyHierarchyRow> hierarchy,
IReadOnlyList<GalaxyAttributeRow> 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<GalaxyObject> 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<GalaxyHierarchyRow> hierarchy,
int objectCount,
int areaCount,
int attributeCount,
int historizedAttributeCount,
int alarmAttributeCount)
{
IReadOnlyList<DashboardGalaxyTemplateUsage> topTemplates;
IReadOnlyList<DashboardGalaxyCategoryCount> objectCategories;
if (hierarchy.Count == 0)
{
topTemplates = Array.Empty<DashboardGalaxyTemplateUsage>();
objectCategories = Array.Empty<DashboardGalaxyCategoryCount>();
}
else
{
Dictionary<int, int> objectsByCategory = new();
Dictionary<string, int> 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)
@@ -1,11 +1,12 @@
using MxGateway.Contracts.Proto.Galaxy;
using MxGateway.Server.Dashboard;
namespace MxGateway.Server.Galaxy;
/// <summary>
/// Immutable snapshot of the Galaxy Repository browse data held by
/// <see cref="GalaxyHierarchyCache"/>. Multiple gRPC clients share the same instance —
/// the materialized <see cref="Reply"/> is produced once per refresh and reused.
/// <see cref="GalaxyHierarchyCache"/>. Multiple gRPC clients share the same
/// materialized object list and precomputed dashboard projection.
/// </summary>
public sealed record GalaxyHierarchyCacheEntry(
GalaxyCacheStatus Status,
@@ -14,9 +15,8 @@ public sealed record GalaxyHierarchyCacheEntry(
DateTimeOffset? LastSuccessAt,
DateTimeOffset? LastDeployTime,
string? LastError,
IReadOnlyList<GalaxyHierarchyRow> Hierarchy,
IReadOnlyList<GalaxyAttributeRow> Attributes,
DiscoverHierarchyReply? Reply,
IReadOnlyList<GalaxyObject> 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<GalaxyHierarchyRow>(),
Attributes: Array.Empty<GalaxyAttributeRow>(),
Reply: null,
Objects: Array.Empty<GalaxyObject>(),
DashboardSummary: DashboardGalaxySummary.Unknown,
ObjectCount: 0,
AreaCount: 0,
AttributeCount: 0,
@@ -21,6 +21,8 @@ public sealed class GalaxyRepositoryGrpcService(
ILogger<GalaxyRepositoryGrpcService> 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<TestConnectionReply> 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",
@@ -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<GatewayGrpcScopeResolver>();
services.AddSingleton<IGatewayRequestIdentityAccessor, GatewayRequestIdentityAccessor>();
services.AddSingleton<GatewayGrpcAuthorizationInterceptor>();
services
.AddOptions<global::Grpc.AspNetCore.Server.GrpcServiceOptions>()
.Configure<IConfiguration>((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<GatewayGrpcAuthorizationInterceptor>());
return services;
@@ -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;
}
}
@@ -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<GatewayOptions> options,
ILogger<SessionLeaseMonitorHostedService> 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)
{
}
}
}
@@ -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);
}
@@ -7,6 +7,7 @@ public static class SessionServiceCollectionExtensions
services.AddSingleton<ISessionRegistry, SessionRegistry>();
services.AddSingleton<ISessionWorkerClientFactory, SessionWorkerClientFactory>();
services.AddSingleton<ISessionManager, SessionManager>();
services.AddHostedService<SessionLeaseMonitorHostedService>();
services.AddHostedService<SessionShutdownHostedService>();
return services;
+36 -3
View File
@@ -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;
+5 -1
View File
@@ -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;",
@@ -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)
@@ -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]
@@ -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]
@@ -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);
}
}
@@ -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,
@@ -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<RpcException>(
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<GalaxyRepositoryGrpcService>.Instance);
}
private static GalaxyHierarchyCacheEntry CreateEntry(IReadOnlyList<GalaxyObject> 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<GalaxyObject> 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<object, object> 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<string, List<AuthProperty>>(StringComparer.Ordinal));
protected override IDictionary<object, object> UserStateCore => userState;
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) => Task.CompletedTask;
protected override ContextPropagationToken CreatePropagationTokenCore(ContextPropagationOptions? options)
{
throw new NotSupportedException();
}
}
}
@@ -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;
}
}
@@ -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;
}
}
}