Add bulk read/write CLI subcommands and e2e matrix coverage

The previous commit added the bulk read/write library surface in every
client; this commit makes that surface reachable from each client's CLI
and exercises it through scripts/run-client-e2e-tests.ps1.

Five new subcommands in every client CLI (.NET / Go / Rust / Python /
Java): read-bulk, write-bulk, write2-bulk, write-secured-bulk, and
write-secured2-bulk. Each follows the existing subscribe-bulk shape:

  - read-bulk takes --server-handle, --items <csv tag list>, and
    --timeout-ms (0 = worker default). JSON output carries the
    BulkReadResult fields, including was_cached so the e2e matrix can
    verify the cached-path semantics.
  - The four bulk-write families take --server-handle, --item-handles
    <csv>, --type, --values <csv>. write2-bulk and write-secured2-bulk
    add a single --timestamp applied to every entry; the secured
    variants take --current-user-id and --verifier-user-id. All four
    output BulkWriteResult JSON.

A new -SkipReadWriteBulk switch on the matrix script (default OFF)
controls two new e2e phases:

  - After the existing subscribe-bulk phase leaves tags advised, the
    script runs read-bulk against the same tag list and asserts most
    results return was_cached = true. This is the only e2e coverage of
    the cache-then-snapshot fork — the unit + gateway tests verify the
    semantics with a fake worker, but only the live cross-language
    matrix proves the cache populates from real OnDataChange events and
    survives the round-trip through every client''s JSON parser.
  - When -VerifyWrite is set, the write phase now also runs a single-
    entry write-bulk against the same writable item handle (using a
    distinct sentinel value) and asserts a per-entry success. Confirms
    the BulkWriteResult wire format end-to-end without complicating
    the OnWriteComplete echo assertion the single-item phase already
    verifies.

