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:
@@ -89,6 +89,16 @@ func runWithIO(ctx context.Context, args []string, stdout, stderr io.Writer) err
|
||||
return runSubscribeBulk(ctx, args[1:], stdout, stderr)
|
||||
case "unsubscribe-bulk":
|
||||
return runUnsubscribeBulk(ctx, args[1:], stdout, stderr)
|
||||
case "read-bulk":
|
||||
return runReadBulk(ctx, args[1:], stdout, stderr)
|
||||
case "write-bulk":
|
||||
return runWriteBulk(ctx, args[1:], stdout, stderr)
|
||||
case "write2-bulk":
|
||||
return runWrite2Bulk(ctx, args[1:], stdout, stderr)
|
||||
case "write-secured-bulk":
|
||||
return runWriteSecuredBulk(ctx, args[1:], stdout, stderr)
|
||||
case "write-secured2-bulk":
|
||||
return runWriteSecured2Bulk(ctx, args[1:], stdout, stderr)
|
||||
case "write":
|
||||
return runWrite(ctx, args[1:], stdout, stderr)
|
||||
case "stream-events":
|
||||
@@ -347,6 +357,167 @@ func runUnsubscribeBulk(ctx context.Context, args []string, stdout, stderr io.Wr
|
||||
return writeBulkOutput(stdout, *jsonOutput, "unsubscribe-bulk", options, results, err)
|
||||
}
|
||||
|
||||
func runReadBulk(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||
flags := flag.NewFlagSet("read-bulk", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
common := bindCommonFlags(flags)
|
||||
jsonOutput := flags.Bool("json", false, "write JSON output")
|
||||
sessionID := flags.String("session-id", "", "gateway session id")
|
||||
serverHandle := flags.Int("server-handle", 0, "MXAccess server handle")
|
||||
items := flags.String("items", "", "comma-separated tag addresses")
|
||||
timeoutMs := flags.Int("timeout-ms", 0, "per-tag snapshot timeout in milliseconds (0 = worker default)")
|
||||
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if *sessionID == "" || *items == "" {
|
||||
return errors.New("session-id and items are required")
|
||||
}
|
||||
|
||||
client, options, err := dialForCommand(ctx, common)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
session := mxgateway.NewSessionForID(client, *sessionID)
|
||||
results, err := session.ReadBulk(ctx, int32(*serverHandle), parseStringList(*items), time.Duration(*timeoutMs)*time.Millisecond)
|
||||
return writeReadBulkOutput(stdout, *jsonOutput, "read-bulk", options, results, err)
|
||||
}
|
||||
|
||||
func runWriteBulk(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||
return runWriteBulkVariant(ctx, args, stdout, stderr, "write-bulk", false, false)
|
||||
}
|
||||
|
||||
func runWrite2Bulk(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||
return runWriteBulkVariant(ctx, args, stdout, stderr, "write2-bulk", true, false)
|
||||
}
|
||||
|
||||
func runWriteSecuredBulk(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||
return runWriteBulkVariant(ctx, args, stdout, stderr, "write-secured-bulk", false, true)
|
||||
}
|
||||
|
||||
func runWriteSecured2Bulk(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||
return runWriteBulkVariant(ctx, args, stdout, stderr, "write-secured2-bulk", true, true)
|
||||
}
|
||||
|
||||
// runWriteBulkVariant shares the flag-parsing + entry-build skeleton across
|
||||
// the four bulk-write families. withTimestamp adds a --timestamp-value flag;
|
||||
// secured switches from --user-id to --current-user-id / --verifier-user-id.
|
||||
func runWriteBulkVariant(ctx context.Context, args []string, stdout, stderr io.Writer, command string, withTimestamp bool, secured bool) error {
|
||||
flags := flag.NewFlagSet(command, flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
common := bindCommonFlags(flags)
|
||||
jsonOutput := flags.Bool("json", false, "write JSON output")
|
||||
sessionID := flags.String("session-id", "", "gateway session id")
|
||||
serverHandle := flags.Int("server-handle", 0, "MXAccess server handle")
|
||||
itemHandles := flags.String("item-handles", "", "comma-separated item handles")
|
||||
valueType := flags.String("type", "string", "value type: bool, int32, int64, float, double, string")
|
||||
values := flags.String("values", "", "comma-separated values (one per item handle)")
|
||||
userID := flags.Int("user-id", 0, "MXAccess user id (Write/Write2 variants)")
|
||||
currentUserID := flags.Int("current-user-id", 0, "MXAccess current user id (Secured variants)")
|
||||
verifierUserID := flags.Int("verifier-user-id", 0, "MXAccess verifier user id (Secured variants)")
|
||||
timestampValue := flags.String("timestamp-value", "", "RFC 3339 timestamp shared across all entries (Write2/WriteSecured2 variants)")
|
||||
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if *sessionID == "" || *itemHandles == "" || *values == "" {
|
||||
return errors.New("session-id, item-handles, and values are required")
|
||||
}
|
||||
|
||||
handles, err := parseInt32List(*itemHandles)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
valueTexts := parseStringList(*values)
|
||||
if len(handles) != len(valueTexts) {
|
||||
return fmt.Errorf("item-handles count (%d) does not match values count (%d)", len(handles), len(valueTexts))
|
||||
}
|
||||
|
||||
parsedValues := make([]*mxgateway.MxValue, len(handles))
|
||||
for i, text := range valueTexts {
|
||||
v, err := parseValue(*valueType, text)
|
||||
if err != nil {
|
||||
return fmt.Errorf("entry %d: %w", i, err)
|
||||
}
|
||||
parsedValues[i] = v
|
||||
}
|
||||
|
||||
var tsValue *mxgateway.MxValue
|
||||
if withTimestamp {
|
||||
if *timestampValue == "" {
|
||||
return errors.New("timestamp-value is required for write2/write-secured2 bulk variants")
|
||||
}
|
||||
parsed, err := parseRfc3339Timestamp(*timestampValue)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tsValue = parsed
|
||||
}
|
||||
|
||||
client, options, err := dialForCommand(ctx, common)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
session := mxgateway.NewSessionForID(client, *sessionID)
|
||||
|
||||
var results []*mxgateway.BulkWriteResult
|
||||
switch command {
|
||||
case "write-bulk":
|
||||
entries := make([]*mxgateway.WriteBulkEntry, len(handles))
|
||||
for i := range handles {
|
||||
entries[i] = &mxgateway.WriteBulkEntry{ItemHandle: handles[i], Value: parsedValues[i], UserId: int32(*userID)}
|
||||
}
|
||||
results, err = session.WriteBulk(ctx, int32(*serverHandle), entries)
|
||||
case "write2-bulk":
|
||||
entries := make([]*mxgateway.Write2BulkEntry, len(handles))
|
||||
for i := range handles {
|
||||
entries[i] = &mxgateway.Write2BulkEntry{ItemHandle: handles[i], Value: parsedValues[i], TimestampValue: tsValue, UserId: int32(*userID)}
|
||||
}
|
||||
results, err = session.Write2Bulk(ctx, int32(*serverHandle), entries)
|
||||
case "write-secured-bulk":
|
||||
entries := make([]*mxgateway.WriteSecuredBulkEntry, len(handles))
|
||||
for i := range handles {
|
||||
entries[i] = &mxgateway.WriteSecuredBulkEntry{
|
||||
ItemHandle: handles[i],
|
||||
Value: parsedValues[i],
|
||||
CurrentUserId: int32(*currentUserID),
|
||||
VerifierUserId: int32(*verifierUserID),
|
||||
}
|
||||
}
|
||||
results, err = session.WriteSecuredBulk(ctx, int32(*serverHandle), entries)
|
||||
case "write-secured2-bulk":
|
||||
entries := make([]*mxgateway.WriteSecured2BulkEntry, len(handles))
|
||||
for i := range handles {
|
||||
entries[i] = &mxgateway.WriteSecured2BulkEntry{
|
||||
ItemHandle: handles[i],
|
||||
Value: parsedValues[i],
|
||||
TimestampValue: tsValue,
|
||||
CurrentUserId: int32(*currentUserID),
|
||||
VerifierUserId: int32(*verifierUserID),
|
||||
}
|
||||
}
|
||||
results, err = session.WriteSecured2Bulk(ctx, int32(*serverHandle), entries)
|
||||
default:
|
||||
return fmt.Errorf("unsupported bulk write command %q", command)
|
||||
}
|
||||
_ = secured // currently only used for routing above; reserved for future per-variant validation
|
||||
return writeWriteBulkOutput(stdout, *jsonOutput, command, options, results, err)
|
||||
}
|
||||
|
||||
// parseRfc3339Timestamp parses an RFC 3339 timestamp and returns the
|
||||
// MxValue protobuf representation used for the timestamped write families.
|
||||
func parseRfc3339Timestamp(text string) (*mxgateway.MxValue, error) {
|
||||
t, err := time.Parse(time.RFC3339Nano, text)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid RFC 3339 timestamp %q: %w", text, err)
|
||||
}
|
||||
return mxgateway.TimestampValue(t), nil
|
||||
}
|
||||
|
||||
func runWrite(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||
flags := flag.NewFlagSet("write", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
@@ -652,6 +823,36 @@ func writeBulkOutput(stdout io.Writer, jsonOutput bool, command string, options
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeWriteBulkOutput(stdout io.Writer, jsonOutput bool, command string, options commonOptions, results []*mxgateway.BulkWriteResult, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if jsonOutput {
|
||||
return writeJSON(stdout, map[string]any{
|
||||
"command": command,
|
||||
"options": options,
|
||||
"results": results,
|
||||
})
|
||||
}
|
||||
fmt.Fprintln(stdout, len(results))
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeReadBulkOutput(stdout io.Writer, jsonOutput bool, command string, options commonOptions, results []*mxgateway.BulkReadResult, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if jsonOutput {
|
||||
return writeJSON(stdout, map[string]any{
|
||||
"command": command,
|
||||
"options": options,
|
||||
"results": results,
|
||||
})
|
||||
}
|
||||
fmt.Fprintln(stdout, len(results))
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeJSON(writer io.Writer, value any) error {
|
||||
encoder := json.NewEncoder(writer)
|
||||
encoder.SetIndent("", " ")
|
||||
|
||||
Reference in New Issue
Block a user