Adds five new MXAccess command kinds (WriteBulk, Write2Bulk,
WriteSecuredBulk, WriteSecured2Bulk, ReadBulk) that ride the existing
"one round-trip, per-entry results" bulk shape used by AddItemBulk and
SubscribeBulk today. MXAccess COM has no native bulk API; the worker
runs each bulk operation as a sequential loop on its STA, returning
one BulkWriteResult / BulkReadResult per requested entry so per-item
MXAccess failures surface as was_successful=false rather than throwing.
ReadBulk has no MXAccess analogue. The worker satisfies it by:
- Returning the last cached OnDataChange payload (was_cached=true)
when the requested tag is already in the session''s item registry
AND advised — the existing subscription is NOT touched, since the
caller did not create it.
- Otherwise taking the AddItem + Advise + wait-for-OnDataChange +
UnAdvise + RemoveItem snapshot lifecycle itself (was_cached=false)
and leaving the session exactly as it was. The wait pumps Windows
messages on the STA so the inbound MXAccess event can dispatch
while the executor still holds the thread.
The new MxAccessValueCache lives on each MxAccessSession, shared with
MxAccessBaseEventSink which populates it on every OnDataChange after
the event clears the outbound queue. Eviction on RemoveItem keeps
reused MXAccess handles from serving stale values from a previous
lifetime.
Gateway-side authorization wires WriteBulk/Write2Bulk to invoke:write,
WriteSecuredBulk/WriteSecured2Bulk to invoke:secure, ReadBulk to
invoke:read. The constraint-filter pipeline is refactored from a single
BulkConstraintPlan record into an abstract base plus three concretes
(SubscribeBulk, WriteBulk, ReadBulk), each owning its own denied-entry
merge so the dispatch site never branches on reply shape. A new
FilterWriteBulkAsync<TEntry> generic over the four write-entry shapes
runs CheckWriteHandleAsync per entry; denied entries surface as the
BulkWriteResult shape, preserving original-index order.
All five language clients (.NET, Go, Rust, Python, Java) gained the
five new methods following their existing bulk pattern, with regenerated
protobufs.
Tests added:
- MxAccessValueCacheTests (6 cases) — Set/TryGet, Remove resets the
version, TryWaitForUpdate signals on Set, pump step fires each poll.
- MxAccessBaseEventSinkTests — OnDataChange populates the cache,
ValueCache property exposes the bound instance.
- MxAccessCommandExecutorTests — four bulk-write variants (per-entry
success/failure, value+timestamp forwarding, secured user ids),
ReadBulk snapshot lifecycle on uncached tag (timeout surfaces as
was_successful=false), invalid-payload reply.
- GatewayGrpcScopeResolverTests — five new MxCommandKind cases.
- SessionManagerTests — WriteBulk and ReadBulk forwarding through
FakeWorkerHarness; ReadBulk forwards timeout_ms.
- Per-client (.NET, Go, Rust, Python, Java) — WriteBulk builds the
right command and returns per-entry results, ReadBulk forwards the
timeout and unpacks the was_cached flag.
Cross-language e2e CLI subcommands for the new bulks are deliberately
scoped out of this change (each of the five client CLIs would need
five new subcommands plus matching phases in
scripts/run-client-e2e-tests.ps1); coverage equivalent to the existing
bulk-subscribe coverage is provided by worker + gateway + per-client
unit tests.
Docs updated in the same commit: gateway.md (Public MXAccess Command
Surface), docs/DesignDecisions.md (new "Bulk Command Family" section
with the ReadBulk cache-then-snapshot rationale), and every client
README.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Java Client
The Java client workspace contains the MXAccess Gateway client library, generated protobuf/gRPC bindings, a Picocli test CLI project, and JUnit tests.
Layout
clients/java/
settings.gradle
build.gradle
src/main/generated/
mxgateway-client/
mxgateway-cli/
mxgateway-client generates Java protobuf and gRPC sources from
../../src/MxGateway.Contracts/Protos. The Gradle protobuf plugin writes those
generated sources under src/main/generated, which matches the client proto
manifest in ../proto/proto-inputs.json. Do not edit generated files by hand.
mxgateway-client exposes MxGatewayClientOptions, MxGatewayClient,
MxGatewaySession, value/status helpers, typed gateway exceptions, raw
generated stubs, and generated protobuf messages for parity tests.
mxgateway-cli depends on mxgateway-client and provides the mxgw-java
application entry point. The CLI supports version, session, command, event
streaming, write, and smoke-test commands with deterministic JSON output.
Regenerating Protobuf Bindings
Run generation from clients/java after the shared .proto files or Java
output path changes:
gradle :mxgateway-client:generateProto
Client Usage
Create a client with explicit transport and auth options:
MxGatewayClientOptions options = MxGatewayClientOptions.builder()
.endpoint("localhost:5000")
.apiKey(System.getenv("MXGATEWAY_API_KEY"))
.plaintext(true)
.build();
try (MxGatewayClient client = MxGatewayClient.connect(options);
MxGatewaySession session = client.openSession("java-client")) {
int serverHandle = session.register("java-client");
int itemHandle = session.addItem(serverHandle, "TestObject.TestInt");
session.advise(serverHandle, itemHandle);
session.write(serverHandle, itemHandle, MxValues.int32Value(123), 0);
}
Use rawBlockingStub, rawFutureStub, rawAsyncStub, openSessionRaw,
closeSessionRaw, invoke, and raw session helper methods when tests need the
underlying protobuf messages. MxGatewayCommandException and
MxAccessException preserve the raw MxCommandReply when the gateway returns a
data-bearing MXAccess failure.
MxGatewaySession exposes the full bulk family — addItemBulk,
adviseItemBulk, removeItemBulk, unAdviseItemBulk, subscribeBulk,
unsubscribeBulk, writeBulk, write2Bulk, writeSecuredBulk,
writeSecured2Bulk, and readBulk. Each carries one round-trip with a
List<*Entry> (or List<String> / List<Integer> for the legacy bulk
shapes) and returns List<SubscribeResult> / List<BulkWriteResult> /
List<BulkReadResult>; per-entry MXAccess failures populate
wasSuccessful == false and never throw. readBulk takes a per-tag
timeoutMs (0 = worker default) and returns cached OnDataChange values
when the tag is already advised (wasCached == true) without touching the
existing subscription.
openSession verifies the gateway's reported gateway_protocol_version against
the version this client was generated for and throws MxGatewayException on a
mismatch, so an incompatible client fails fast with a clear message instead of
issuing commands that fail downstream. A gateway that does not populate the
field is accepted unchanged.
MxGatewaySession implements AutoCloseable. The try-with-resources close()
performs a CloseSession network RPC but swallows (and logs) any failure of
that RPC so a close-time error never replaces the exception a try-with-resources
body is already propagating. Call closeRaw() explicitly when you need to
observe the close result or handle a close-time failure.
MxGatewayClient and GalaxyRepositoryClient implement AutoCloseable. For a
client that owns its channel (built with connect), the try-with-resources
close() shuts the channel down and waits up to the configured connect timeout
for termination, forcibly shutting it down on timeout, so in-flight calls and
Netty event-loop threads are not left running after the block exits. If the
calling thread is interrupted while waiting, the channel is forcibly shut down
and the interrupt flag is restored. closeAndAwaitTermination() does the same
but throws InterruptedException for callers that want a checked,
blocking-aware shutdown. close() is a no-op for a caller-managed channel.
MxEventStream implements Iterator<MxEvent> and AutoCloseable. Closing it
cancels the underlying gRPC stream. Canceling or timing out a Java client call
only stops the client from waiting; it does not abort an in-flight MXAccess COM
call on the worker STA. The event stream uses gRPC's default auto-inbound flow
control with a fixed 16-element buffer and no client-side flow control: this is
the gateway's documented fail-fast event-backpressure model, so a consumer that
stalls long enough to fill the buffer triggers an overflow that cancels the
subscription and surfaces an MxGatewayException from the next next() call.
Drain events promptly and be prepared to resubscribe with a resume cursor.
Galaxy Repository Browse
The Galaxy Repository service is a separate metadata-only gRPC service exposed
by the gateway. It lets clients enumerate the deployed Galaxy object hierarchy
and the dynamic attributes on each object so they know which tag references to
subscribe to via the MXAccess Gateway service. It uses the same API-key auth as
the gateway and requires the metadata:read scope.
GalaxyRepositoryClient mirrors the MxGatewayClient pattern (caller-managed
or owned channel, MxGatewayClientOptions, blocking + async variants). Three
RPCs are exposed:
MxGatewayClientOptions options = MxGatewayClientOptions.builder()
.endpoint("localhost:5000")
.apiKey(System.getenv("MXGATEWAY_API_KEY"))
.plaintext(true)
.build();
try (GalaxyRepositoryClient galaxy = GalaxyRepositoryClient.connect(options)) {
boolean ok = galaxy.testConnection();
Optional<Instant> lastDeploy = galaxy.getLastDeployTime();
List<GalaxyObject> hierarchy = galaxy.discoverHierarchy();
}
getLastDeployTime returns Optional.empty() when the server reports
present=false. discoverHierarchy returns the generated GalaxyObject proto
messages directly so callers can read all fields (including the nested
GalaxyAttribute list) without an extra DTO layer.
The CLI exposes matching subcommands: galaxy-test, galaxy-deploy-time,
galaxy-discover, and galaxy-watch. They take the same --endpoint,
--api-key-env, --plaintext, --ca-file, --server-name-override,
--timeout, and --json options as the gateway commands.
gradle :mxgateway-cli:run --args="galaxy-test --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
gradle :mxgateway-cli:run --args="galaxy-deploy-time --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
gradle :mxgateway-cli:run --args="galaxy-discover --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
Watching deploy events
GalaxyRepository.WatchDeployEvents is a server-streaming RPC: the gateway
sends a bootstrap DeployEvent immediately on subscribe and then one event
each time it observes a new galaxy.time_of_last_deploy. The sequence field
is monotonic per server start; gaps mean the per-subscriber buffer dropped
older events because the consumer was too slow.
The client exposes both an iterator-style adaptor over the async stub and an
observer-callback variant. Both honour the channel-level streamTimeout.
try (GalaxyRepositoryClient galaxy = GalaxyRepositoryClient.connect(options);
DeployEventStream events = galaxy.watchDeployEvents(/* lastSeenDeployTime */ null)) {
while (events.hasNext()) {
DeployEvent event = events.next();
// event.getSequence(), event.getObservedAt(),
// event.getTimeOfLastDeploy() / getTimeOfLastDeployPresent(),
// event.getObjectCount(), event.getAttributeCount()
}
}
Pass an Instant for lastSeenDeployTime to suppress the bootstrap event when
the cached deploy time matches what the caller already has. DeployEventStream
implements Iterator<DeployEvent> and AutoCloseable; closing it cancels the
underlying gRPC call.
For callback delivery (e.g. when the consumer wants to drive a queue or reactive pipeline), use the async variant:
DeployEventSubscription subscription = galaxy.watchDeployEventsAsync(
lastSeen,
new StreamObserver<>() {
@Override public void onNext(DeployEvent value) { /* ... */ }
@Override public void onError(Throwable t) { /* ... */ }
@Override public void onCompleted() { /* ... */ }
});
// later:
subscription.cancel(); // or subscription.close()
The matching CLI subcommand streams events until cancelled (Ctrl+C) and prints
one line per event in text mode or one JSON object per event with --json:
gradle :mxgateway-cli:run --args="galaxy-watch --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
gradle :mxgateway-cli:run --args="galaxy-watch --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --last-seen-deploy-time 2026-04-28T18:30:00Z --limit 5"
CLI Usage
Run the CLI through Gradle:
gradle :mxgateway-cli:run --args="version --json"
gradle :mxgateway-cli:run --args="open-session --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --client-session-name java-cli --json"
gradle :mxgateway-cli:run --args="register --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --client-name java-cli --json"
gradle :mxgateway-cli:run --args="add-item --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item TestObject.TestInt --json"
gradle :mxgateway-cli:run --args="advise --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item-handle 1 --json"
gradle :mxgateway-cli:run --args="write --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --json"
gradle :mxgateway-cli:run --args="stream-events --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --limit 1 --json"
gradle :mxgateway-cli:run --args="smoke --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --item TestObject.TestInt --json"
The CLI accepts --api-key, --api-key-env, --plaintext, --ca-file,
--server-name-override, --timeout, and --json on gateway commands. JSON
output redacts API keys.
Use TLS options for a secured gateway:
gradle :mxgateway-cli:run --args="smoke --endpoint mxgateway.example.local:5001 --ca-file C:\certs\mxgateway-ca.pem --server-name-override mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item TestObject.TestInt --json"
Build And Test
Run the Java checks from clients/java:
gradle test
The build uses the Java 21 Gradle toolchain, compiles generated protobuf/gRPC code, and runs JUnit 5 tests for the client wrapper, shared behavior fixtures, in-process gRPC behavior, stream cancellation, and CLI parser/output behavior.
Packaging
Create local library and CLI artifacts from clients/java:
gradle :mxgateway-client:jar :mxgateway-cli:installDist
The library jar is under mxgateway-client/build/libs. The installed CLI
distribution is under mxgateway-cli/build/install/mxgateway-cli.
Integration Checks
Run live checks only when a gateway and MXAccess-backed worker are available:
$env:MXGATEWAY_INTEGRATION = '1'
$env:MXGATEWAY_ENDPOINT = 'localhost:5000'
$env:MXGATEWAY_API_KEY = '<gateway-api-key>'
$env:MXGATEWAY_TEST_ITEM = 'TestObject.TestInt'
gradle :mxgateway-cli:run --args="smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json"