Java client: port bulk read/write SDK methods + CLI subcommands

Final language in the bulk-CLI port wave. HEAD's MxGatewaySession had
only the subscribe-style bulks; this commit adds the value-bulks plus
matching picocli subcommands and a bench-read-bulk harness.

SDK (MxGatewaySession.java):
- List<BulkWriteResult> writeBulk(int serverHandle, List<WriteBulkEntry> entries)
- List<BulkWriteResult> write2Bulk(int serverHandle, List<Write2BulkEntry> entries)
- List<BulkWriteResult> writeSecuredBulk(int serverHandle, List<WriteSecuredBulkEntry> entries)
- List<BulkWriteResult> writeSecured2Bulk(int serverHandle, List<WriteSecured2BulkEntry> entries)
- List<BulkReadResult> readBulk(int serverHandle, List<String> tagAddresses, Duration timeout)

readBulk uses java.time.Duration for the timeout parameter (idiomatic
Java) and internally converts to the timeoutMs proto field;
Duration.ZERO / null both delegate to the worker default. Per-entry
secured user ids stay on each WriteSecured(2)BulkEntry to match the
proto's per-row shape.

CLI (MxGatewayCli.java):
- read-bulk / write-bulk / write2-bulk / write-secured-bulk /
  write-secured2-bulk as picocli @Command subcommands. Write families
  share value-parsing logic; gating of --current-user-id /
  --verifier-user-id / --timestamp matches the cross-language flag
  contract.
- bench-read-bulk: --iterations / --warmup loop with avg/min/max ms
  reporting plus a --json mode that emits the cross-language bench
  JSON schema.

A small fixture in MxGatewayCliTests.FakeSession adds stub
implementations of the five new interface methods so the test module
compiles.

