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
@@ -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)