Dry-run validation passes for all five clients: each emits the correct
read-bulk and write-bulk CLI invocations with the right flags.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-20 04:06:14 -04:00
parent 5e375f6d3d
commit f220908f3f
6 changed files with 1411 additions and 4 deletions
@@ -25,12 +25,18 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.Callable;
import mxaccess_gateway.v1.MxaccessGateway.BulkReadResult;
import mxaccess_gateway.v1.MxaccessGateway.BulkWriteResult;
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
import mxaccess_gateway.v1.MxaccessGateway.MxValue;
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest;
import mxaccess_gateway.v1.MxaccessGateway.SubscribeResult;
import mxaccess_gateway.v1.MxaccessGateway.Write2BulkEntry;
import mxaccess_gateway.v1.MxaccessGateway.WriteBulkEntry;
import mxaccess_gateway.v1.MxaccessGateway.WriteSecured2BulkEntry;
import mxaccess_gateway.v1.MxaccessGateway.WriteSecuredBulkEntry;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Mixin;
@@ -109,6 +115,11 @@ public final class MxGatewayCli implements Callable<Integer> {
commandLine.addSubcommand("advise", new AdviseCommand(clientFactory));
commandLine.addSubcommand("subscribe-bulk", new SubscribeBulkCommand(clientFactory));
commandLine.addSubcommand("unsubscribe-bulk", new UnsubscribeBulkCommand(clientFactory));
commandLine.addSubcommand("read-bulk", new ReadBulkCommand(clientFactory));
commandLine.addSubcommand("write-bulk", new WriteBulkCommand(clientFactory));
commandLine.addSubcommand("write2-bulk", new Write2BulkCommand(clientFactory));
commandLine.addSubcommand("write-secured-bulk", new WriteSecuredBulkCommand(clientFactory));
commandLine.addSubcommand("write-secured2-bulk", new WriteSecured2BulkCommand(clientFactory));
commandLine.addSubcommand("write", new WriteCommand(clientFactory));
commandLine.addSubcommand("stream-events", new StreamEventsCommand(clientFactory));
commandLine.addSubcommand("smoke", new SmokeCommand(clientFactory));
@@ -518,6 +529,246 @@ public final class MxGatewayCli implements Callable<Integer> {
}
}
@Command(name = "read-bulk", description = "Invokes MXAccess ReadBulk (cached or snapshot per tag).")
static final class ReadBulkCommand extends GatewayCommand {
@Option(names = "--session-id", required = true, description = "Gateway session id.")
String sessionId;
@Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
int serverHandle;
@Option(names = "--items", required = true, description = "Comma-separated tag addresses.")
String items;
@Option(names = "--timeout-ms", defaultValue = "0",
description = "Per-tag snapshot timeout in milliseconds (0 = worker default).")
int timeoutMs;
ReadBulkCommand(MxGatewayCliClientFactory clientFactory) {
super(clientFactory);
}
@Override
public Integer call() {
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
List<BulkReadResult> results =
client.session(sessionId).readBulk(serverHandle, parseStringList(items), timeoutMs);
writeReadBulkOutput("read-bulk", common, json, results);
}
return 0;
}
}
@Command(name = "write-bulk", description = "Invokes MXAccess WriteBulk.")
static final class WriteBulkCommand extends GatewayCommand {
@Option(names = "--session-id", required = true, description = "Gateway session id.")
String sessionId;
@Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
int serverHandle;
@Option(names = "--item-handles", required = true, description = "Comma-separated item handles.")
String itemHandles;
@Option(names = "--type", defaultValue = "string", description = "Value type for all entries.")
String type;
@Option(names = "--values", required = true, description = "Comma-separated values, one per item handle.")
String values;
@Option(names = "--user-id", defaultValue = "0", description = "MXAccess user id.")
int userId;
WriteBulkCommand(MxGatewayCliClientFactory clientFactory) {
super(clientFactory);
}
@Override
public Integer call() {
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
List<Integer> handles = parseIntList(itemHandles);
List<String> valueTexts = parseStringList(values);
if (handles.size() != valueTexts.size()) {
throw new IllegalArgumentException(
"item-handles count (" + handles.size() + ") does not match values count (" + valueTexts.size() + ")");
}
List<WriteBulkEntry> entries = new ArrayList<>(handles.size());
for (int i = 0; i < handles.size(); i++) {
entries.add(WriteBulkEntry.newBuilder()
.setItemHandle(handles.get(i))
.setUserId(userId)
.setValue(parseValue(type, valueTexts.get(i)))
.build());
}
List<BulkWriteResult> results = client.session(sessionId).writeBulk(serverHandle, entries);
writeWriteBulkOutput("write-bulk", common, json, results);
}
return 0;
}
}
@Command(name = "write2-bulk", description = "Invokes MXAccess Write2Bulk (timestamped).")
static final class Write2BulkCommand extends GatewayCommand {
@Option(names = "--session-id", required = true, description = "Gateway session id.")
String sessionId;
@Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
int serverHandle;
@Option(names = "--item-handles", required = true, description = "Comma-separated item handles.")
String itemHandles;
@Option(names = "--type", defaultValue = "string", description = "Value type for all entries.")
String type;
@Option(names = "--values", required = true, description = "Comma-separated values, one per item handle.")
String values;
@Option(names = "--timestamp", required = true, description = "ISO-8601 timestamp shared across all entries.")
String timestamp;
@Option(names = "--user-id", defaultValue = "0", description = "MXAccess user id.")
int userId;
Write2BulkCommand(MxGatewayCliClientFactory clientFactory) {
super(clientFactory);
}
@Override
public Integer call() {
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
List<Integer> handles = parseIntList(itemHandles);
List<String> valueTexts = parseStringList(values);
if (handles.size() != valueTexts.size()) {
throw new IllegalArgumentException(
"item-handles count (" + handles.size() + ") does not match values count (" + valueTexts.size() + ")");
}
MxValue timestampValue = MxValues.timestampValue(Instant.parse(timestamp));
List<Write2BulkEntry> entries = new ArrayList<>(handles.size());
for (int i = 0; i < handles.size(); i++) {
entries.add(Write2BulkEntry.newBuilder()
.setItemHandle(handles.get(i))
.setUserId(userId)
.setValue(parseValue(type, valueTexts.get(i)))
.setTimestampValue(timestampValue)
.build());
}
List<BulkWriteResult> results = client.session(sessionId).write2Bulk(serverHandle, entries);
writeWriteBulkOutput("write2-bulk", common, json, results);
}
return 0;
}
}
@Command(name = "write-secured-bulk", description = "Invokes MXAccess WriteSecuredBulk.")
static final class WriteSecuredBulkCommand extends GatewayCommand {
@Option(names = "--session-id", required = true, description = "Gateway session id.")
String sessionId;
@Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
int serverHandle;
@Option(names = "--item-handles", required = true, description = "Comma-separated item handles.")
String itemHandles;
@Option(names = "--type", defaultValue = "string", description = "Value type for all entries.")
String type;
@Option(names = "--values", required = true, description = "Comma-separated values, one per item handle.")
String values;
@Option(names = "--current-user-id", defaultValue = "0", description = "MXAccess current user id.")
int currentUserId;
@Option(names = "--verifier-user-id", defaultValue = "0", description = "MXAccess verifier user id.")
int verifierUserId;
WriteSecuredBulkCommand(MxGatewayCliClientFactory clientFactory) {
super(clientFactory);
}
@Override
public Integer call() {
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
List<Integer> handles = parseIntList(itemHandles);
List<String> valueTexts = parseStringList(values);
if (handles.size() != valueTexts.size()) {
throw new IllegalArgumentException(
"item-handles count (" + handles.size() + ") does not match values count (" + valueTexts.size() + ")");
}
List<WriteSecuredBulkEntry> entries = new ArrayList<>(handles.size());
for (int i = 0; i < handles.size(); i++) {
entries.add(WriteSecuredBulkEntry.newBuilder()
.setItemHandle(handles.get(i))
.setCurrentUserId(currentUserId)
.setVerifierUserId(verifierUserId)
.setValue(parseValue(type, valueTexts.get(i)))
.build());
}
List<BulkWriteResult> results = client.session(sessionId).writeSecuredBulk(serverHandle, entries);
writeWriteBulkOutput("write-secured-bulk", common, json, results);
}
return 0;
}
}
@Command(name = "write-secured2-bulk", description = "Invokes MXAccess WriteSecured2Bulk.")
static final class WriteSecured2BulkCommand extends GatewayCommand {
@Option(names = "--session-id", required = true, description = "Gateway session id.")
String sessionId;
@Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
int serverHandle;
@Option(names = "--item-handles", required = true, description = "Comma-separated item handles.")
String itemHandles;
@Option(names = "--type", defaultValue = "string", description = "Value type for all entries.")
String type;
@Option(names = "--values", required = true, description = "Comma-separated values, one per item handle.")
String values;
@Option(names = "--timestamp", required = true, description = "ISO-8601 timestamp shared across all entries.")
String timestamp;
@Option(names = "--current-user-id", defaultValue = "0", description = "MXAccess current user id.")
int currentUserId;
@Option(names = "--verifier-user-id", defaultValue = "0", description = "MXAccess verifier user id.")
int verifierUserId;
WriteSecured2BulkCommand(MxGatewayCliClientFactory clientFactory) {
super(clientFactory);
}
@Override
public Integer call() {
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
List<Integer> handles = parseIntList(itemHandles);
List<String> valueTexts = parseStringList(values);
if (handles.size() != valueTexts.size()) {
throw new IllegalArgumentException(
"item-handles count (" + handles.size() + ") does not match values count (" + valueTexts.size() + ")");
}
MxValue timestampValue = MxValues.timestampValue(Instant.parse(timestamp));
List<WriteSecured2BulkEntry> entries = new ArrayList<>(handles.size());
for (int i = 0; i < handles.size(); i++) {
entries.add(WriteSecured2BulkEntry.newBuilder()
.setItemHandle(handles.get(i))
.setCurrentUserId(currentUserId)
.setVerifierUserId(verifierUserId)
.setValue(parseValue(type, valueTexts.get(i)))
.setTimestampValue(timestampValue)
.build());
}
List<BulkWriteResult> results = client.session(sessionId).writeSecured2Bulk(serverHandle, entries);
writeWriteBulkOutput("write-secured2-bulk", common, json, results);
}
return 0;
}
}
@Command(name = "write", description = "Invokes MXAccess Write.")
static final class WriteCommand extends GatewayCommand {
@Option(names = "--session-id", required = true, description = "Gateway session id.")
@@ -760,6 +1011,16 @@ public final class MxGatewayCli implements Callable<Integer> {
List<SubscribeResult> unsubscribeBulk(int serverHandle, List<Integer> itemHandles);
List<BulkReadResult> readBulk(int serverHandle, List<String> items, int timeoutMs);
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);
MxEventStream streamEventsAfter(long afterWorkerSequence);
}
@@ -851,6 +1112,31 @@ public final class MxGatewayCli implements Callable<Integer> {
return session.unsubscribeBulk(serverHandle, itemHandles);
}
@Override
public List<BulkReadResult> readBulk(int serverHandle, List<String> items, int timeoutMs) {
return session.readBulk(serverHandle, items, timeoutMs);
}
@Override
public List<BulkWriteResult> writeBulk(int serverHandle, List<WriteBulkEntry> entries) {
return session.writeBulk(serverHandle, entries);
}
@Override
public List<BulkWriteResult> write2Bulk(int serverHandle, List<Write2BulkEntry> entries) {
return session.write2Bulk(serverHandle, entries);
}
@Override
public List<BulkWriteResult> writeSecuredBulk(int serverHandle, List<WriteSecuredBulkEntry> entries) {
return session.writeSecuredBulk(serverHandle, entries);
}
@Override
public List<BulkWriteResult> writeSecured2Bulk(int serverHandle, List<WriteSecured2BulkEntry> entries) {
return session.writeSecured2Bulk(serverHandle, entries);
}
@Override
public MxEventStream streamEventsAfter(long afterWorkerSequence) {
return session.streamEventsAfter(afterWorkerSequence);
@@ -899,6 +1185,56 @@ public final class MxGatewayCli implements Callable<Integer> {
return values;
}
private static void writeWriteBulkOutput(
String command, CommonOptions common, boolean json, List<BulkWriteResult> results) {
PrintWriter out = common.spec.commandLine().getOut();
if (json) {
Map<String, Object> output = new LinkedHashMap<>();
output.put("command", command);
output.put("options", common.redactedJsonMap());
output.put("results", results.stream().map(MxGatewayCli::bulkWriteResultMap).toList());
out.println(jsonObject(output));
return;
}
out.println(results.size());
}
private static Map<String, Object> bulkWriteResultMap(BulkWriteResult result) {
Map<String, Object> values = new LinkedHashMap<>();
values.put("serverHandle", result.getServerHandle());
values.put("itemHandle", result.getItemHandle());
values.put("wasSuccessful", result.getWasSuccessful());
values.put("hresult", result.hasHresult() ? (Object) result.getHresult() : null);
values.put("errorMessage", result.getErrorMessage());
return values;
}
private static void writeReadBulkOutput(
String command, CommonOptions common, boolean json, List<BulkReadResult> results) {
PrintWriter out = common.spec.commandLine().getOut();
if (json) {
Map<String, Object> output = new LinkedHashMap<>();
output.put("command", command);
output.put("options", common.redactedJsonMap());
output.put("results", results.stream().map(MxGatewayCli::bulkReadResultMap).toList());
out.println(jsonObject(output));
return;
}
out.println(results.size());
}
private static Map<String, Object> bulkReadResultMap(BulkReadResult result) {
Map<String, Object> values = new LinkedHashMap<>();
values.put("serverHandle", result.getServerHandle());
values.put("tagAddress", result.getTagAddress());
values.put("itemHandle", result.getItemHandle());
values.put("wasSuccessful", result.getWasSuccessful());
values.put("wasCached", result.getWasCached());
values.put("quality", result.getQuality());
values.put("errorMessage", result.getErrorMessage());
return values;
}
private static MxValue parseValue(String type, String text) {
return switch (type) {
case "bool" -> MxValues.boolValue(Boolean.parseBoolean(text));