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
+318 -1
View File
@@ -18,7 +18,8 @@ use futures_util::StreamExt;
use mxgateway_client::generated::galaxy_repository::v1::DeployEvent;
use mxgateway_client::generated::mxaccess_gateway::v1::{
CloseSessionRequest, MxCommand, MxCommandKind, MxCommandRequest, MxEvent, MxEventFamily,
MxValue as ProtoMxValue, OpenSessionRequest, PingCommand, StreamEventsRequest,
MxValue as ProtoMxValue, OpenSessionRequest, PingCommand, StreamEventsRequest, Write2BulkEntry,
WriteBulkEntry, WriteSecured2BulkEntry, WriteSecuredBulkEntry,
};
use mxgateway_client::{
ApiKey, ClientOptions, Error, GalaxyClient, GatewayClient, MxValue, MxValueProjection,
@@ -127,6 +128,109 @@ enum Command {
#[arg(long)]
json: bool,
},
/// Snapshot the current value for each requested tag. Cached
/// OnDataChange values are returned for tags that are already advised
/// without touching the existing subscription; otherwise the worker
/// takes a one-shot AddItem + Advise + UnAdvise + RemoveItem lifecycle.
ReadBulk {
#[command(flatten)]
connection: ConnectionArgs,
#[arg(long)]
session_id: String,
#[arg(long)]
server_handle: i32,
#[arg(long, value_delimiter = ',')]
items: Vec<String>,
/// Per-tag snapshot timeout in milliseconds. `0` uses the worker default (1000 ms).
#[arg(long, default_value_t = 0)]
timeout_ms: u32,
#[arg(long)]
json: bool,
},
/// Bulk Write — one MXAccess Write per (item_handle, value) pair.
WriteBulk {
#[command(flatten)]
connection: ConnectionArgs,
#[arg(long)]
session_id: String,
#[arg(long)]
server_handle: i32,
#[arg(long, value_delimiter = ',')]
item_handles: Vec<i32>,
#[arg(long, value_enum)]
value_type: CliValueType,
#[arg(long, value_delimiter = ',')]
values: Vec<String>,
#[arg(long, default_value_t = 0)]
user_id: i32,
#[arg(long)]
json: bool,
},
/// Bulk Write2 — timestamped variant; the timestamp applies to all entries.
Write2Bulk {
#[command(flatten)]
connection: ConnectionArgs,
#[arg(long)]
session_id: String,
#[arg(long)]
server_handle: i32,
#[arg(long, value_delimiter = ',')]
item_handles: Vec<i32>,
#[arg(long, value_enum)]
value_type: CliValueType,
#[arg(long, value_delimiter = ',')]
values: Vec<String>,
#[arg(long)]
timestamp: String,
#[arg(long, default_value_t = 0)]
user_id: i32,
#[arg(long)]
json: bool,
},
/// Bulk WriteSecured.
WriteSecuredBulk {
#[command(flatten)]
connection: ConnectionArgs,
#[arg(long)]
session_id: String,
#[arg(long)]
server_handle: i32,
#[arg(long, value_delimiter = ',')]
item_handles: Vec<i32>,
#[arg(long, value_enum)]
value_type: CliValueType,
#[arg(long, value_delimiter = ',')]
values: Vec<String>,
#[arg(long, default_value_t = 0)]
current_user_id: i32,
#[arg(long, default_value_t = 0)]
verifier_user_id: i32,
#[arg(long)]
json: bool,
},
/// Bulk WriteSecured2 — timestamped + verified.
WriteSecured2Bulk {
#[command(flatten)]
connection: ConnectionArgs,
#[arg(long)]
session_id: String,
#[arg(long)]
server_handle: i32,
#[arg(long, value_delimiter = ',')]
item_handles: Vec<i32>,
#[arg(long, value_enum)]
value_type: CliValueType,
#[arg(long, value_delimiter = ',')]
values: Vec<String>,
#[arg(long)]
timestamp: String,
#[arg(long, default_value_t = 0)]
current_user_id: i32,
#[arg(long, default_value_t = 0)]
verifier_user_id: i32,
#[arg(long)]
json: bool,
},
StreamEvents {
#[command(flatten)]
connection: ConnectionArgs,
@@ -429,6 +533,136 @@ async fn run(cli: Cli) -> Result<(), Error> {
.await?;
print_bulk_results("unsubscribe-bulk", &results, json);
}
Command::ReadBulk {
connection,
session_id,
server_handle,
items,
timeout_ms,
json,
} => {
let session = session_for(connection, session_id).await?;
let results = session.read_bulk(server_handle, items, timeout_ms).await?;
print_read_bulk_results("read-bulk", &results, json);
}
Command::WriteBulk {
connection,
session_id,
server_handle,
item_handles,
value_type,
values,
user_id,
json,
} => {
let entries = build_write_bulk_entries(&item_handles, value_type, &values)?;
let session = session_for(connection, session_id).await?;
let results = session
.write_bulk(
server_handle,
entries
.into_iter()
.map(|(item_handle, value)| WriteBulkEntry {
item_handle,
value: Some(value),
user_id,
})
.collect(),
)
.await?;
print_write_bulk_results("write-bulk", &results, json);
}
Command::Write2Bulk {
connection,
session_id,
server_handle,
item_handles,
value_type,
values,
timestamp,
user_id,
json,
} => {
let entries = build_write_bulk_entries(&item_handles, value_type, &values)?;
let timestamp_value: ProtoMxValue = MxValue::string(timestamp).into_proto();
let session = session_for(connection, session_id).await?;
let results = session
.write2_bulk(
server_handle,
entries
.into_iter()
.map(|(item_handle, value)| Write2BulkEntry {
item_handle,
value: Some(value),
timestamp_value: Some(timestamp_value.clone()),
user_id,
})
.collect(),
)
.await?;
print_write_bulk_results("write2-bulk", &results, json);
}
Command::WriteSecuredBulk {
connection,
session_id,
server_handle,
item_handles,
value_type,
values,
current_user_id,
verifier_user_id,
json,
} => {
let entries = build_write_bulk_entries(&item_handles, value_type, &values)?;
let session = session_for(connection, session_id).await?;
let results = session
.write_secured_bulk(
server_handle,
entries
.into_iter()
.map(|(item_handle, value)| WriteSecuredBulkEntry {
item_handle,
value: Some(value),
current_user_id,
verifier_user_id,
})
.collect(),
)
.await?;
print_write_bulk_results("write-secured-bulk", &results, json);
}
Command::WriteSecured2Bulk {
connection,
session_id,
server_handle,
item_handles,
value_type,
values,
timestamp,
current_user_id,
verifier_user_id,
json,
} => {
let entries = build_write_bulk_entries(&item_handles, value_type, &values)?;
let timestamp_value: ProtoMxValue = MxValue::string(timestamp).into_proto();
let session = session_for(connection, session_id).await?;
let results = session
.write_secured2_bulk(
server_handle,
entries
.into_iter()
.map(|(item_handle, value)| WriteSecured2BulkEntry {
item_handle,
value: Some(value),
timestamp_value: Some(timestamp_value.clone()),
current_user_id,
verifier_user_id,
})
.collect(),
)
.await?;
print_write_bulk_results("write-secured2-bulk", &results, json);
}
Command::StreamEvents {
connection,
session_id,
@@ -784,6 +1018,89 @@ fn print_bulk_results(
}
}
fn print_write_bulk_results(
operation: &str,
results: &[mxgateway_client::generated::mxaccess_gateway::v1::BulkWriteResult],
use_json: bool,
) {
if use_json {
let results_json: Vec<_> = results
.iter()
.map(|result| {
json!({
"serverHandle": result.server_handle,
"itemHandle": result.item_handle,
"wasSuccessful": result.was_successful,
"hresult": result.hresult,
"errorMessage": result.error_message,
})
})
.collect();
println!(
"{}",
json!({ "operation": operation, "results": results_json })
);
} else {
println!("{}", results.len());
}
}
fn print_read_bulk_results(
operation: &str,
results: &[mxgateway_client::generated::mxaccess_gateway::v1::BulkReadResult],
use_json: bool,
) {
if use_json {
let results_json: Vec<_> = results
.iter()
.map(|result| {
json!({
"serverHandle": result.server_handle,
"tagAddress": result.tag_address,
"itemHandle": result.item_handle,
"wasSuccessful": result.was_successful,
"wasCached": result.was_cached,
"quality": result.quality,
"errorMessage": result.error_message,
})
})
.collect();
println!(
"{}",
json!({ "operation": operation, "results": results_json })
);
} else {
println!("{}", results.len());
}
}
/// Pairs each parsed item handle with its parsed MxValue (proto form) so a
/// single helper can build the four bulk-write families without each branch
/// repeating the length check and per-value parsing.
fn build_write_bulk_entries(
item_handles: &[i32],
value_type: CliValueType,
values: &[String],
) -> Result<Vec<(i32, mxgateway_client::generated::mxaccess_gateway::v1::MxValue)>, Error> {
if item_handles.len() != values.len() {
return Err(Error::InvalidArgument {
name: "values".to_owned(),
detail: format!(
"item-handles count ({}) does not match values count ({})",
item_handles.len(),
values.len()
),
});
}
item_handles
.iter()
.zip(values.iter())
.map(|(handle, value)| {
parse_value(value_type, value).map(|wrapper| (*handle, wrapper.into_proto()))
})
.collect()
}
fn parse_value(value_type: CliValueType, value: &str) -> Result<MxValue, Error> {
let parsed = match value_type {
CliValueType::Bool => MxValue::bool(parse_cli_value(value)?),