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:
@@ -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)?),
|
||||
|
||||
Reference in New Issue
Block a user