Verification: gradle build BUILD SUCCESSFUL (4 tasks executed, all
tests pass); gradle :zb-mom-ww-mxgateway-cli:installDist BUILD
SUCCESSFUL. Manual smoke against live gateway on localhost:5120:
open-session → register → read-bulk cold (wasCached=false both tags)
→ subscribe-bulk → read-bulk warm (wasCached=true both tags) →
write-bulk int32 111,222 (both wasSuccessful=true) → write2-bulk
timestamped (both wasSuccessful=true) → write-secured-bulk and
write-secured2-bulk return per-entry MXAccess "Value does not fall
within the expected range" failures with the configured user/verifier
ids (0,0) — confirming the SDK does NOT throw on per-entry MXAccess
failures and surfaces them through BulkWriteResult exactly as the
.NET and Go ports do → bench-read-bulk iterations=20 avg=9.5 ms
last_success=2/2 cached=2/2 → close-session SESSION_STATE_CLOSED.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-24 04:50:34 -04:00
parent 6add4b4acc
commit f90bff01db
3 changed files with 672 additions and 0 deletions
@@ -1,6 +1,7 @@
package com.zb.mom.ww.mxgateway.client;
import java.security.SecureRandom;
import java.time.Duration;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
@@ -9,6 +10,8 @@ import mxaccess_gateway.v1.MxaccessGateway.AddItemBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.AddItemCommand;
import mxaccess_gateway.v1.MxaccessGateway.AdviseItemBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.AdviseCommand;
import mxaccess_gateway.v1.MxaccessGateway.BulkReadResult;
import mxaccess_gateway.v1.MxaccessGateway.BulkWriteResult;
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply;
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
import mxaccess_gateway.v1.MxaccessGateway.MxCommand;
@@ -17,6 +20,7 @@ import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
import mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest;
import mxaccess_gateway.v1.MxaccessGateway.MxValue;
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply;
import mxaccess_gateway.v1.MxaccessGateway.ReadBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.RegisterCommand;
import mxaccess_gateway.v1.MxaccessGateway.RemoveItemBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.RemoveItemCommand;
@@ -27,8 +31,16 @@ import mxaccess_gateway.v1.MxaccessGateway.UnAdviseCommand;
import mxaccess_gateway.v1.MxaccessGateway.UnAdviseItemBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.UnsubscribeBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.UnregisterCommand;
import mxaccess_gateway.v1.MxaccessGateway.Write2BulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.Write2BulkEntry;
import mxaccess_gateway.v1.MxaccessGateway.Write2Command;
import mxaccess_gateway.v1.MxaccessGateway.WriteBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.WriteBulkEntry;
import mxaccess_gateway.v1.MxaccessGateway.WriteCommand;
import mxaccess_gateway.v1.MxaccessGateway.WriteSecured2BulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.WriteSecured2BulkEntry;
import mxaccess_gateway.v1.MxaccessGateway.WriteSecuredBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.WriteSecuredBulkEntry;
/**
* Typed handle for a single MXAccess gateway session.
@@ -421,6 +433,142 @@ public final class MxGatewaySession implements AutoCloseable {
return reply.getUnsubscribeBulk().getResultsList();
}
/**
* Bulk {@code Write} — sequential MXAccess Write per entry on the worker's STA.
*
* <p>Per-entry failures appear as {@link BulkWriteResult} entries with
* {@code wasSuccessful == false}; this method does not throw for per-entry
* MXAccess failures (it still throws {@link MxGatewayException} on transport
* or protocol-level failures).
*
* @param serverHandle the {@code ServerHandle} owning the items
* @param entries the per-item (handle, value, user id) tuples
* @return a per-entry {@link BulkWriteResult} list
* @throws MxGatewayException on transport or protocol failure
* @throws NullPointerException if {@code entries} is {@code null}
*/
public List<BulkWriteResult> writeBulk(int serverHandle, List<WriteBulkEntry> entries) {
Objects.requireNonNull(entries, "entries");
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_WRITE_BULK)
.setWriteBulk(WriteBulkCommand.newBuilder()
.setServerHandle(serverHandle)
.addAllEntries(entries))
.build());
return reply.getWriteBulk().getResultsList();
}
/**
* Bulk {@code Write2} — sequential MXAccess Write2 (timestamped) per entry.
*
* <p>Per-entry semantics mirror {@link #writeBulk(int, List)}.
*
* @param serverHandle the {@code ServerHandle} owning the items
* @param entries the per-item (handle, value, timestamp, user id) tuples
* @return a per-entry {@link BulkWriteResult} list
* @throws MxGatewayException on transport or protocol failure
* @throws NullPointerException if {@code entries} is {@code null}
*/
public List<BulkWriteResult> write2Bulk(int serverHandle, List<Write2BulkEntry> entries) {
Objects.requireNonNull(entries, "entries");
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_WRITE2_BULK)
.setWrite2Bulk(Write2BulkCommand.newBuilder()
.setServerHandle(serverHandle)
.addAllEntries(entries))
.build());
return reply.getWrite2Bulk().getResultsList();
}
/**
* Bulk {@code WriteSecured} — credential-sensitive values must not be logged
* by callers; mirrors the single-item write-secured redaction contract.
*
* <p>Per-entry semantics mirror {@link #writeBulk(int, List)}.
*
* @param serverHandle the {@code ServerHandle} owning the items
* @param entries the per-item (handle, value, current+verifier user id) tuples
* @return a per-entry {@link BulkWriteResult} list
* @throws MxGatewayException on transport or protocol failure
* @throws NullPointerException if {@code entries} is {@code null}
*/
public List<BulkWriteResult> writeSecuredBulk(int serverHandle, List<WriteSecuredBulkEntry> entries) {
Objects.requireNonNull(entries, "entries");
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_WRITE_SECURED_BULK)
.setWriteSecuredBulk(WriteSecuredBulkCommand.newBuilder()
.setServerHandle(serverHandle)
.addAllEntries(entries))
.build());
return reply.getWriteSecuredBulk().getResultsList();
}
/**
* Bulk {@code WriteSecured2} — sequential timestamped + verified write per entry.
*
* <p>Per-entry semantics mirror {@link #writeBulk(int, List)}.
*
* @param serverHandle the {@code ServerHandle} owning the items
* @param entries the per-item (handle, value, timestamp, current+verifier user id) tuples
* @return a per-entry {@link BulkWriteResult} list
* @throws MxGatewayException on transport or protocol failure
* @throws NullPointerException if {@code entries} is {@code null}
*/
public List<BulkWriteResult> writeSecured2Bulk(int serverHandle, List<WriteSecured2BulkEntry> entries) {
Objects.requireNonNull(entries, "entries");
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_WRITE_SECURED2_BULK)
.setWriteSecured2Bulk(WriteSecured2BulkCommand.newBuilder()
.setServerHandle(serverHandle)
.addAllEntries(entries))
.build());
return reply.getWriteSecured2Bulk().getResultsList();
}
/**
* Bulk {@code Read} — snapshot the current value of each requested tag.
*
* <p>MXAccess COM has no synchronous read; the worker returns the cached
* {@code OnDataChange} value for any tag that is already advised
* ({@code wasCached == true}) without modifying the existing subscription,
* and falls back to a full AddItem + Advise + wait + UnAdvise + RemoveItem
* snapshot lifecycle otherwise. The supplied {@code timeout} bounds the
* per-tag wait in the snapshot case; pass {@link Duration#ZERO} (or
* {@code null}) to use the worker default (1000 ms). Per-tag failures
* appear as {@link BulkReadResult} entries with {@code wasSuccessful == false};
* this method does not throw for per-tag MXAccess failures.
*
* @param serverHandle the {@code ServerHandle} owning the items
* @param tagAddresses the tag addresses to read
* @param timeout per-tag snapshot timeout (zero or null = worker default)
* @return a per-tag {@link BulkReadResult} list
* @throws MxGatewayException on transport or protocol failure
* @throws NullPointerException if {@code tagAddresses} is {@code null}
* @throws IllegalArgumentException if {@code timeout} is negative or exceeds {@link Integer#MAX_VALUE} milliseconds
*/
public List<BulkReadResult> readBulk(int serverHandle, List<String> tagAddresses, Duration timeout) {
Objects.requireNonNull(tagAddresses, "tagAddresses");
int timeoutMs = 0;
if (timeout != null) {
if (timeout.isNegative()) {
throw new IllegalArgumentException("timeout must be non-negative");
}
long millis = timeout.toMillis();
if (millis > Integer.MAX_VALUE) {
throw new IllegalArgumentException("timeout exceeds Integer.MAX_VALUE milliseconds");
}
timeoutMs = (int) millis;
}
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_READ_BULK)
.setReadBulk(ReadBulkCommand.newBuilder()
.setServerHandle(serverHandle)
.addAllTagAddresses(tagAddresses)
.setTimeoutMs(timeoutMs))
.build());
return reply.getReadBulk().getResultsList();
}
/**
* Invokes MXAccess {@code Write}.
*