Merge origin/main with local pending work and update AGENTS.md references

- Resolve 14 conflicts from popping local stash on top of origin's
  eed1e88 + 8d3352f doc-comment additions (11 mechanical, plus
  version.rs, DashboardAuthenticatorTests.cs, DashboardGalaxyProjector.cs)
- Fix 4 test files that used AGENTS.md as the repo-root sentinel
  (now use CLAUDE.md, since AGENTS.md was removed in 4731ab5)
- Redirect 10 doc citations from AGENTS.md to the matching gateway.md
  sections (Value Model, Status Model, Security, STA Worker Thread
  Model, gRPC Layer rule, cancellation rule)

Verified: solution build clean, x86 worker build clean, 266/266
gateway tests passing, 121/121 worker tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-30 14:13:33 -04:00
parent 8d3352f2c6
commit ddad573b75
101 changed files with 6053 additions and 621 deletions
@@ -824,9 +824,7 @@ public static class MxGatewayClientCli
TextWriter output,
CancellationToken cancellationToken)
{
DiscoverHierarchyReply reply = await client.GalaxyDiscoverHierarchyAsync(
new DiscoverHierarchyRequest(),
cancellationToken)
DiscoverHierarchyReply reply = await DiscoverAllGalaxyHierarchyAsync(client, cancellationToken)
.ConfigureAwait(false);
if (arguments.HasFlag("json"))
@@ -844,6 +842,39 @@ public static class MxGatewayClientCli
return 0;
}
private static async Task<DiscoverHierarchyReply> DiscoverAllGalaxyHierarchyAsync(
IMxGatewayCliClient client,
CancellationToken cancellationToken)
{
DiscoverHierarchyReply aggregate = new();
HashSet<string> seenPageTokens = new(StringComparer.Ordinal);
string pageToken = string.Empty;
do
{
DiscoverHierarchyReply page = await client.GalaxyDiscoverHierarchyAsync(
new DiscoverHierarchyRequest
{
PageSize = 5000,
PageToken = pageToken,
},
cancellationToken)
.ConfigureAwait(false);
aggregate.Objects.Add(page.Objects);
aggregate.TotalObjectCount = page.TotalObjectCount;
pageToken = page.NextPageToken;
if (!string.IsNullOrWhiteSpace(pageToken)
&& !seenPageTokens.Add(pageToken))
{
throw new MxGatewayException(
$"Galaxy DiscoverHierarchy returned a repeated page token '{pageToken}'.");
}
}
while (!string.IsNullOrWhiteSpace(pageToken));
return aggregate;
}
private static async Task<int> GalaxyWatchAsync(
CliArguments arguments,
IMxGatewayCliClient client,
@@ -48,6 +48,8 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
/// </summary>
public DiscoverHierarchyReply DiscoverHierarchyReply { get; set; } = new();
public Queue<DiscoverHierarchyReply> DiscoverHierarchyReplies { get; } = new();
/// <summary>
/// Gets the queue of exceptions to throw from TestConnection; dequeued in FIFO order.
/// </summary>
@@ -114,7 +116,10 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
throw exception;
}
return Task.FromResult(DiscoverHierarchyReply);
return Task.FromResult(
DiscoverHierarchyReplies.TryDequeue(out DiscoverHierarchyReply? reply)
? reply
: DiscoverHierarchyReply);
}
/// <summary>
@@ -83,8 +83,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
@@ -106,12 +108,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);
@@ -142,6 +161,57 @@ public sealed class GalaxyRepositoryClientTests
/// <summary>
/// Verifies that TestConnectionAsync retries on transient gRPC failures.
/// </summary>
[Fact]
public async Task DiscoverHierarchyAsync_WithRepeatedPageToken_ThrowsProtocolError()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.DiscoverHierarchyReplies.Enqueue(new DiscoverHierarchyReply
{
NextPageToken = "7:1",
});
transport.DiscoverHierarchyReplies.Enqueue(new DiscoverHierarchyReply
{
NextPageToken = "7:1",
});
await using GalaxyRepositoryClient client = CreateClient(transport);
MxGatewayException exception = await Assert.ThrowsAsync<MxGatewayException>(
async () => await client.DiscoverHierarchyAsync());
Assert.Contains("repeated page token", exception.Message, StringComparison.Ordinal);
}
[Fact]
public async Task DiscoverHierarchyAsync_WithOptions_MapsTypedFilters()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
await using GalaxyRepositoryClient client = CreateClient(transport);
await client.DiscoverHierarchyAsync(new DiscoverHierarchyOptions
{
RootContainedPath = "Area1/Line3",
MaxDepth = 2,
CategoryIds = [10, 13],
TemplateChainContains = ["Pump"],
TagNameGlob = "Pump_*",
IncludeAttributes = false,
AlarmBearingOnly = true,
HistorizedOnly = true,
});
DiscoverHierarchyRequest request = Assert.Single(transport.DiscoverHierarchyCalls).Request;
Assert.Equal(DiscoverHierarchyRequest.RootOneofCase.RootContainedPath, request.RootCase);
Assert.Equal("Area1/Line3", request.RootContainedPath);
Assert.Equal(2, request.MaxDepth);
Assert.Equal([10, 13], request.CategoryIds);
Assert.Equal(["Pump"], request.TemplateChainContains);
Assert.Equal("Pump_*", request.TagNameGlob);
Assert.True(request.HasIncludeAttributes);
Assert.False(request.IncludeAttributes);
Assert.True(request.AlarmBearingOnly);
Assert.True(request.HistorizedOnly);
}
[Fact]
public async Task TestConnectionAsync_RetriesOnTransientGrpcFailure()
{
@@ -18,7 +18,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());
}
@@ -33,7 +33,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());
}
@@ -216,8 +216,10 @@ public sealed class MxGatewayClientCliTests
using var output = new StringWriter();
using var error = new StringWriter();
FakeCliClient fakeClient = new();
fakeClient.GalaxyDiscoverHierarchyReply = new DiscoverHierarchyReply
fakeClient.GalaxyDiscoverHierarchyReplies.Enqueue(new DiscoverHierarchyReply
{
NextPageToken = "7:1",
TotalObjectCount = 2,
Objects =
{
new GalaxyObject
@@ -236,7 +238,21 @@ public sealed class MxGatewayClientCliTests
},
},
},
};
});
fakeClient.GalaxyDiscoverHierarchyReplies.Enqueue(new DiscoverHierarchyReply
{
TotalObjectCount = 2,
Objects =
{
new GalaxyObject
{
GobjectId = 8,
TagName = "DelmiaReceiver_002",
ContainedName = "DelmiaReceiver",
ParentGobjectId = 1,
},
},
});
int exitCode = await MxGatewayClientCli.RunAsync(
[
@@ -251,10 +267,14 @@ public sealed class MxGatewayClientCliTests
_ => fakeClient);
Assert.Equal(0, exitCode);
Assert.Single(fakeClient.GalaxyDiscoverHierarchyRequests);
Assert.Equal(2, fakeClient.GalaxyDiscoverHierarchyRequests.Count);
Assert.Equal(5000, fakeClient.GalaxyDiscoverHierarchyRequests[0].PageSize);
Assert.Equal("", fakeClient.GalaxyDiscoverHierarchyRequests[0].PageToken);
Assert.Equal("7:1", fakeClient.GalaxyDiscoverHierarchyRequests[1].PageToken);
string text = output.ToString();
Assert.Contains("objects=1", text);
Assert.Contains("objects=2", text);
Assert.Contains("DelmiaReceiver_001", text);
Assert.Contains("DelmiaReceiver_002", text);
Assert.Contains("attributes=1", text);
Assert.Equal(string.Empty, error.ToString());
}
@@ -436,6 +456,8 @@ public sealed class MxGatewayClientCliTests
/// <summary>Galaxy discover hierarchy reply to return.</summary>
public DiscoverHierarchyReply GalaxyDiscoverHierarchyReply { get; set; } = new();
public Queue<DiscoverHierarchyReply> GalaxyDiscoverHierarchyReplies { get; } = new();
/// <summary>List of received galaxy test connection requests.</summary>
public List<TestConnectionRequest> GalaxyTestConnectionRequests { get; } = [];
@@ -469,7 +491,10 @@ public sealed class MxGatewayClientCliTests
CancellationToken cancellationToken)
{
GalaxyDiscoverHierarchyRequests.Add(request);
return Task.FromResult(GalaxyDiscoverHierarchyReply);
return Task.FromResult(
GalaxyDiscoverHierarchyReplies.TryDequeue(out DiscoverHierarchyReply? reply)
? reply
: GalaxyDiscoverHierarchyReply);
}
/// <summary>List of received galaxy watch deploy events requests.</summary>
@@ -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;
@@ -84,6 +86,8 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
{
HttpHandler = handler,
LoggerFactory = options.LoggerFactory,
MaxReceiveMessageSize = options.MaxGrpcMessageBytes,
MaxSendMessageSize = options.MaxGrpcMessageBytes,
});
return new GalaxyRepositoryClient(
@@ -175,12 +179,81 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
/// <returns>The collection of Galaxy objects in the hierarchy.</returns>
public async Task<IReadOnlyList<GalaxyObject>> DiscoverHierarchyAsync(CancellationToken cancellationToken = default)
{
DiscoverHierarchyReply reply = await DiscoverHierarchyRawAsync(
new DiscoverHierarchyRequest(),
cancellationToken)
.ConfigureAwait(false);
return await DiscoverHierarchyAsync(new DiscoverHierarchyOptions(), cancellationToken).ConfigureAwait(false);
}
return reply.Objects;
public async Task<IReadOnlyList<GalaxyObject>> DiscoverHierarchyAsync(
DiscoverHierarchyOptions options,
CancellationToken cancellationToken = default)
{
List<GalaxyObject> objects = [];
HashSet<string> seenPageTokens = new(StringComparer.Ordinal);
string pageToken = string.Empty;
do
{
DiscoverHierarchyRequest request = CreateDiscoverHierarchyRequest(options);
request.PageSize = DiscoverHierarchyPageSize;
request.PageToken = pageToken;
DiscoverHierarchyReply reply = await DiscoverHierarchyRawAsync(
request,
cancellationToken)
.ConfigureAwait(false);
objects.AddRange(reply.Objects);
pageToken = reply.NextPageToken;
if (!string.IsNullOrWhiteSpace(pageToken)
&& !seenPageTokens.Add(pageToken))
{
throw new MxGatewayException(
$"Galaxy DiscoverHierarchy returned a repeated page token '{pageToken}'.");
}
}
while (!string.IsNullOrWhiteSpace(pageToken));
return objects;
}
private static DiscoverHierarchyRequest CreateDiscoverHierarchyRequest(DiscoverHierarchyOptions options)
{
ArgumentNullException.ThrowIfNull(options);
DiscoverHierarchyRequest request = new()
{
AlarmBearingOnly = options.AlarmBearingOnly,
HistorizedOnly = options.HistorizedOnly,
};
if (options.RootGobjectId.HasValue)
{
request.RootGobjectId = options.RootGobjectId.Value;
}
else if (!string.IsNullOrWhiteSpace(options.RootTagName))
{
request.RootTagName = options.RootTagName;
}
else if (!string.IsNullOrWhiteSpace(options.RootContainedPath))
{
request.RootContainedPath = options.RootContainedPath;
}
if (options.MaxDepth.HasValue)
{
request.MaxDepth = options.MaxDepth.Value;
}
request.CategoryIds.Add(options.CategoryIds);
request.TemplateChainContains.Add(options.TemplateChainContains);
if (!string.IsNullOrWhiteSpace(options.TagNameGlob))
{
request.TagNameGlob = options.TagNameGlob;
}
if (options.IncludeAttributes.HasValue)
{
request.IncludeAttributes = options.IncludeAttributes.Value;
}
return request;
}
/// <summary>
@@ -80,6 +80,8 @@ public sealed class MxGatewayClient : IAsyncDisposable
{
HttpHandler = handler,
LoggerFactory = options.LoggerFactory,
MaxReceiveMessageSize = options.MaxGrpcMessageBytes,
MaxSendMessageSize = options.MaxGrpcMessageBytes,
});
return new MxGatewayClient(
@@ -47,6 +47,8 @@ public sealed class MxGatewayClientOptions
/// </summary>
public TimeSpan? StreamTimeout { get; init; }
public int MaxGrpcMessageBytes { get; init; } = 16 * 1024 * 1024;
/// <summary>
/// Gets the retry configuration for safe unary calls.
/// </summary>
@@ -102,6 +104,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(
@@ -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;
@@ -177,9 +179,22 @@ 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<>();
java.util.HashSet<String> seenPageTokens = new java.util.HashSet<>();
String pageToken = "";
do {
DiscoverHierarchyReply reply = rawBlockingStub().discoverHierarchy(DiscoverHierarchyRequest.newBuilder()
.setPageSize(DISCOVER_HIERARCHY_PAGE_SIZE)
.setPageToken(pageToken)
.build());
objects.addAll(reply.getObjectsList());
pageToken = reply.getNextPageToken();
if (!pageToken.isBlank() && !seenPageTokens.add(pageToken)) {
throw new MxGatewayException(
"galaxy discover hierarchy returned repeated page token: " + pageToken);
}
} while (!pageToken.isBlank());
return objects;
} catch (RuntimeException error) {
if (error instanceof MxGatewayException) {
throw error;
@@ -195,8 +210,7 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
* exceptionally with {@link MxGatewayException} on failure
*/
public CompletableFuture<List<GalaxyObject>> discoverHierarchyAsync() {
return toCompletable(rawFutureStub().discoverHierarchy(DiscoverHierarchyRequest.getDefaultInstance()))
.thenApply(DiscoverHierarchyReply::getObjectsList);
return discoverHierarchyPageAsync("", new java.util.ArrayList<>(), new java.util.HashSet<>());
}
/**
@@ -295,7 +309,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,
@@ -327,6 +341,27 @@ 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, java.util.HashSet<String> seenPageTokens) {
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);
}
if (!seenPageTokens.add(reply.getNextPageToken())) {
CompletableFuture<List<GalaxyObject>> failed = new CompletableFuture<>();
failed.completeExceptionally(new MxGatewayException(
"galaxy discover hierarchy returned repeated page token: " + reply.getNextPageToken()));
return failed;
}
return discoverHierarchyPageAsync(reply.getNextPageToken(), objects, seenPageTokens);
});
}
private static <T> CompletableFuture<T> toCompletable(com.google.common.util.concurrent.ListenableFuture<T> source) {
CompletableFuture<T> target = new CompletableFuture<>();
Futures.addCallback(
@@ -284,7 +284,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,
@@ -14,6 +14,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;
@@ -23,6 +24,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");
@@ -33,6 +35,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;
}
/**
@@ -126,6 +131,10 @@ public final class MxGatewayClientOptions {
return streamTimeout;
}
public int maxGrpcMessageBytes() {
return maxGrpcMessageBytes;
}
@Override
public String toString() {
return "MxGatewayClientOptions{"
@@ -148,6 +157,8 @@ public final class MxGatewayClientOptions {
+ callTimeout
+ ", streamTimeout="
+ streamTimeout
+ ", maxGrpcMessageBytes="
+ maxGrpcMessageBytes
+ '}';
}
@@ -170,6 +181,7 @@ public final class MxGatewayClientOptions {
private Duration connectTimeout;
private Duration callTimeout;
private Duration streamTimeout;
private int maxGrpcMessageBytes;
private Builder() {
}
@@ -265,6 +277,11 @@ public final class MxGatewayClientOptions {
return this;
}
public Builder maxGrpcMessageBytes(int value) {
maxGrpcMessageBytes = value;
return this;
}
/**
* Builds an immutable {@link MxGatewayClientOptions} from the current state.
*
@@ -7,7 +7,7 @@ package com.dohertylan.mxgateway.client;
* worker speak the same protocol version as the 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";
@@ -3,6 +3,7 @@ package com.dohertylan.mxgateway.client;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.google.protobuf.Timestamp;
@@ -25,6 +26,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 +103,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 +148,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 +161,41 @@ 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 discoverHierarchyRejectsRepeatedPageToken() throws Exception {
TestService service = new TestService() {
@Override
public void discoverHierarchy(
DiscoverHierarchyRequest request, StreamObserver<DiscoverHierarchyReply> responseObserver) {
responseObserver.onNext(DiscoverHierarchyReply.newBuilder()
.setNextPageToken("7:1")
.build());
responseObserver.onCompleted();
}
};
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
GalaxyRepositoryClient client = g.client("")) {
MxGatewayException error = assertThrows(MxGatewayException.class, client::discoverHierarchy);
assertTrue(error.getMessage().contains("repeated page token"));
}
}
@Test
void watchDeployEventsReceivesEventsInOrder() throws Exception {
DeployEvent first = DeployEvent.newBuilder()
@@ -281,6 +335,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": [
+24 -7
View File
@@ -18,11 +18,13 @@ import grpc
from google.protobuf.timestamp_pb2 import Timestamp
from .auth import merge_metadata
from .errors import map_rpc_error
from .errors import MxGatewayError, map_rpc_error
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."""
@@ -115,12 +117,27 @@ 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] = []
seen_page_tokens: set[str] = set()
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
if page_token in seen_page_tokens:
raise MxGatewayError(
f"galaxy discover hierarchy returned repeated page token {page_token!r}"
)
seen_page_tokens.add(page_token)
def watch_deploy_events(
self,
@@ -23,9 +23,10 @@ _sym_db = _symbol_database.Default()
from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2
from google.protobuf import wrappers_pb2 as google_dot_protobuf_dot_wrappers__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\x1a\x1egoogle/protobuf/wrappers.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\"\x87\x03\n\x18\x44iscoverHierarchyRequest\x12\x11\n\tpage_size\x18\x01 \x01(\x05\x12\x12\n\npage_token\x18\x02 \x01(\t\x12\x19\n\x0froot_gobject_id\x18\x03 \x01(\x05H\x00\x12\x17\n\rroot_tag_name\x18\x04 \x01(\tH\x00\x12\x1d\n\x13root_contained_path\x18\x05 \x01(\tH\x00\x12.\n\tmax_depth\x18\x06 \x01(\x0b\x32\x1b.google.protobuf.Int32Value\x12\x14\n\x0c\x63\x61tegory_ids\x18\x07 \x03(\x05\x12\x1f\n\x17template_chain_contains\x18\x08 \x03(\t\x12\x15\n\rtag_name_glob\x18\t \x01(\t\x12\x1f\n\x12include_attributes\x18\n \x01(\x08H\x01\x88\x01\x01\x12\x1a\n\x12\x61larm_bearing_only\x18\x0b \x01(\x08\x12\x17\n\x0fhistorized_only\x18\x0c \x01(\x08\x42\x06\n\x04rootB\x15\n\x13_include_attributes\"\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)
@@ -33,26 +34,26 @@ _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'galaxy_repository_pb2', _gl
if not _descriptor._USE_C_DESCRIPTORS:
_globals['DESCRIPTOR']._loaded_options = None
_globals['DESCRIPTOR']._serialized_options = b'\252\002 MxGateway.Contracts.Proto.Galaxy'
_globals['_TESTCONNECTIONREQUEST']._serialized_start=82
_globals['_TESTCONNECTIONREQUEST']._serialized_end=105
_globals['_TESTCONNECTIONREPLY']._serialized_start=107
_globals['_TESTCONNECTIONREPLY']._serialized_end=140
_globals['_GETLASTDEPLOYTIMEREQUEST']._serialized_start=142
_globals['_GETLASTDEPLOYTIMEREQUEST']._serialized_end=168
_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['_TESTCONNECTIONREQUEST']._serialized_start=114
_globals['_TESTCONNECTIONREQUEST']._serialized_end=137
_globals['_TESTCONNECTIONREPLY']._serialized_start=139
_globals['_TESTCONNECTIONREPLY']._serialized_end=172
_globals['_GETLASTDEPLOYTIMEREQUEST']._serialized_start=174
_globals['_GETLASTDEPLOYTIMEREQUEST']._serialized_end=200
_globals['_GETLASTDEPLOYTIMEREPLY']._serialized_start=202
_globals['_GETLASTDEPLOYTIMEREPLY']._serialized_end=300
_globals['_DISCOVERHIERARCHYREQUEST']._serialized_start=303
_globals['_DISCOVERHIERARCHYREQUEST']._serialized_end=694
_globals['_DISCOVERHIERARCHYREPLY']._serialized_start=697
_globals['_DISCOVERHIERARCHYREPLY']._serialized_end=827
_globals['_WATCHDEPLOYEVENTSREQUEST']._serialized_start=829
_globals['_WATCHDEPLOYEVENTSREQUEST']._serialized_end=914
_globals['_DEPLOYEVENT']._serialized_start=917
_globals['_DEPLOYEVENT']._serialized_end=1138
_globals['_GALAXYOBJECT']._serialized_start=1141
_globals['_GALAXYOBJECT']._serialized_end=1416
_globals['_GALAXYATTRIBUTE']._serialized_start=1419
_globals['_GALAXYATTRIBUTE']._serialized_end=1715
_globals['_GALAXYREPOSITORY']._serialized_start=1718
_globals['_GALAXYREPOSITORY']._serialized_end=2178
# @@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:
"""Validate options; raise `ValueError` for invalid combinations."""
@@ -33,6 +34,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:
"""Return a repr that redacts the API key value."""
@@ -43,14 +46,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"),
],
),
]
+27
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,10 +140,30 @@ 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"
@pytest.mark.asyncio
async def test_discover_hierarchy_rejects_repeated_page_token() -> None:
stub = FakeGalaxyStub()
stub.discover_hierarchy.replies = [
galaxy_pb.DiscoverHierarchyReply(next_page_token="7:1"),
galaxy_pb.DiscoverHierarchyReply(next_page_token="7:1"),
]
client = await GalaxyRepositoryClient.connect(
ClientOptions(endpoint="fake", plaintext=True),
stub=stub,
)
with pytest.raises(Exception, match="repeated page token"):
await client.discover_hierarchy()
@pytest.mark.asyncio
async def test_watch_deploy_events_yields_events_in_order() -> None:
ts1 = Timestamp()
+1 -1
View File
@@ -1048,7 +1048,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
@@ -79,9 +79,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(),
})
+140 -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,33 @@ 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 seen_page_tokens = std::collections::HashSet::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,
..Default::default()
}))
.await?;
let reply = response.into_inner();
objects.extend(reply.objects);
page_token = reply.next_page_token;
if page_token.is_empty() {
return Ok(objects);
}
if !seen_page_tokens.insert(page_token.clone()) {
return Err(Error::InvalidArgument {
name: "page_token".to_owned(),
detail: format!(
"galaxy discover hierarchy returned repeated page token `{page_token}`"
),
});
}
}
}
/// Subscribe to the server-streamed deploy-event feed.
@@ -217,6 +247,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 +288,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 +452,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 +512,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");
@@ -451,6 +527,37 @@ mod tests {
);
}
#[tokio::test]
async fn discover_hierarchy_rejects_repeated_page_token() {
let state = Arc::new(FakeState::default());
state
.discover_replies
.lock()
.unwrap()
.push_back(DiscoverHierarchyReply {
objects: Vec::new(),
next_page_token: "7:1".to_owned(),
total_object_count: 1,
});
state
.discover_replies
.lock()
.unwrap()
.push_back(DiscoverHierarchyReply {
objects: Vec::new(),
next_page_token: "7:1".to_owned(),
total_object_count: 1,
});
let endpoint = spawn_fake(state).await;
let mut client = GalaxyClient::connect(ClientOptions::new(endpoint))
.await
.unwrap();
let error = client.discover_hierarchy().await.unwrap_err();
assert!(error.to_string().contains("repeated page token"));
}
#[tokio::test]
async fn watch_deploy_events_yields_events_in_order() {
let state = Arc::new(FakeState::default());
+14
View File
@@ -8,6 +8,8 @@ use std::time::Duration;
use crate::auth::ApiKey;
const DEFAULT_MAX_GRPC_MESSAGE_BYTES: usize = 16 * 1024 * 1024;
/// Configuration for connecting to a gateway endpoint.
///
/// Defaults are 10s connect timeout, 30s call timeout, no streaming timeout,
@@ -24,6 +26,7 @@ pub struct ClientOptions {
connect_timeout: Duration,
call_timeout: Duration,
stream_timeout: Option<Duration>,
max_grpc_message_bytes: usize,
}
impl ClientOptions {
@@ -39,6 +42,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,
}
}
@@ -91,6 +95,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
}
/// Configured endpoint URL.
pub fn endpoint(&self) -> &str {
&self.endpoint
@@ -130,6 +139,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 {
@@ -150,6 +163,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
@@ -7,7 +7,7 @@
pub const CLIENT_VERSION: &str = "0.1.0-dev";
/// Public gateway gRPC protocol version this client targets.
pub const GATEWAY_PROTOCOL_VERSION: u32 = 1;
pub const GATEWAY_PROTOCOL_VERSION: u32 = 2;
/// Internal worker IPC protocol version this client expects sessions to use.
pub const WORKER_PROTOCOL_VERSION: u32 = 1;