Adds a bench-read-bulk subcommand to every client CLI (.NET, Go, Rust,
Python, Java) and a PowerShell driver that runs all five concurrently
against the deployed gateway and prints a side-by-side comparison.
Each CLI''s bench:
- Opens its own session, registers, subscribes to bulk-size tags so the
worker''s MxAccessValueCache populates from real OnDataChange events.
- Runs a warmup-seconds-long pre-loop with identical calls so JIT /
connection-pool / first-call overhead is amortised before the
measurement window.
- Runs ReadBulk in a tight in-process loop for duration-seconds with
per-call high-resolution latency capture (Stopwatch in .NET,
time.Now in Go, std::time::Instant in Rust, time.perf_counter in
Python, System.nanoTime in Java).
- Unsubscribes + closes the session, then emits one JSON object with
the shared schema: { language, durationMs, totalCalls, successfulCalls,
failedCalls, totalReadResults, cachedReadResults, callsPerSecond,
latencyMs: { p50, p95, p99, max, mean } }.
The PS driver (scripts/bench-read-bulk.ps1) launches one detached process
per client, waits for all to finish, parses the trailing JSON object from
each stdout, prints a comparison table, and persists the combined report
under artifacts/bench/. Quoting around Java''s `gradle --args="..."` is
handled by writing a one-shot .bat that cmd.exe runs; the .NET CLI''s
per-call gRPC timeout is auto-scaled to (Duration + Warmup + 30s) so the
channel-wide timeout doesn''t cancel the bench mid-loop.
Live 30-second steady-state run against the deployed gateway, all five
clients hitting the same six TestMachine_001..006.TestChangingInt tags:
client calls/sec cached/total p50 ms p95 ms p99 ms max ms
dotnet 171.78 30924/30924 3.84 14.06 40.41 542.48
go 175.46 31590/31590 3.93 13.52 41.26 243.00
rust 123.26 22188/22188 5.52 15.78 48.11 544.41
python 145.79 26244/26244 4.86 14.85 41.65 645.84
java 181.12 32604/32604 3.80 10.59 33.37 344.27
143,550 ReadBulk results across all five clients during the 30s window;
100% were was_cached = true (the worker''s cache fast-path never fell
through to the snapshot lifecycle). Aggregate read throughput ~800
calls/sec against five concurrent sessions sharing the same cached tags.
A second variant with bulk-size 20 sustained the same per-client call
rate while delivering 3.3x more values per call (~37,000 cached reads/sec
aggregate across the five concurrent sessions), confirming the linear
per-tag cache lookup inside one call is not a bottleneck at this scale.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
.NET Client Projects
The .NET client workspace contains the MXAccess Gateway client library, test CLI, and unit tests.
Projects
| Project | Purpose |
|---|---|
MxGateway.Client |
.NET 10 library entry point, raw gRPC calls, and session helpers. |
MxGateway.Client.Cli |
Test CLI for smoke and diagnostic commands. |
MxGateway.Client.Tests |
Unit tests for client options, generated contract wiring, auth metadata, session helpers, cancellation, and event streaming. |
The projects reference src/MxGateway.Contracts/MxGateway.Contracts.csproj so
the client compiles against the same generated protobuf and gRPC types as the
gateway. clients/dotnet/generated remains reserved for generator output if a
future client build switches to client-local Grpc.Tools generation.
Build And Test
dotnet build clients/dotnet/MxGateway.Client.sln
dotnet test clients/dotnet/MxGateway.Client.sln --no-build
Packaging
Create local library and CLI artifacts from the repository root:
$dotnetPackageOutput = Join-Path (Get-Location) 'artifacts/clients/dotnet'
dotnet pack clients/dotnet/MxGateway.Client/MxGateway.Client.csproj -c Release -p:PackageOutputPath="$dotnetPackageOutput"
dotnet publish clients/dotnet/MxGateway.Client.Cli/MxGateway.Client.Cli.csproj -c Release -o artifacts/clients/dotnet/mxgw-dotnet
The library package references the shared contracts project at build time. The
published CLI runs from artifacts/clients/dotnet/mxgw-dotnet.
Regenerating Protobuf Bindings
The .NET client uses the generated C# types from
src/MxGateway.Contracts/Generated. Regenerate those files through the
contracts project:
dotnet build src/MxGateway.Contracts/MxGateway.Contracts.csproj
Client Usage
MxGatewayClient opens a gRPC channel to the gateway and attaches the API key
to every unary and streaming call as authorization: Bearer <api-key>.
Cancellation tokens passed to the public methods flow to the generated gRPC
call. Client-side cancellation stops waiting for the gateway response; it does
not abort an MXAccess COM call that is already executing inside a worker.
await using MxGatewayClient client = MxGatewayClient.Create(
new MxGatewayClientOptions
{
Endpoint = new Uri("http://localhost:5000"),
ApiKey = apiKey,
});
MxGatewaySession session = await client.OpenSessionAsync();
try
{
int serverHandle = await session.RegisterAsync("sample-client");
int itemHandle = await session.AddItemAsync(
serverHandle,
"Area001.Pump001.Speed");
await session.AdviseAsync(serverHandle, itemHandle);
}
finally
{
await session.CloseAsync();
}
Use OpenSessionRawAsync, CloseSessionRawAsync, InvokeAsync, and
StreamEventsAsync when tests or parity tools need direct generated protobuf
messages. MxGatewaySession.OpenSessionReply keeps the raw session-open reply
available, and command helpers have *RawAsync variants when callers need the
complete MxCommandReply.
Bulk Commands
The session exposes bulk variants for every command family that has one
upstream — they all carry a list of entries in one gRPC round-trip, the worker
runs the per-item MXAccess calls sequentially on its STA, and the reply
returns one result per requested entry. Per-entry failures populate
WasSuccessful = false with the underlying HRESULT and never throw; only
protocol-level failures throw via EnsureProtocolSuccess.
// Subscribe + Unsubscribe to a batch of tags in one round-trip
IReadOnlyList<SubscribeResult> subResults = await session.SubscribeBulkAsync(
serverHandle,
new[] { "Area001.Pump001.Speed", "Area001.Pump001.RunHours" });
int[] itemHandles = subResults.Where(r => r.WasSuccessful).Select(r => r.ItemHandle).ToArray();
await session.UnsubscribeBulkAsync(serverHandle, itemHandles);
// Bulk Write — sequential MXAccess Write per entry.
IReadOnlyList<BulkWriteResult> writeResults = await session.WriteBulkAsync(
serverHandle,
new[]
{
new WriteBulkEntry { ItemHandle = h1, UserId = 0, Value = 1.0.ToMxValue() },
new WriteBulkEntry { ItemHandle = h2, UserId = 0, Value = 2.0.ToMxValue() },
});
foreach (BulkWriteResult r in writeResults.Where(r => !r.WasSuccessful))
{
Console.Error.WriteLine($"item {r.ItemHandle}: {r.ErrorMessage}");
}
// Bulk Read — returns the cached OnDataChange value when the tag is already
// advised (was_cached = true) or takes a one-shot snapshot otherwise.
IReadOnlyList<BulkReadResult> readResults = await session.ReadBulkAsync(
serverHandle,
new[] { "Area001.Pump001.Speed", "Area001.Pump002.Speed" },
timeout: TimeSpan.FromMilliseconds(750));
Write2BulkAsync, WriteSecuredBulkAsync, and WriteSecured2BulkAsync follow
the same shape; the secured variants additionally carry CurrentUserId and
VerifierUserId per entry and require invoke:secure scope.
MxGatewaySession.CloseAsync is explicit and idempotent. Repeated calls return
the first CloseSessionReply instead of sending another close request.
Values, Status, And Errors
The client provides extension helpers for generated protobuf values. Use
ToMxValue() on .NET scalar values and typed arrays to create MxValue
instances for Write and Write2. Use ToClrValue() and
GetProjectionKind() when test or diagnostic code needs to inspect generated
MxValue replies while preserving rawDiagnostic, raw data type fields, and
raw byte payloads.
MxStatusProxy.IsSuccess() and ToDiagnosticSummary() expose MXAccess status
arrays without collapsing them into a single gateway success flag. Command
reply helpers follow the same split:
reply.EnsureProtocolSuccess();
reply.EnsureMxAccessSuccess();
EnsureProtocolSuccess() raises gateway, session, worker, or command
exceptions for gateway-level failures. It leaves
PROTOCOL_STATUS_CODE_MXACCESS_FAILURE to EnsureMxAccessSuccess() so callers
can keep the full MxCommandReply, HRESULT, and status array when MXAccess
itself rejects a command. MxAccessException.Reply contains the raw generated
reply.
When a gRPC call itself fails, the transport maps the underlying
RpcException to a native exception: Unauthenticated becomes
MxGatewayAuthenticationException, PermissionDenied becomes
MxGatewayAuthorizationException, a cancelled call becomes
OperationCanceledException, and every other status becomes a base
MxGatewayException. MxGatewayException.StatusCode carries the originating
gRPC Grpc.Core.StatusCode (non-null whenever the failure came from a gRPC
status), so callers can distinguish a transient outage (Unavailable) from a
permanent error (InvalidArgument, NotFound) without downcasting
InnerException.
CLI Usage
The test CLI supports deterministic JSON output for automation:
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- version --json
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- open-session --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- register --session-id <id> --client-name mxgw-dotnet-cli --json
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- add-item --session-id <id> --server-handle 1 --item Area001.Pump001.Speed --json
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- advise --session-id <id> --server-handle 1 --item-handle 1 --json
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- write --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --json
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- write2 --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --timestamp 2026-01-01T00:00:00Z --json
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- stream-events --session-id <id> --max-events 1 --json
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- smoke --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --item Area001.Pump001.Speed --json
smoke opens a session, registers a client, adds one item, advises it,
optionally writes a value when --type and --value are supplied, reads a
bounded event stream, and closes the session in a finally block. CLI error
output redacts the effective API key, whether it was supplied through
--api-key or resolved from the --api-key-env environment variable.
Galaxy Repository Browse
GalaxyRepositoryClient is a separate read-only wrapper around the
GalaxyRepository gRPC service exposed by the same gateway. It shares the API
key auth interceptor with MxGatewayClient and requires the metadata:read
scope server-side. Use it to probe the ZB SQL connection, watch
time_of_last_deploy for redeployments, and enumerate the deployed Galaxy
object hierarchy plus each object's dynamic attributes.
await using GalaxyRepositoryClient repository = GalaxyRepositoryClient.Create(
new MxGatewayClientOptions
{
Endpoint = new Uri("http://localhost:5000"),
ApiKey = apiKey,
});
bool ok = await repository.TestConnectionAsync();
DateTime? lastDeploy = await repository.GetLastDeployTimeAsync();
IReadOnlyList<GalaxyObject> objects = await repository.DiscoverHierarchyAsync();
foreach (GalaxyObject galaxyObject in objects)
{
Console.WriteLine($"{galaxyObject.TagName} ({galaxyObject.ContainedName})");
foreach (GalaxyAttribute attribute in galaxyObject.Attributes)
{
Console.WriteLine($" {attribute.AttributeName} -> {attribute.FullTagReference}");
}
}
Use DiscoverHierarchyOptions to request a server-side slice without pulling
the full Galaxy:
IReadOnlyList<GalaxyObject> pumps = await repository.DiscoverHierarchyAsync(
new DiscoverHierarchyOptions
{
RootContainedPath = "Area1/Line3",
TagNameGlob = "Pump_*",
IncludeAttributes = false,
});
The CLI exposes the same operations:
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-test-connection --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-last-deploy --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-discover --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY
Watching deploy events
WatchDeployEventsAsync opens the WatchDeployEvents server-streaming RPC. The
server emits a bootstrap event with the current state on subscribe, then one
event per new time_of_last_deploy. Pass a lastSeenDeployTime to suppress the
bootstrap when the caller already holds the current deploy time. Use the
monotonic Sequence field to detect dropped events: gaps mean the
per-subscriber server-side buffer overflowed and the caller should reconcile.
Streaming RPCs are not wrapped by the unary safe-read retry pipeline. The caller is responsible for reopening the stream on transient failures.
await using GalaxyRepositoryClient repository = GalaxyRepositoryClient.Create(options);
DateTimeOffset? lastSeen = null;
await foreach (DeployEvent evt in repository.WatchDeployEventsAsync(
lastSeen,
cancellationToken))
{
Console.WriteLine(
$"seq={evt.Sequence} objects={evt.ObjectCount} attributes={evt.AttributeCount}");
if (evt.TimeOfLastDeployPresent && evt.TimeOfLastDeploy is not null)
{
lastSeen = evt.TimeOfLastDeploy.ToDateTimeOffset();
}
}
The CLI counterpart streams events until Ctrl+C (or --max-events):
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --last-seen-deploy-time 2026-04-28T14:30:00Z --json
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --max-events 5 --json
Use TLS options for a secured gateway:
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- smoke --endpoint https://mxgateway.example.local:5001 --tls --ca-file C:\certs\mxgateway-ca.pem --server-name mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item Area001.Pump001.Speed --json
Integration Checks
Run live checks only when a gateway and MXAccess-backed worker are available:
$env:MXGATEWAY_INTEGRATION = '1'
$env:MXGATEWAY_ENDPOINT = 'http://localhost:5000'
$env:MXGATEWAY_API_KEY = '<gateway-api-key>'
$env:MXGATEWAY_TEST_ITEM = 'Area001.Pump001.Speed'
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- smoke --endpoint $env:MXGATEWAY_ENDPOINT --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json