Compare commits
131 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dd7ca1634e | |||
| bdccdbf6dd | |||
| fa491c752b | |||
| aba228f443 | |||
| 5e493484f1 | |||
| 3e22285f09 | |||
| 120cd0b1b6 | |||
| 56949c967b | |||
| 7dec9b30f5 | |||
| 1d3c8edb44 | |||
| 58259016b0 | |||
| 864b9f4bd3 | |||
| de58872435 | |||
| 6777d49030 | |||
| 1b6ca07bb5 | |||
| 1ad0be8276 | |||
| 9328c4f657 | |||
| 0361dc1817 | |||
| ac12c150c3 | |||
| 40ca4b6908 | |||
| bf73985481 | |||
| 0a54fa5e35 | |||
| cec84bf572 | |||
| 099d4783b0 | |||
| c1fe7fbc4a | |||
| b39848b5f5 | |||
| 6126099cdb | |||
| c1ff8c94e8 | |||
| b794c46bc7 | |||
| 84d36b7638 | |||
| 1aafd6bde4 | |||
| a0203503a7 | |||
| 1cd51bbda3 | |||
| 61644e63fb | |||
| 7db4bffa30 | |||
| 93633ce99c | |||
| eaa7093cd6 | |||
| f220908f3f | |||
| 5e375f6d3d | |||
| 758aca2355 | |||
| 06030dd1ef | |||
| e355a7674b | |||
| cd92048f4e | |||
| 964b40dcbc | |||
| bb5603b7ec | |||
| 24de7e21d9 | |||
| ee959e46e6 | |||
| 771229b39f | |||
| a7bf1ef95d | |||
| b4f5e8eb48 | |||
| 371bcb3f91 | |||
| 9582de077b | |||
| bd3096533d | |||
| 6eb9ea9105 | |||
| 555fe4c0ba | |||
| 89043cb2b6 | |||
| 1764eff1cf | |||
| fe9044115b | |||
| a02faa6ade | |||
| 1f546c46ee | |||
| 6a4833bd32 | |||
| e4fbbb541a | |||
| f13f35bc79 | |||
| 18ce2922e2 | |||
| 5ade3f4f48 | |||
| 98f9b7792b | |||
| ff41556b9a | |||
| f88a029ecc | |||
| 8023eccfa6 | |||
| 54325343bd | |||
| 1d9e3afadd | |||
| 5e795aeeb8 | |||
| 1b4dcf32d5 | |||
| 53e3973209 | |||
| e967e85973 | |||
| bc55396334 | |||
| b381bfcaf1 | |||
| 2a635c8522 | |||
| 9082e504a9 | |||
| 0d8a28d2fe | |||
| f0a4af62b9 | |||
| a8aafdf974 | |||
| 3cc53a8c69 | |||
| ae164ea34f | |||
| 6c640306e5 | |||
| a67a5a4857 | |||
| e00ee61cf0 | |||
| 271bf7edff | |||
| 3397e99783 | |||
| f598b3a647 | |||
| 509b0118d4 | |||
| 298836d2f3 | |||
| 96bea1d478 | |||
| a4ed605f74 | |||
| 4e02927f01 | |||
| 47b1fd422c | |||
| 9b21ca3554 | |||
| 01f5e6ad91 | |||
| 82eb0ad569 | |||
| f711a55be4 | |||
| f490ae2593 | |||
| 39f9fd8946 | |||
| bb7be14d1d | |||
| 8ac6642bf8 | |||
| 4e8928cf71 | |||
| f4423dfb6d | |||
| 3ff4969224 | |||
| 12881ca791 | |||
| 6e356da092 | |||
| a739fadb5f | |||
| 6b3c117d1e | |||
| c7d5b83390 | |||
| 1ac5bcafb2 | |||
| e7c2c546b5 | |||
| a14098468b | |||
| e030661c1b | |||
| 4e933802a7 | |||
| 6c3edf4516 | |||
| 9de2c0c43d | |||
| bc61598b44 | |||
| 335c952f00 | |||
| 3256733d24 | |||
| 4f0f03fca5 | |||
| 9ca200f814 | |||
| fe19c478c0 | |||
| d0bc78cd43 | |||
| 730fdc93e0 | |||
| 55470e3e09 | |||
| b4016e738c | |||
| 10004879f6 | |||
| 168bb9a39a |
@@ -32,7 +32,7 @@ dotnet test src/MxGateway.Worker.Tests/MxGateway.Worker.Tests.csproj -p:Platform
|
||||
dotnet run --project src/MxGateway.Server/MxGateway.Server.csproj
|
||||
|
||||
# API-key admin CLI (same exe, "apikey" subcommand)
|
||||
dotnet run --project src/MxGateway.Server/MxGateway.Server.csproj -- apikey create --display-name "dev" --scopes session,invoke,event,metadata,admin
|
||||
dotnet run --project src/MxGateway.Server/MxGateway.Server.csproj -- apikey create --display-name "dev" --scopes session:open,session:close,invoke:read,invoke:write,invoke:secure,events:read,metadata:read,admin
|
||||
```
|
||||
|
||||
Single test by name (xUnit `--filter`):
|
||||
@@ -114,7 +114,7 @@ External analysis sources referenced by design docs:
|
||||
|
||||
## Authentication
|
||||
|
||||
Gateway gRPC clients authenticate with an API key in metadata: `authorization: Bearer mxgw_<key-id>_<secret>`. Keys are stored hashed (with a peppered SHA) in a gateway-owned SQLite DB (default `C:\ProgramData\MxGateway\gateway-auth.db`). Scopes (`session`, `invoke`, `event`, `metadata`, `admin`) gate specific RPCs; missing → `Unauthenticated`, insufficient → `PermissionDenied`. The `apikey` subcommand on the server exe manages keys; see `src/MxGateway.Server/Security/Authentication/`.
|
||||
Gateway gRPC clients authenticate with an API key in metadata: `authorization: Bearer mxgw_<key-id>_<secret>`. Keys are stored hashed (with a peppered SHA) in a gateway-owned SQLite DB (default `C:\ProgramData\MxGateway\gateway-auth.db`). Scopes (`session:open`, `session:close`, `invoke:read`, `invoke:write`, `invoke:secure`, `events:read`, `metadata:read`, `admin`) gate specific RPCs; missing → `Unauthenticated`, insufficient → `PermissionDenied`. The `apikey` subcommand on the server exe manages keys; see `src/MxGateway.Server/Security/Authentication/`.
|
||||
|
||||
Dashboard auth uses the same verifier but exchanges the API key for an HTTP-only secure cookie at `/dashboard/login`. `Dashboard:AllowAnonymousLocalhost` bypasses cookie auth on loopback when explicitly enabled.
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
# Code Review Process
|
||||
|
||||
This document describes how to perform a comprehensive, per-module code review of
|
||||
the `mxaccessgw` codebase and how to track findings to resolution.
|
||||
|
||||
A **module** is one buildable project under `src/` (e.g. `src/MxGateway.Worker`)
|
||||
or one language client under `clients/` (e.g. `clients/rust`). Each module has
|
||||
its own folder under `code-reviews/` containing a single `findings.md`.
|
||||
|
||||
## 1. Before you start
|
||||
|
||||
1. Pick the module to review. Its folder is `code-reviews/<Module>/`:
|
||||
- For a `src/` project, `<Module>` is the project name with the `MxGateway.`
|
||||
prefix stripped — `src/MxGateway.Server` is reviewed in `code-reviews/Server/`.
|
||||
- For a language client, `<Module>` is `Client.<Lang>` — `clients/rust` is
|
||||
reviewed in `code-reviews/Client.Rust/`.
|
||||
2. Identify the design context for the module:
|
||||
- `gateway.md` — top-level architecture, command/event surface, IPC envelope,
|
||||
STA thread model, fault handling.
|
||||
- The relevant component design docs under `docs/` (e.g.
|
||||
`docs/MxAccessWorkerInstanceDesign.md`, `docs/GatewayProcessDesign.md`,
|
||||
`docs/Sessions.md`, `docs/Authentication.md`, `docs/GalaxyRepository.md`).
|
||||
- `docs/DesignDecisions.md` for the v1 design choices.
|
||||
- The **Repository-Specific Conventions** and **Process / Platform Notes** in
|
||||
`CLAUDE.md`.
|
||||
3. Record the exact commit being reviewed: `git rev-parse --short HEAD`. Every
|
||||
review is a snapshot — a finding only means something relative to a known
|
||||
commit.
|
||||
4. Open `code-reviews/<Module>/findings.md` and fill in the header table
|
||||
(reviewer, date, commit SHA, status).
|
||||
|
||||
## 2. Review checklist
|
||||
|
||||
Work through **every** category below for the module. A comprehensive review
|
||||
means the checklist is completed even where it produces no findings — record
|
||||
"No issues found" for a category rather than leaving it ambiguous.
|
||||
|
||||
1. **Correctness & logic bugs** — off-by-one, null handling, incorrect
|
||||
conditionals, misuse of APIs, broken edge cases.
|
||||
2. **mxaccessgw conventions** — the rules in `CLAUDE.md` and the style guides
|
||||
under `docs/style-guides/`: the gateway never instantiates MXAccess COM
|
||||
directly; all MXAccess COM calls run on the worker's dedicated STA thread and
|
||||
the STA loop pumps Windows messages; IPC uses one bidirectional named pipe per
|
||||
worker carrying length-prefixed `WorkerEnvelope` protobuf frames; MXAccess
|
||||
parity is the contract (don't "fix" surprising MXAccess behaviour, never
|
||||
synthesize events); one worker and one event subscriber per session; the
|
||||
gateway terminates orphan workers on startup and does not reattach; C# style
|
||||
(file-scoped namespaces, `sealed` by default, `Async` suffix, MXAccess-aligned
|
||||
names); no Blazor UI component libraries; no logging of secrets or full tag
|
||||
values; generated code is never hand-edited.
|
||||
3. **Concurrency & thread safety** — shared mutable state, STA affinity, race
|
||||
conditions, correct use of `async`/`await`, locking, disposal races.
|
||||
4. **Error handling & resilience** — exception paths, worker crash / reconnect
|
||||
handling, fail-fast event backpressure, transient vs permanent error
|
||||
classification, graceful degradation, correct gRPC status codes.
|
||||
5. **Security** — authentication/authorization checks, API-key scope enforcement,
|
||||
input validation, SQL injection in the Galaxy Repository RPCs, secret
|
||||
handling, the dashboard anonymous-localhost bypass, logging of sensitive data.
|
||||
6. **Performance & resource management** — `IDisposable` disposal, pipe / stream
|
||||
/ COM lifetimes, buffering and back-pressure, unnecessary allocations on hot
|
||||
paths, N+1 queries.
|
||||
7. **Design-document adherence** — does the code match `gateway.md`, the relevant
|
||||
`docs/` component designs, `docs/DesignDecisions.md`, and `CLAUDE.md`? Flag
|
||||
both code that drifts from the design and design docs that are now stale.
|
||||
8. **Code organization & conventions** — namespace hierarchy, project layout, the
|
||||
Options pattern, separation of concerns, additive-only contract evolution.
|
||||
9. **Testing coverage** — are the module's behaviours covered by tests
|
||||
(`src/MxGateway.Tests`, `src/MxGateway.Worker.Tests`,
|
||||
`src/MxGateway.IntegrationTests`)? Note untested critical paths and missing
|
||||
edge-case tests.
|
||||
10. **Documentation & comments** — XML doc accuracy, misleading or stale comments,
|
||||
undocumented non-obvious behaviour.
|
||||
|
||||
## 3. Recording findings
|
||||
|
||||
Add one entry per finding to the `## Findings` section of the module's
|
||||
`findings.md`, using the entry format in
|
||||
[`_template/findings.md`](code-reviews/_template/findings.md).
|
||||
|
||||
- **Finding ID** — `<Module>-NNN`, numbered sequentially within the module and
|
||||
never reused (e.g. `Worker-001`). IDs are permanent even after resolution.
|
||||
- **Severity:**
|
||||
- **Critical** — data loss, security breach, crash/deadlock, or outage.
|
||||
- **High** — incorrect behaviour with significant impact; no safe workaround.
|
||||
- **Medium** — incorrect or risky behaviour with limited impact or a workaround.
|
||||
- **Low** — minor issues, style, maintainability, documentation.
|
||||
- **Category** — one of the 10 checklist categories above.
|
||||
- **Location** — `file:line` (clickable), or a list of locations.
|
||||
- **Description** — what is wrong and why it matters.
|
||||
- **Recommendation** — concrete suggested fix.
|
||||
|
||||
After recording findings, update the module header table (status, open-finding
|
||||
count) and regenerate the base README (step 5).
|
||||
|
||||
## 4. Marking an item resolved
|
||||
|
||||
Findings are **never deleted** — they are an audit trail. To close one, change
|
||||
its **Status** and complete the **Resolution** field:
|
||||
|
||||
- `Open` — newly recorded, not yet addressed.
|
||||
- `In Progress` — a fix is actively being worked on.
|
||||
- `Resolved` — fixed. The Resolution field must state the fixing commit SHA, the
|
||||
date, and a one-line description of the fix.
|
||||
- `Won't Fix` — intentionally not fixed. The Resolution field must justify why.
|
||||
- `Deferred` — valid but postponed. The Resolution field must say what it is
|
||||
waiting on (e.g. a tracked issue or a later milestone).
|
||||
|
||||
`Resolved`, `Won't Fix`, and `Deferred` findings are all considered **closed**.
|
||||
`Open` and `In Progress` are **pending** and appear in the base README's Pending
|
||||
Findings table.
|
||||
|
||||
## 5. Updating the base README
|
||||
|
||||
`code-reviews/README.md` holds the single cross-module view (the Module Status
|
||||
table and the Pending / Closed Findings tables). It is **generated** from the
|
||||
per-module `findings.md` files — do not edit it by hand.
|
||||
|
||||
After any review or status change, regenerate it:
|
||||
|
||||
```
|
||||
python code-reviews/regen-readme.py
|
||||
```
|
||||
|
||||
`regen-readme.py --check` exits non-zero if `README.md` is stale, if a module
|
||||
header's `Open findings` count disagrees with its finding statuses, or if a
|
||||
finding carries an unrecognised Status value. The PowerShell wrapper
|
||||
`scripts/check-code-reviews-readme.ps1` runs that check and is the intended hook
|
||||
for CI or a pre-commit step.
|
||||
|
||||
> The repo's installed `python` is the real interpreter; the bare `python3`
|
||||
> alias resolves to the Windows Store stub and fails. Use `python`.
|
||||
|
||||
The per-module `findings.md` files are the source of truth; `README.md` is the
|
||||
aggregated index and must always agree with them — which the script guarantees.
|
||||
|
||||
## 6. Re-reviewing a module
|
||||
|
||||
Re-reviews append to the same `findings.md`. Update the header to the new commit
|
||||
and date, continue the finding numbering from the last used ID, and leave prior
|
||||
findings (including closed ones) in place as history.
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project>
|
||||
<!--
|
||||
Mirrors src/Directory.Build.props for the .NET client projects under
|
||||
clients/dotnet/ so they share the same enforcement floor (warnings-as-
|
||||
errors, latest analyzers, code-style enforcement, deterministic builds)
|
||||
even though they live outside src/.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<AnalysisLevel>latest</AnalysisLevel>
|
||||
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||
<Deterministic>true</Deterministic>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -3,6 +3,12 @@ using MxGateway.Contracts.Proto.Galaxy;
|
||||
|
||||
namespace MxGateway.Client.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal transport surface the CLI talks to. Exposes only the gateway and
|
||||
/// Galaxy Repository RPCs the CLI needs so tests can substitute an in-process
|
||||
/// fake without standing up a real gRPC channel. The production binding is a
|
||||
/// thin adapter over <see cref="MxGatewayClient"/> and <see cref="GalaxyRepositoryClient"/>.
|
||||
/// </summary>
|
||||
public interface IMxGatewayCliClient : IAsyncDisposable
|
||||
{
|
||||
/// <summary>
|
||||
@@ -45,6 +51,27 @@ public interface IMxGatewayCliClient : IAsyncDisposable
|
||||
StreamEventsRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledges an active MXAccess alarm condition through the gateway.
|
||||
/// </summary>
|
||||
/// <param name="request">The acknowledge request — alarm reference, comment, operator user.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>The acknowledge reply with protocol + native MxStatus.</returns>
|
||||
Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
|
||||
AcknowledgeAlarmRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Attaches to the gateway's central alarm feed — the current active-alarm
|
||||
/// snapshot followed by live transitions.
|
||||
/// </summary>
|
||||
/// <param name="request">The stream request, optionally scoped by alarm-reference prefix.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>An async enumerable of alarm feed messages.</returns>
|
||||
IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
||||
StreamAlarmsRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Tests connection to the Galaxy Repository.
|
||||
/// </summary>
|
||||
|
||||
@@ -52,6 +52,22 @@ internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient
|
||||
return _client.StreamEventsAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
|
||||
AcknowledgeAlarmRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return _client.AcknowledgeAlarmAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
||||
StreamAlarmsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return _client.StreamAlarmsAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TestConnectionReply> GalaxyTestConnectionAsync(
|
||||
TestConnectionRequest request,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -47,9 +47,9 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
||||
public List<(AcknowledgeAlarmRequest Request, CallOptions CallOptions)> AcknowledgeAlarmCalls { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of captured QueryActiveAlarmsAsync calls.
|
||||
/// Gets the list of captured StreamAlarmsAsync calls.
|
||||
/// </summary>
|
||||
public List<(QueryActiveAlarmsRequest Request, CallOptions CallOptions)> QueryActiveAlarmsCalls { get; } = [];
|
||||
public List<(StreamAlarmsRequest Request, CallOptions CallOptions)> StreamAlarmsCalls { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the queue of exceptions to throw from AcknowledgeAlarmAsync.
|
||||
@@ -91,6 +91,19 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
||||
/// </summary>
|
||||
public Queue<Exception> CloseSessionExceptions { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether thrown <see cref="RpcException"/>s are mapped
|
||||
/// to <see cref="MxGatewayException"/> the way the production gRPC transport does. Lets
|
||||
/// retry tests exercise the wrapped-exception predicate branch that runs in production.
|
||||
/// </summary>
|
||||
public bool MapTransportExceptions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional hook awaited inside CloseSessionAsync after the call is
|
||||
/// recorded; lets tests pause a close mid-flight to observe concurrent dispose.
|
||||
/// </summary>
|
||||
public Func<Task>? CloseSessionHook { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the queue of exceptions to throw from InvokeAsync.
|
||||
/// </summary>
|
||||
@@ -108,7 +121,7 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
||||
OpenSessionCalls.Add((request, callOptions));
|
||||
if (OpenSessionExceptions.TryDequeue(out Exception? exception))
|
||||
{
|
||||
throw exception;
|
||||
throw Translate(exception, callOptions);
|
||||
}
|
||||
|
||||
return Task.FromResult(OpenSessionReply);
|
||||
@@ -119,17 +132,23 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
||||
/// </summary>
|
||||
/// <param name="request">The CloseSessionRequest to process.</param>
|
||||
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
||||
public Task<CloseSessionReply> CloseSessionAsync(
|
||||
public async Task<CloseSessionReply> CloseSessionAsync(
|
||||
CloseSessionRequest request,
|
||||
CallOptions callOptions)
|
||||
{
|
||||
CloseSessionCalls.Add((request, callOptions));
|
||||
if (CloseSessionExceptions.TryDequeue(out Exception? exception))
|
||||
|
||||
if (CloseSessionHook is not null)
|
||||
{
|
||||
throw exception;
|
||||
await CloseSessionHook().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return Task.FromResult(CloseSessionReply);
|
||||
if (CloseSessionExceptions.TryDequeue(out Exception? exception))
|
||||
{
|
||||
throw Translate(exception, callOptions);
|
||||
}
|
||||
|
||||
return CloseSessionReply;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -144,7 +163,7 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
||||
InvokeCalls.Add((request, callOptions));
|
||||
if (InvokeExceptions.TryDequeue(out Exception? exception))
|
||||
{
|
||||
throw exception;
|
||||
throw Translate(exception, callOptions);
|
||||
}
|
||||
|
||||
return Task.FromResult(_invokeReplies.Dequeue());
|
||||
@@ -197,14 +216,13 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
||||
AcknowledgeAlarmCalls.Add((request, callOptions));
|
||||
if (AcknowledgeAlarmExceptions.TryDequeue(out Exception? exception))
|
||||
{
|
||||
throw exception;
|
||||
throw Translate(exception, callOptions);
|
||||
}
|
||||
|
||||
return Task.FromResult(_acknowledgeReplies.Count > 0
|
||||
? _acknowledgeReplies.Dequeue()
|
||||
: new AcknowledgeAlarmReply
|
||||
{
|
||||
SessionId = request.SessionId,
|
||||
CorrelationId = request.ClientCorrelationId,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
Status = new MxStatusProxy { Success = 1, Category = MxStatusCategory.Ok },
|
||||
@@ -212,20 +230,23 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records the query call and yields each enqueued snapshot.
|
||||
/// Records the call and yields each enqueued snapshot as an active-alarm
|
||||
/// feed message, then a snapshot-complete sentinel.
|
||||
/// </summary>
|
||||
public async IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
|
||||
QueryActiveAlarmsRequest request,
|
||||
public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
||||
StreamAlarmsRequest request,
|
||||
CallOptions callOptions)
|
||||
{
|
||||
QueryActiveAlarmsCalls.Add((request, callOptions));
|
||||
StreamAlarmsCalls.Add((request, callOptions));
|
||||
|
||||
foreach (ActiveAlarmSnapshot snapshot in _activeAlarmSnapshots)
|
||||
{
|
||||
callOptions.CancellationToken.ThrowIfCancellationRequested();
|
||||
await Task.Yield();
|
||||
yield return snapshot;
|
||||
yield return new AlarmFeedMessage { ActiveAlarm = snapshot };
|
||||
}
|
||||
|
||||
yield return new AlarmFeedMessage { SnapshotComplete = true };
|
||||
}
|
||||
|
||||
/// <summary>Enqueues an acknowledge reply.</summary>
|
||||
@@ -234,9 +255,23 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
||||
_acknowledgeReplies.Enqueue(reply);
|
||||
}
|
||||
|
||||
/// <summary>Enqueues a snapshot to be yielded from QueryActiveAlarmsAsync.</summary>
|
||||
/// <summary>Enqueues a snapshot yielded from StreamAlarmsAsync as an active-alarm message.</summary>
|
||||
public void AddActiveAlarmSnapshot(ActiveAlarmSnapshot snapshot)
|
||||
{
|
||||
_activeAlarmSnapshots.Add(snapshot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a queued exception the way the production gRPC transport does when
|
||||
/// <see cref="MapTransportExceptions"/> is set; otherwise returns it unchanged.
|
||||
/// </summary>
|
||||
private Exception Translate(Exception exception, CallOptions callOptions)
|
||||
{
|
||||
if (MapTransportExceptions && exception is RpcException rpcException)
|
||||
{
|
||||
return RpcExceptionMapper.Map(rpcException, callOptions.CancellationToken);
|
||||
}
|
||||
|
||||
return exception;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ using MxGateway.Contracts.Proto;
|
||||
namespace MxGateway.Client.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// PR E.2 — pins the .NET SDK surface for the new alarm RPCs:
|
||||
/// Pins the .NET SDK surface for the alarm RPCs:
|
||||
/// <see cref="MxGatewayClient.AcknowledgeAlarmAsync"/> and
|
||||
/// <see cref="MxGatewayClient.QueryActiveAlarmsAsync"/>.
|
||||
/// <see cref="MxGatewayClient.StreamAlarmsAsync"/>.
|
||||
/// </summary>
|
||||
public sealed class MxGatewayClientAlarmsTests
|
||||
{
|
||||
@@ -17,7 +17,6 @@ public sealed class MxGatewayClientAlarmsTests
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
transport.AddAcknowledgeReply(new AcknowledgeAlarmReply
|
||||
{
|
||||
SessionId = "session-fixture",
|
||||
CorrelationId = "corr-1",
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
Status = new MxStatusProxy
|
||||
@@ -31,7 +30,6 @@ public sealed class MxGatewayClientAlarmsTests
|
||||
|
||||
AcknowledgeAlarmReply reply = await client.AcknowledgeAlarmAsync(new AcknowledgeAlarmRequest
|
||||
{
|
||||
SessionId = "session-fixture",
|
||||
ClientCorrelationId = "corr-1",
|
||||
AlarmFullReference = "Tank01.Level.HiHi",
|
||||
Comment = "investigating",
|
||||
@@ -64,7 +62,6 @@ public sealed class MxGatewayClientAlarmsTests
|
||||
client.AcknowledgeAlarmAsync(
|
||||
new AcknowledgeAlarmRequest
|
||||
{
|
||||
SessionId = "session-fixture",
|
||||
AlarmFullReference = "Tank01.Level.HiHi",
|
||||
Comment = string.Empty,
|
||||
OperatorUser = "alice",
|
||||
@@ -73,23 +70,20 @@ public sealed class MxGatewayClientAlarmsTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcknowledgeAlarmAsync_MapsUnauthenticated_RpcException_ToTypedException()
|
||||
public async Task AcknowledgeAlarmAsync_SurfacesRpcExceptionFromFakeTransportVerbatim_WhenMappingDisabled()
|
||||
{
|
||||
// Default FakeGatewayTransport.MapTransportExceptions is false, matching the
|
||||
// historical pass-through shape: a thrown RpcException reaches the caller as
|
||||
// RpcException rather than being mapped to a typed MxGatewayException. This
|
||||
// test pins that shape so a future change can't silently flip it.
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
transport.AcknowledgeAlarmExceptions.Enqueue(
|
||||
new RpcException(new Status(StatusCode.Unauthenticated, "expired key")));
|
||||
await using MxGatewayClient client = CreateClient(transport);
|
||||
|
||||
// Note: the FakeGatewayTransport surfaces RpcException directly (it does not run
|
||||
// through GrpcMxGatewayClientTransport's mapping); the fake's contract here is to
|
||||
// pass the exception verbatim. RpcException → typed exception mapping is covered
|
||||
// in the GrpcMxGatewayClientTransport-level tests; the SDK-level test pins the
|
||||
// pass-through shape so a future migration to direct mapping won't silently
|
||||
// change observable behaviour.
|
||||
var ex = await Assert.ThrowsAsync<RpcException>(
|
||||
() => client.AcknowledgeAlarmAsync(new AcknowledgeAlarmRequest
|
||||
{
|
||||
SessionId = "session-fixture",
|
||||
AlarmFullReference = "Tank01.Level.HiHi",
|
||||
Comment = string.Empty,
|
||||
OperatorUser = "alice",
|
||||
@@ -98,50 +92,72 @@ public sealed class MxGatewayClientAlarmsTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryActiveAlarmsAsync_StreamsEnqueuedSnapshots()
|
||||
public async Task AcknowledgeAlarmAsync_MapsUnauthenticated_RpcException_ToTypedException()
|
||||
{
|
||||
// Production parity: GrpcMxGatewayClientTransport.AcknowledgeAlarmAsync runs
|
||||
// every thrown RpcException through RpcExceptionMapper.Map, so callers see
|
||||
// MxGatewayAuthenticationException (for Unauthenticated) rather than the raw
|
||||
// RpcException. The fake transport reproduces that mapping when
|
||||
// MapTransportExceptions is set, letting this SDK-level test cover the same
|
||||
// observable behaviour without standing up a real gRPC channel.
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
transport.MapTransportExceptions = true;
|
||||
transport.AcknowledgeAlarmExceptions.Enqueue(
|
||||
new RpcException(new Status(StatusCode.Unauthenticated, "expired key")));
|
||||
await using MxGatewayClient client = CreateClient(transport);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<MxGatewayAuthenticationException>(
|
||||
() => client.AcknowledgeAlarmAsync(new AcknowledgeAlarmRequest
|
||||
{
|
||||
AlarmFullReference = "Tank01.Level.HiHi",
|
||||
Comment = string.Empty,
|
||||
OperatorUser = "alice",
|
||||
}));
|
||||
Assert.Equal(StatusCode.Unauthenticated, ex.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StreamAlarmsAsync_StreamsSnapshotThenSnapshotComplete()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
transport.AddActiveAlarmSnapshot(MakeSnapshot("Tank01.Level.HiHi", AlarmConditionState.Active));
|
||||
transport.AddActiveAlarmSnapshot(MakeSnapshot("Tank02.Level.HiHi", AlarmConditionState.ActiveAcked));
|
||||
await using MxGatewayClient client = CreateClient(transport);
|
||||
|
||||
List<ActiveAlarmSnapshot> snapshots = [];
|
||||
await foreach (ActiveAlarmSnapshot snapshot in client.QueryActiveAlarmsAsync(new QueryActiveAlarmsRequest
|
||||
List<AlarmFeedMessage> messages = [];
|
||||
await foreach (AlarmFeedMessage message in client.StreamAlarmsAsync(new StreamAlarmsRequest()))
|
||||
{
|
||||
SessionId = "session-fixture",
|
||||
}))
|
||||
{
|
||||
snapshots.Add(snapshot);
|
||||
messages.Add(message);
|
||||
}
|
||||
|
||||
Assert.Equal(2, snapshots.Count);
|
||||
Assert.Equal("Tank01.Level.HiHi", snapshots[0].AlarmFullReference);
|
||||
Assert.Equal(AlarmConditionState.Active, snapshots[0].CurrentState);
|
||||
Assert.Equal(AlarmConditionState.ActiveAcked, snapshots[1].CurrentState);
|
||||
Assert.Single(transport.QueryActiveAlarmsCalls);
|
||||
Assert.Equal(3, messages.Count);
|
||||
Assert.Equal("Tank01.Level.HiHi", messages[0].ActiveAlarm.AlarmFullReference);
|
||||
Assert.Equal(AlarmConditionState.Active, messages[0].ActiveAlarm.CurrentState);
|
||||
Assert.Equal(AlarmConditionState.ActiveAcked, messages[1].ActiveAlarm.CurrentState);
|
||||
Assert.True(messages[2].SnapshotComplete);
|
||||
Assert.Single(transport.StreamAlarmsCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryActiveAlarmsAsync_PassesFilterPrefix()
|
||||
public async Task StreamAlarmsAsync_PassesFilterPrefix()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
await using MxGatewayClient client = CreateClient(transport);
|
||||
|
||||
await foreach (ActiveAlarmSnapshot _ in client.QueryActiveAlarmsAsync(new QueryActiveAlarmsRequest
|
||||
await foreach (AlarmFeedMessage _ in client.StreamAlarmsAsync(new StreamAlarmsRequest
|
||||
{
|
||||
SessionId = "session-fixture",
|
||||
AlarmFilterPrefix = "Tank01.",
|
||||
}))
|
||||
{
|
||||
// no snapshots enqueued; just verifying the request passes through
|
||||
// only the snapshot-complete sentinel; verifying the request passes through
|
||||
}
|
||||
|
||||
var call = Assert.Single(transport.QueryActiveAlarmsCalls);
|
||||
var call = Assert.Single(transport.StreamAlarmsCalls);
|
||||
Assert.Equal("Tank01.", call.Request.AlarmFilterPrefix);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryActiveAlarmsAsync_HonorsCancellationDuringEnumeration()
|
||||
public async Task StreamAlarmsAsync_HonorsCancellationDuringEnumeration()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
transport.AddActiveAlarmSnapshot(MakeSnapshot("Tank01.Level.HiHi", AlarmConditionState.Active));
|
||||
@@ -151,8 +167,8 @@ public sealed class MxGatewayClientAlarmsTests
|
||||
using CancellationTokenSource cancellation = new();
|
||||
await Assert.ThrowsAsync<OperationCanceledException>(async () =>
|
||||
{
|
||||
await foreach (ActiveAlarmSnapshot _ in client.QueryActiveAlarmsAsync(
|
||||
new QueryActiveAlarmsRequest { SessionId = "session-fixture" },
|
||||
await foreach (AlarmFeedMessage _ in client.StreamAlarmsAsync(
|
||||
new StreamAlarmsRequest(),
|
||||
cancellation.Token))
|
||||
{
|
||||
cancellation.Cancel();
|
||||
|
||||
@@ -106,6 +106,43 @@ public sealed class MxGatewayClientCliTests
|
||||
Assert.Contains("[redacted]", error.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that error output redacts the API key even when it was sourced from
|
||||
/// the <c>--api-key-env</c> environment variable rather than passed via
|
||||
/// <c>--api-key</c> — the documented default credential path.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_ErrorOutput_RedactsApiKey_WhenSourcedFromEnvironmentVariable()
|
||||
{
|
||||
const string environmentVariableName = "MXGATEWAY_TEST_API_KEY_REDACT";
|
||||
using var output = new StringWriter();
|
||||
using var error = new StringWriter();
|
||||
|
||||
Environment.SetEnvironmentVariable(environmentVariableName, "env-secret-api-key");
|
||||
try
|
||||
{
|
||||
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||
[
|
||||
"open-session",
|
||||
"--endpoint",
|
||||
"http://localhost:5000",
|
||||
"--api-key-env",
|
||||
environmentVariableName,
|
||||
],
|
||||
output,
|
||||
error,
|
||||
_ => throw new InvalidOperationException("boom env-secret-api-key"));
|
||||
|
||||
Assert.Equal(1, exitCode);
|
||||
Assert.DoesNotContain("env-secret-api-key", error.ToString());
|
||||
Assert.Contains("[redacted]", error.ToString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable(environmentVariableName, null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that stream-events with max-events limit stops output in non-JSON format.</summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_StreamEvents_WithMaxEventsStopsNonJsonOutput()
|
||||
@@ -147,6 +184,150 @@ public sealed class MxGatewayClientCliTests
|
||||
Assert.DoesNotContain("ON_WRITE_COMPLETE", output.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client.Dotnet-017 regression: a finite-window event collector
|
||||
/// (<c>stream-events --timeout</c>) must exit 0 and emit the events
|
||||
/// that arrived before the timeout fired, instead of propagating the
|
||||
/// timeout-driven <see cref="OperationCanceledException"/> as an
|
||||
/// unhandled exception (exit code -532462766). The fix wraps the
|
||||
/// <c>await foreach</c> in a token-aware catch so the cancellation
|
||||
/// ends the foreach gracefully; the aggregated JSON output still runs.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_StreamEvents_WhenTimeoutFiresAfterEvents_EmitsCollectedEventsAndExitsZero()
|
||||
{
|
||||
using var output = new StringWriter();
|
||||
using var error = new StringWriter();
|
||||
FakeCliClient fakeClient = new();
|
||||
fakeClient.Events.Add(new MxEvent
|
||||
{
|
||||
SessionId = "session-fixture",
|
||||
Family = MxEventFamily.OnDataChange,
|
||||
WorkerSequence = 1,
|
||||
});
|
||||
fakeClient.Events.Add(new MxEvent
|
||||
{
|
||||
SessionId = "session-fixture",
|
||||
Family = MxEventFamily.OnDataChange,
|
||||
WorkerSequence = 2,
|
||||
});
|
||||
// Park forever after yielding the configured events so the CLI's
|
||||
// --timeout drives the cancellation path.
|
||||
fakeClient.StreamHangAfterEvents = async token =>
|
||||
{
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, token).ConfigureAwait(false);
|
||||
};
|
||||
|
||||
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||
[
|
||||
"stream-events",
|
||||
"--endpoint",
|
||||
"http://localhost:5000",
|
||||
"--api-key",
|
||||
"test-api-key",
|
||||
"--session-id",
|
||||
"session-fixture",
|
||||
"--json",
|
||||
"--max-events",
|
||||
"200",
|
||||
"--timeout",
|
||||
"1s",
|
||||
],
|
||||
output,
|
||||
error,
|
||||
_ => fakeClient);
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
string json = output.ToString();
|
||||
// Aggregate JSON output must run even though the foreach exited via
|
||||
// cancellation, and it must contain both events that arrived first.
|
||||
Assert.Contains("\"events\"", json);
|
||||
Assert.Contains("\"workerSequence\":\"1\"", json);
|
||||
Assert.Contains("\"workerSequence\":\"2\"", json);
|
||||
Assert.Equal(string.Empty, error.ToString());
|
||||
}
|
||||
|
||||
|
||||
/// <summary>Verifies that stream-alarms with --max-events stops output and distinguishes payload cases.</summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_StreamAlarms_WithMaxEventsStopsAndDistinguishesPayloadCases()
|
||||
{
|
||||
using var output = new StringWriter();
|
||||
using var error = new StringWriter();
|
||||
FakeCliClient fakeClient = new();
|
||||
fakeClient.AlarmFeedMessages.Add(new AlarmFeedMessage
|
||||
{
|
||||
ActiveAlarm = new ActiveAlarmSnapshot { AlarmFullReference = "Tank01.Level.HiHi" },
|
||||
});
|
||||
fakeClient.AlarmFeedMessages.Add(new AlarmFeedMessage { SnapshotComplete = true });
|
||||
|
||||
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||
[
|
||||
"stream-alarms",
|
||||
"--endpoint",
|
||||
"http://localhost:5000",
|
||||
"--api-key",
|
||||
"test-api-key",
|
||||
"--filter-prefix",
|
||||
"Tank01",
|
||||
"--max-events",
|
||||
"1",
|
||||
],
|
||||
output,
|
||||
error,
|
||||
_ => fakeClient);
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
StreamAlarmsRequest request = Assert.Single(fakeClient.StreamAlarmsRequests);
|
||||
Assert.Equal("Tank01", request.AlarmFilterPrefix);
|
||||
string text = output.ToString();
|
||||
Assert.Contains("active-alarm", text);
|
||||
Assert.Contains("Tank01.Level.HiHi", text);
|
||||
Assert.DoesNotContain("snapshot-complete", text);
|
||||
Assert.Equal(string.Empty, error.ToString());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that acknowledge-alarm builds a request and prints the JSON reply.</summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_AcknowledgeAlarm_BuildsRequestAndPrintsJsonReply()
|
||||
{
|
||||
using var output = new StringWriter();
|
||||
using var error = new StringWriter();
|
||||
FakeCliClient fakeClient = new();
|
||||
fakeClient.AcknowledgeAlarmReplies.Enqueue(new AcknowledgeAlarmReply
|
||||
{
|
||||
CorrelationId = "ack-fixture",
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
Hresult = 0,
|
||||
});
|
||||
|
||||
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||
[
|
||||
"acknowledge-alarm",
|
||||
"--endpoint",
|
||||
"http://localhost:5000",
|
||||
"--api-key",
|
||||
"test-api-key",
|
||||
"--reference",
|
||||
"Tank01.Level.HiHi",
|
||||
"--comment",
|
||||
"ack from cli",
|
||||
"--operator",
|
||||
"operator1",
|
||||
"--json",
|
||||
],
|
||||
output,
|
||||
error,
|
||||
_ => fakeClient);
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
AcknowledgeAlarmRequest request = Assert.Single(fakeClient.AcknowledgeAlarmRequests);
|
||||
Assert.Equal("Tank01.Level.HiHi", request.AlarmFullReference);
|
||||
Assert.Equal("ack from cli", request.Comment);
|
||||
Assert.Equal("operator1", request.OperatorUser);
|
||||
Assert.Contains("ack-fixture", output.ToString());
|
||||
Assert.Equal(string.Empty, error.ToString());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that smoke command closes opened session when a command fails.</summary>
|
||||
[Fact]
|
||||
@@ -368,6 +549,141 @@ public sealed class MxGatewayClientCliTests
|
||||
Assert.Contains("\"objectCount\": 99", text);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that batch mode executes a single no-gateway command and writes the EOR sentinel.</summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_Batch_SingleVersionCommand_WritesOutputAndEorSentinel()
|
||||
{
|
||||
using var output = new StringWriter();
|
||||
using var error = new StringWriter();
|
||||
using var stdin = new StringReader("version --json\n");
|
||||
|
||||
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||
["batch"],
|
||||
output,
|
||||
error,
|
||||
clientFactory: null,
|
||||
standardInput: stdin);
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
string text = output.ToString();
|
||||
Assert.Contains("\"gatewayProtocolVersion\"", text);
|
||||
Assert.Contains("__MXGW_BATCH_EOR__", text);
|
||||
// Sentinel must appear after the output, not before.
|
||||
int outputIdx = text.IndexOf("gatewayProtocolVersion", StringComparison.Ordinal);
|
||||
int eorIdx = text.IndexOf("__MXGW_BATCH_EOR__", StringComparison.Ordinal);
|
||||
Assert.True(outputIdx < eorIdx, "EOR sentinel must follow command output.");
|
||||
Assert.Equal(string.Empty, error.ToString());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that batch mode processes two commands sequentially and writes two EOR sentinels.</summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_Batch_TwoVersionCommands_WritesTwoEorSentinels()
|
||||
{
|
||||
using var output = new StringWriter();
|
||||
using var error = new StringWriter();
|
||||
// Two commands followed by EOF (end of string).
|
||||
using var stdin = new StringReader("version\nversion --json\n");
|
||||
|
||||
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||
["batch"],
|
||||
output,
|
||||
error,
|
||||
clientFactory: null,
|
||||
standardInput: stdin);
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
string text = output.ToString();
|
||||
int firstEor = text.IndexOf("__MXGW_BATCH_EOR__", StringComparison.Ordinal);
|
||||
int secondEor = text.IndexOf("__MXGW_BATCH_EOR__", firstEor + 1, StringComparison.Ordinal);
|
||||
Assert.True(firstEor >= 0, "First EOR sentinel must be present.");
|
||||
Assert.True(secondEor > firstEor, "Second EOR sentinel must follow first.");
|
||||
Assert.Equal(string.Empty, error.ToString());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that batch mode on EOF (empty stdin) exits 0 immediately without writing any sentinel.</summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_Batch_EmptyStdin_ExitsZeroWithNoOutput()
|
||||
{
|
||||
using var output = new StringWriter();
|
||||
using var error = new StringWriter();
|
||||
using var stdin = new StringReader(string.Empty);
|
||||
|
||||
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||
["batch"],
|
||||
output,
|
||||
error,
|
||||
clientFactory: null,
|
||||
standardInput: stdin);
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
Assert.Equal(string.Empty, output.ToString());
|
||||
Assert.Equal(string.Empty, error.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that batch mode continues after a command failure and writes the error JSON
|
||||
/// to stdout (not stderr), followed by the EOR sentinel.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_Batch_CommandFailure_WritesErrorJsonToStdoutAndContinues()
|
||||
{
|
||||
using var output = new StringWriter();
|
||||
using var error = new StringWriter();
|
||||
// First line: a gateway command with no API key (will fail).
|
||||
// Second line: version (will succeed).
|
||||
using var stdin = new StringReader("open-session --endpoint http://localhost:5000\nversion --json\n");
|
||||
|
||||
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||
["batch"],
|
||||
output,
|
||||
error,
|
||||
clientFactory: _ => throw new InvalidOperationException("injected failure"),
|
||||
standardInput: stdin);
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
string text = output.ToString();
|
||||
|
||||
// Error record: the error JSON must be on stdout, not stderr.
|
||||
Assert.Contains("\"error\"", text);
|
||||
Assert.Equal(string.Empty, error.ToString());
|
||||
|
||||
// Both records must be present.
|
||||
int firstEor = text.IndexOf("__MXGW_BATCH_EOR__", StringComparison.Ordinal);
|
||||
int secondEor = text.IndexOf("__MXGW_BATCH_EOR__", firstEor + 1, StringComparison.Ordinal);
|
||||
Assert.True(firstEor >= 0, "EOR after failed command must be present.");
|
||||
Assert.True(secondEor > firstEor, "EOR after successful command must follow first EOR.");
|
||||
|
||||
// Second record must contain the version output.
|
||||
string afterFirstEor = text[(firstEor + "__MXGW_BATCH_EOR__".Length)..];
|
||||
Assert.Contains("\"gatewayProtocolVersion\"", afterFirstEor);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that batch mode treats an empty (blank) line as EOF and exits 0.</summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_Batch_EmptyLine_ExitsZeroAfterPreviousCommands()
|
||||
{
|
||||
using var output = new StringWriter();
|
||||
using var error = new StringWriter();
|
||||
// One command, then an empty line (stop signal), then another command that must NOT run.
|
||||
using var stdin = new StringReader("version --json\n\nversion --json\n");
|
||||
|
||||
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||
["batch"],
|
||||
output,
|
||||
error,
|
||||
clientFactory: null,
|
||||
standardInput: stdin);
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
string text = output.ToString();
|
||||
// Only one EOR sentinel — the second command after the empty line must not execute.
|
||||
int firstEor = text.IndexOf("__MXGW_BATCH_EOR__", StringComparison.Ordinal);
|
||||
int secondEor = text.IndexOf("__MXGW_BATCH_EOR__", firstEor + 1, StringComparison.Ordinal);
|
||||
Assert.True(firstEor >= 0, "One EOR sentinel must be present.");
|
||||
Assert.Equal(-1, secondEor);
|
||||
Assert.Equal(string.Empty, error.ToString());
|
||||
}
|
||||
|
||||
/// <summary>Fake CLI client for testing.</summary>
|
||||
private sealed class FakeCliClient : IMxGatewayCliClient
|
||||
{
|
||||
@@ -386,6 +702,14 @@ public sealed class MxGatewayClientCliTests
|
||||
/// <summary>Exception to throw on invoke, if any.</summary>
|
||||
public Exception? InvokeFailure { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When set, after yielding all <see cref="Events"/> the stream
|
||||
/// awaits the provided handle and then throws
|
||||
/// <see cref="OperationCanceledException"/> — used to simulate the
|
||||
/// CLI timeout / Ctrl+C cancellation path (Client.Dotnet-017).
|
||||
/// </summary>
|
||||
public Func<CancellationToken, Task>? StreamHangAfterEvents { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
@@ -445,6 +769,46 @@ public sealed class MxGatewayClientCliTests
|
||||
await Task.Yield();
|
||||
yield return gatewayEvent;
|
||||
}
|
||||
|
||||
if (StreamHangAfterEvents is not null)
|
||||
{
|
||||
await StreamHangAfterEvents(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Queue of acknowledge-alarm replies to return.</summary>
|
||||
public Queue<AcknowledgeAlarmReply> AcknowledgeAlarmReplies { get; } = new();
|
||||
|
||||
/// <summary>List of received acknowledge-alarm requests.</summary>
|
||||
public List<AcknowledgeAlarmRequest> AcknowledgeAlarmRequests { get; } = [];
|
||||
|
||||
/// <summary>List of received stream-alarms requests.</summary>
|
||||
public List<StreamAlarmsRequest> StreamAlarmsRequests { get; } = [];
|
||||
|
||||
/// <summary>List of alarm feed messages to yield when streaming alarms.</summary>
|
||||
public List<AlarmFeedMessage> AlarmFeedMessages { get; } = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
|
||||
AcknowledgeAlarmRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
AcknowledgeAlarmRequests.Add(request);
|
||||
return Task.FromResult(AcknowledgeAlarmReplies.Dequeue());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
||||
StreamAlarmsRequest request,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
StreamAlarmsRequests.Add(request);
|
||||
foreach (AlarmFeedMessage feedMessage in AlarmFeedMessages)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await Task.Yield();
|
||||
yield return feedMessage;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Galaxy test connection reply to return.</summary>
|
||||
|
||||
@@ -184,6 +184,96 @@ public sealed class MxGatewayClientSessionTests
|
||||
Assert.Equal(["Area001.Pump001.Speed"], request.Command.SubscribeBulk.TagAddresses);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that WriteBulk builds one command carrying the entry list verbatim
|
||||
/// and returns the per-entry BulkWriteResult list without throwing on per-entry
|
||||
/// failures.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task WriteBulkAsync_BuildsOneBulkCommandAndReturnsPerEntryResults()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
transport.AddInvokeReply(new MxCommandReply
|
||||
{
|
||||
SessionId = "session-fixture",
|
||||
Kind = MxCommandKind.WriteBulk,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
WriteBulk = new BulkWriteReply
|
||||
{
|
||||
Results =
|
||||
{
|
||||
new BulkWriteResult { ServerHandle = 12, ItemHandle = 901, WasSuccessful = true },
|
||||
new BulkWriteResult { ServerHandle = 12, ItemHandle = 902, WasSuccessful = false, ErrorMessage = "Invalid handle" },
|
||||
},
|
||||
},
|
||||
});
|
||||
await using MxGatewayClient client = CreateClient(transport);
|
||||
MxGatewaySession session = await client.OpenSessionAsync();
|
||||
|
||||
IReadOnlyList<BulkWriteResult> results = await session.WriteBulkAsync(
|
||||
12,
|
||||
new[]
|
||||
{
|
||||
new WriteBulkEntry { ItemHandle = 901, UserId = 5, Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 11 } },
|
||||
new WriteBulkEntry { ItemHandle = 902, UserId = 5, Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 22 } },
|
||||
});
|
||||
|
||||
Assert.Equal(2, results.Count);
|
||||
Assert.True(results[0].WasSuccessful);
|
||||
Assert.False(results[1].WasSuccessful);
|
||||
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
|
||||
Assert.Equal(MxCommandKind.WriteBulk, request.Command.Kind);
|
||||
Assert.Equal(12, request.Command.WriteBulk.ServerHandle);
|
||||
Assert.Equal(2, request.Command.WriteBulk.Entries.Count);
|
||||
Assert.Equal(901, request.Command.WriteBulk.Entries[0].ItemHandle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that ReadBulk forwards the timeout to the gateway as milliseconds
|
||||
/// and unpacks the BulkReadReply payload's was_cached / value fields.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadBulkAsync_ForwardsTimeoutAndUnpacksCachedFlag()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
transport.AddInvokeReply(new MxCommandReply
|
||||
{
|
||||
SessionId = "session-fixture",
|
||||
Kind = MxCommandKind.ReadBulk,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
ReadBulk = new BulkReadReply
|
||||
{
|
||||
Results =
|
||||
{
|
||||
new BulkReadResult
|
||||
{
|
||||
ServerHandle = 12,
|
||||
TagAddress = "Area001.Pump001.Speed",
|
||||
ItemHandle = 901,
|
||||
WasSuccessful = true,
|
||||
WasCached = true,
|
||||
Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 99 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await using MxGatewayClient client = CreateClient(transport);
|
||||
MxGatewaySession session = await client.OpenSessionAsync();
|
||||
|
||||
IReadOnlyList<BulkReadResult> results = await session.ReadBulkAsync(
|
||||
12,
|
||||
["Area001.Pump001.Speed"],
|
||||
TimeSpan.FromMilliseconds(750));
|
||||
|
||||
BulkReadResult result = Assert.Single(results);
|
||||
Assert.True(result.WasCached);
|
||||
Assert.Equal(99, result.Value.Int32Value);
|
||||
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
|
||||
Assert.Equal(MxCommandKind.ReadBulk, request.Command.Kind);
|
||||
Assert.Equal(750u, request.Command.ReadBulk.TimeoutMs);
|
||||
Assert.Equal(["Area001.Pump001.Speed"], request.Command.ReadBulk.TagAddresses);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that stream events yields events in the order received from the gateway.</summary>
|
||||
[Fact]
|
||||
public async Task StreamEventsAsync_YieldsEventsInGatewayOrder()
|
||||
@@ -231,6 +321,52 @@ public sealed class MxGatewayClientSessionTests
|
||||
Assert.Equal("session-fixture", call.Request.SessionId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that disposing a session while other callers are concurrently inside
|
||||
/// <see cref="MxGatewaySession.CloseAsync"/> — one holding the close lock and one
|
||||
/// parked on it — never throws <see cref="ObjectDisposedException"/> into those
|
||||
/// callers. The close lock must outlive every pending close.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DisposeAsync_DoesNotRaceConcurrentCloseAsync()
|
||||
{
|
||||
for (int iteration = 0; iteration < 100; iteration++)
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
using SemaphoreSlim firstCloseEntered = new(0, 1);
|
||||
using SemaphoreSlim releaseFirstClose = new(0, 1);
|
||||
|
||||
// The first CloseAsync to reach the transport parks here while holding the
|
||||
// session's close lock; later callers queue on the lock behind it.
|
||||
transport.CloseSessionHook = async () =>
|
||||
{
|
||||
firstCloseEntered.Release();
|
||||
await releaseFirstClose.WaitAsync().ConfigureAwait(false);
|
||||
transport.CloseSessionHook = null;
|
||||
};
|
||||
|
||||
await using MxGatewayClient client = CreateClient(transport);
|
||||
MxGatewaySession session = await client.OpenSessionAsync();
|
||||
|
||||
// Holder enters CloseAsync, acquires the lock, and parks in the hook.
|
||||
Task holder = Task.Run(() => session.CloseAsync());
|
||||
await firstCloseEntered.WaitAsync();
|
||||
|
||||
// Waiter is parked on the close lock behind the holder.
|
||||
Task waiter = Task.Run(() => session.CloseAsync());
|
||||
|
||||
// DisposeAsync runs concurrently; it must wait out both callers before
|
||||
// disposing the close lock rather than tearing it down underneath them.
|
||||
Task dispose = session.DisposeAsync().AsTask();
|
||||
|
||||
releaseFirstClose.Release();
|
||||
|
||||
await holder;
|
||||
await waiter;
|
||||
await dispose;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that invoke retries safe diagnostic commands on transient RPC failure.</summary>
|
||||
[Fact]
|
||||
public async Task InvokeAsync_RetriesSafeDiagnosticCommandOnTransientGrpcFailure()
|
||||
@@ -255,6 +391,35 @@ public sealed class MxGatewayClientSessionTests
|
||||
Assert.Equal(2, transport.InvokeCalls.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the retry pipeline still retries when the transport maps the raw
|
||||
/// <see cref="RpcException"/> to an <see cref="MxGatewayException"/> before it reaches
|
||||
/// the retry predicate — the wrapped-exception shape that production always produces.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InvokeAsync_RetriesSafeDiagnosticCommand_WhenTransportMapsRpcException()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
transport.MapTransportExceptions = true;
|
||||
transport.InvokeExceptions.Enqueue(CreateTransientRpcException());
|
||||
transport.AddInvokeReply(new MxCommandReply
|
||||
{
|
||||
SessionId = "session-fixture",
|
||||
Kind = MxCommandKind.Ping,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
});
|
||||
await using MxGatewayClient client = CreateClient(transport);
|
||||
MxGatewaySession session = await client.OpenSessionAsync();
|
||||
|
||||
await session.InvokeAsync(new MxCommandRequest
|
||||
{
|
||||
SessionId = session.SessionId,
|
||||
Command = new MxCommand { Kind = MxCommandKind.Ping, Ping = new PingCommand() },
|
||||
});
|
||||
|
||||
Assert.Equal(2, transport.InvokeCalls.Count);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that open session does not retry on transient RPC failure.</summary>
|
||||
[Fact]
|
||||
public async Task OpenSessionAsync_DoesNotRetryTransientGrpcFailure()
|
||||
@@ -303,6 +468,84 @@ public sealed class MxGatewayClientSessionTests
|
||||
Assert.Equal(cancellation.Token, Assert.Single(transport.InvokeCalls).CallOptions.CancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a client-imposed <see cref="StatusCode.DeadlineExceeded"/> is not
|
||||
/// retried. The deadline budget is shared across the whole safe-unary operation, so
|
||||
/// an immediate retry would only fail again — the call must surface the failure.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InvokeAsync_DoesNotRetrySafeDiagnosticCommand_OnDeadlineExceeded()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
transport.InvokeExceptions.Enqueue(
|
||||
new RpcException(new Status(StatusCode.DeadlineExceeded, "deadline exceeded")));
|
||||
transport.AddInvokeReply(new MxCommandReply
|
||||
{
|
||||
SessionId = "session-fixture",
|
||||
Kind = MxCommandKind.Ping,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
});
|
||||
await using MxGatewayClient client = CreateClient(transport);
|
||||
MxGatewaySession session = await client.OpenSessionAsync();
|
||||
|
||||
await Assert.ThrowsAsync<RpcException>(async () => await session.InvokeAsync(
|
||||
new MxCommandRequest
|
||||
{
|
||||
SessionId = session.SessionId,
|
||||
Command = new MxCommand { Kind = MxCommandKind.Ping, Ping = new PingCommand() },
|
||||
}));
|
||||
|
||||
Assert.Single(transport.InvokeCalls);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a successful register reply missing the typed <c>register</c>
|
||||
/// payload throws a descriptive <see cref="MxGatewayException"/> rather than
|
||||
/// silently returning a zero server handle.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RegisterAsync_Throws_WhenSuccessfulReplyMissingPayload()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
transport.AddInvokeReply(new MxCommandReply
|
||||
{
|
||||
SessionId = "session-fixture",
|
||||
Kind = MxCommandKind.Register,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
});
|
||||
await using MxGatewayClient client = CreateClient(transport);
|
||||
MxGatewaySession session = await client.OpenSessionAsync();
|
||||
|
||||
MxGatewayException exception = await Assert.ThrowsAsync<MxGatewayException>(
|
||||
async () => await session.RegisterAsync("client-name"));
|
||||
|
||||
Assert.Contains("register", exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a successful add-item reply missing the typed <c>add_item</c>
|
||||
/// payload throws a descriptive <see cref="MxGatewayException"/> rather than
|
||||
/// silently returning a zero item handle.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AddItemAsync_Throws_WhenSuccessfulReplyMissingPayload()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
transport.AddInvokeReply(new MxCommandReply
|
||||
{
|
||||
SessionId = "session-fixture",
|
||||
Kind = MxCommandKind.AddItem,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
});
|
||||
await using MxGatewayClient client = CreateClient(transport);
|
||||
MxGatewaySession session = await client.OpenSessionAsync();
|
||||
|
||||
MxGatewayException exception = await Assert.ThrowsAsync<MxGatewayException>(
|
||||
async () => await session.AddItemAsync(1, "Area.Pump.Speed"));
|
||||
|
||||
Assert.Contains("add_item", exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static MxGatewayClient CreateClient(FakeGatewayTransport transport)
|
||||
{
|
||||
return new MxGatewayClient(transport.Options, transport);
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
using Grpc.Core;
|
||||
|
||||
namespace MxGateway.Client.Tests;
|
||||
|
||||
/// <summary>Tests for the shared gRPC-to-native exception mapping used by the transports.</summary>
|
||||
public sealed class RpcExceptionMapperTests
|
||||
{
|
||||
/// <summary>Verifies that an unauthenticated status maps to the authentication exception.</summary>
|
||||
[Fact]
|
||||
public void Map_UnauthenticatedStatus_ProducesAuthenticationException()
|
||||
{
|
||||
RpcException rpc = new(new Status(StatusCode.Unauthenticated, "no key"));
|
||||
|
||||
Exception mapped = RpcExceptionMapper.Map(rpc, CancellationToken.None);
|
||||
|
||||
MxGatewayAuthenticationException authentication =
|
||||
Assert.IsType<MxGatewayAuthenticationException>(mapped);
|
||||
Assert.Equal(StatusCode.Unauthenticated, authentication.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a permission-denied status maps to the authorization exception.</summary>
|
||||
[Fact]
|
||||
public void Map_PermissionDeniedStatus_ProducesAuthorizationException()
|
||||
{
|
||||
RpcException rpc = new(new Status(StatusCode.PermissionDenied, "missing scope"));
|
||||
|
||||
Exception mapped = RpcExceptionMapper.Map(rpc, CancellationToken.None);
|
||||
|
||||
MxGatewayAuthorizationException authorization =
|
||||
Assert.IsType<MxGatewayAuthorizationException>(mapped);
|
||||
Assert.Equal(StatusCode.PermissionDenied, authorization.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a cancelled status maps to OperationCanceledException.</summary>
|
||||
[Fact]
|
||||
public void Map_CancelledStatus_ProducesOperationCanceledException()
|
||||
{
|
||||
RpcException rpc = new(new Status(StatusCode.Cancelled, "cancelled"));
|
||||
|
||||
Exception mapped = RpcExceptionMapper.Map(rpc, CancellationToken.None);
|
||||
|
||||
Assert.IsType<OperationCanceledException>(mapped);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that non-auth statuses surface the originating gRPC status code on the
|
||||
/// mapped exception so callers can distinguish transient from permanent failures
|
||||
/// without reflecting into InnerException.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(StatusCode.NotFound)]
|
||||
[InlineData(StatusCode.InvalidArgument)]
|
||||
[InlineData(StatusCode.ResourceExhausted)]
|
||||
[InlineData(StatusCode.FailedPrecondition)]
|
||||
[InlineData(StatusCode.Unavailable)]
|
||||
[InlineData(StatusCode.Internal)]
|
||||
public void Map_NonAuthStatus_CarriesStatusCodeOnMxGatewayException(StatusCode statusCode)
|
||||
{
|
||||
RpcException rpc = new(new Status(statusCode, "boom"));
|
||||
|
||||
Exception mapped = RpcExceptionMapper.Map(rpc, CancellationToken.None);
|
||||
|
||||
MxGatewayException gatewayException = Assert.IsType<MxGatewayException>(mapped);
|
||||
Assert.Equal(statusCode, gatewayException.StatusCode);
|
||||
Assert.Same(rpc, gatewayException.InnerException);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an MxGatewayException built without a gRPC status reports a null StatusCode.</summary>
|
||||
[Fact]
|
||||
public void StatusCode_IsNull_WhenNoGrpcStatusProvided()
|
||||
{
|
||||
MxGatewayException gatewayException = new("plain failure");
|
||||
|
||||
Assert.Null(gatewayException.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
namespace MxGateway.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Server-side filters and shape options for
|
||||
/// <see cref="GalaxyRepositoryClient.DiscoverHierarchyAsync(DiscoverHierarchyOptions, System.Threading.CancellationToken)"/>.
|
||||
/// Each property maps directly to the corresponding field on the
|
||||
/// <c>DiscoverHierarchyRequest</c> proto so the gateway can narrow the
|
||||
/// hierarchy walk before serializing it back to the client.
|
||||
/// </summary>
|
||||
public sealed record DiscoverHierarchyOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Root Galaxy object id to start the walk from. When set, takes
|
||||
/// precedence over <see cref="RootTagName"/> and <see cref="RootContainedPath"/>.
|
||||
/// </summary>
|
||||
public int? RootGobjectId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Root tag (assigned) name to start the walk from. Used when
|
||||
/// <see cref="RootGobjectId"/> is null.
|
||||
/// </summary>
|
||||
public string? RootTagName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Root contained-name dotted path to start the walk from. Used when
|
||||
/// neither <see cref="RootGobjectId"/> nor <see cref="RootTagName"/> are set.
|
||||
/// </summary>
|
||||
public string? RootContainedPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum traversal depth below the root, inclusive. Leave null for the
|
||||
/// server default (unbounded).
|
||||
/// </summary>
|
||||
public int? MaxDepth { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Galaxy category ids to include. Empty means all categories.
|
||||
/// </summary>
|
||||
public IReadOnlyList<int> CategoryIds { get; init; } = Array.Empty<int>();
|
||||
|
||||
/// <summary>
|
||||
/// Template tag names that must appear somewhere in each returned
|
||||
/// object's template chain. Empty means no template filter.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> TemplateChainContains { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Optional glob (e.g. <c>"Tank*"</c>) matched against each object's tag name.
|
||||
/// </summary>
|
||||
public string? TagNameGlob { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When set, overrides whether each returned <c>GalaxyObject</c> includes
|
||||
/// its dynamic attribute list. Leave null to use the server default.
|
||||
/// </summary>
|
||||
public bool? IncludeAttributes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, restrict results to objects that bear at least one configured alarm.
|
||||
/// </summary>
|
||||
public bool AlarmBearingOnly { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, restrict results to objects that have at least one historized attribute.
|
||||
/// </summary>
|
||||
public bool HistorizedOnly { get; init; }
|
||||
}
|
||||
@@ -23,7 +23,7 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
||||
private readonly GrpcChannel? _channel;
|
||||
private readonly IGalaxyRepositoryClientTransport _transport;
|
||||
private readonly ResiliencePipeline _safeUnaryRetryPipeline;
|
||||
private bool _disposed;
|
||||
private int _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a Galaxy Repository client with custom transport and options.
|
||||
@@ -182,6 +182,17 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
||||
return await DiscoverHierarchyAsync(new DiscoverHierarchyOptions(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates the deployed Galaxy object hierarchy with caller-supplied
|
||||
/// server-side filters. Each returned <see cref="GalaxyObject"/> may include
|
||||
/// its dynamic attributes (controlled by <see cref="DiscoverHierarchyOptions.IncludeAttributes"/>),
|
||||
/// so callers can determine which tag references they may subscribe to via
|
||||
/// the MxAccessGateway service. The client transparently follows the
|
||||
/// gateway's pagination cursor until the hierarchy is fully drained.
|
||||
/// </summary>
|
||||
/// <param name="options">Server-side filter and shape options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The filtered collection of Galaxy objects.</returns>
|
||||
public async Task<IReadOnlyList<GalaxyObject>> DiscoverHierarchyAsync(
|
||||
DiscoverHierarchyOptions options,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -338,12 +349,11 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
||||
/// </summary>
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
_channel?.Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
@@ -444,6 +454,6 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
|
||||
}
|
||||
catch (RpcException exception)
|
||||
{
|
||||
throw MapRpcException(exception, callOptions.CancellationToken);
|
||||
throw RpcExceptionMapper.Map(exception, callOptions.CancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
|
||||
}
|
||||
catch (RpcException exception)
|
||||
{
|
||||
throw MapRpcException(exception, callOptions.CancellationToken);
|
||||
throw RpcExceptionMapper.Map(exception, callOptions.CancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
|
||||
}
|
||||
catch (RpcException exception)
|
||||
{
|
||||
throw MapRpcException(exception, callOptions.CancellationToken);
|
||||
throw RpcExceptionMapper.Map(exception, callOptions.CancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
|
||||
}
|
||||
catch (RpcException exception)
|
||||
{
|
||||
throw MapRpcException(exception, effectiveCancellationToken);
|
||||
throw RpcExceptionMapper.Map(exception, effectiveCancellationToken);
|
||||
}
|
||||
|
||||
yield return deployEvent;
|
||||
@@ -115,28 +115,4 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
|
||||
{
|
||||
return WatchDeployEventsAsync(request, callOptions);
|
||||
}
|
||||
|
||||
private static Exception MapRpcException(
|
||||
RpcException exception,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested || exception.StatusCode == StatusCode.Cancelled)
|
||||
{
|
||||
return new OperationCanceledException(
|
||||
exception.Status.Detail,
|
||||
exception,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
return exception.StatusCode switch
|
||||
{
|
||||
StatusCode.Unauthenticated => new MxGatewayAuthenticationException(
|
||||
exception.Status.Detail,
|
||||
innerException: exception),
|
||||
StatusCode.PermissionDenied => new MxGatewayAuthorizationException(
|
||||
exception.Status.Detail,
|
||||
innerException: exception),
|
||||
_ => new MxGatewayException(exception.Status.Detail, exception),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ internal sealed class GrpcMxGatewayClientTransport(
|
||||
}
|
||||
catch (RpcException exception)
|
||||
{
|
||||
throw MapRpcException(exception, callOptions.CancellationToken);
|
||||
throw RpcExceptionMapper.Map(exception, callOptions.CancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ internal sealed class GrpcMxGatewayClientTransport(
|
||||
}
|
||||
catch (RpcException exception)
|
||||
{
|
||||
throw MapRpcException(exception, callOptions.CancellationToken);
|
||||
throw RpcExceptionMapper.Map(exception, callOptions.CancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ internal sealed class GrpcMxGatewayClientTransport(
|
||||
}
|
||||
catch (RpcException exception)
|
||||
{
|
||||
throw MapRpcException(exception, callOptions.CancellationToken);
|
||||
throw RpcExceptionMapper.Map(exception, callOptions.CancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ internal sealed class GrpcMxGatewayClientTransport(
|
||||
}
|
||||
catch (RpcException exception)
|
||||
{
|
||||
throw MapRpcException(exception, effectiveCancellationToken);
|
||||
throw RpcExceptionMapper.Map(exception, effectiveCancellationToken);
|
||||
}
|
||||
|
||||
yield return gatewayEvent;
|
||||
@@ -129,13 +129,13 @@ internal sealed class GrpcMxGatewayClientTransport(
|
||||
}
|
||||
catch (RpcException exception)
|
||||
{
|
||||
throw MapRpcException(exception, callOptions.CancellationToken);
|
||||
throw RpcExceptionMapper.Map(exception, callOptions.CancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
|
||||
QueryActiveAlarmsRequest request,
|
||||
public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
||||
StreamAlarmsRequest request,
|
||||
CallOptions callOptions,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -143,12 +143,12 @@ internal sealed class GrpcMxGatewayClientTransport(
|
||||
? cancellationToken
|
||||
: callOptions.CancellationToken;
|
||||
|
||||
using AsyncServerStreamingCall<ActiveAlarmSnapshot> call = RawClient.QueryActiveAlarms(request, callOptions);
|
||||
using AsyncServerStreamingCall<AlarmFeedMessage> call = RawClient.StreamAlarms(request, callOptions);
|
||||
|
||||
IAsyncStreamReader<ActiveAlarmSnapshot> responseStream = call.ResponseStream;
|
||||
IAsyncStreamReader<AlarmFeedMessage> responseStream = call.ResponseStream;
|
||||
while (true)
|
||||
{
|
||||
ActiveAlarmSnapshot? snapshot;
|
||||
AlarmFeedMessage? message;
|
||||
try
|
||||
{
|
||||
if (!await responseStream.MoveNext(effectiveCancellationToken).ConfigureAwait(false))
|
||||
@@ -156,46 +156,22 @@ internal sealed class GrpcMxGatewayClientTransport(
|
||||
break;
|
||||
}
|
||||
|
||||
snapshot = responseStream.Current;
|
||||
message = responseStream.Current;
|
||||
}
|
||||
catch (RpcException exception)
|
||||
{
|
||||
throw MapRpcException(exception, effectiveCancellationToken);
|
||||
throw RpcExceptionMapper.Map(exception, effectiveCancellationToken);
|
||||
}
|
||||
|
||||
yield return snapshot;
|
||||
yield return message;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
IAsyncEnumerable<ActiveAlarmSnapshot> IMxGatewayClientTransport.QueryActiveAlarmsAsync(
|
||||
QueryActiveAlarmsRequest request,
|
||||
IAsyncEnumerable<AlarmFeedMessage> IMxGatewayClientTransport.StreamAlarmsAsync(
|
||||
StreamAlarmsRequest request,
|
||||
CallOptions callOptions)
|
||||
{
|
||||
return QueryActiveAlarmsAsync(request, callOptions);
|
||||
}
|
||||
|
||||
private static Exception MapRpcException(
|
||||
RpcException exception,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested || exception.StatusCode == StatusCode.Cancelled)
|
||||
{
|
||||
return new OperationCanceledException(
|
||||
exception.Status.Detail,
|
||||
exception,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
return exception.StatusCode switch
|
||||
{
|
||||
StatusCode.Unauthenticated => new MxGatewayAuthenticationException(
|
||||
exception.Status.Detail,
|
||||
innerException: exception),
|
||||
StatusCode.PermissionDenied => new MxGatewayAuthorizationException(
|
||||
exception.Status.Detail,
|
||||
innerException: exception),
|
||||
_ => new MxGatewayException(exception.Status.Detail, exception),
|
||||
};
|
||||
return StreamAlarmsAsync(request, callOptions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,13 +66,13 @@ internal interface IMxGatewayClientTransport
|
||||
CallOptions callOptions);
|
||||
|
||||
/// <summary>
|
||||
/// Streams a snapshot of all alarms currently in Active or ActiveAcked state — the
|
||||
/// ConditionRefresh equivalent for the gateway.
|
||||
/// Attaches to the gateway's central alarm feed — the current active-alarm
|
||||
/// snapshot followed by live transitions.
|
||||
/// </summary>
|
||||
/// <param name="request">The query request, optionally scoped by alarm-reference prefix.</param>
|
||||
/// <param name="request">The stream request, optionally scoped by alarm-reference prefix.</param>
|
||||
/// <param name="callOptions">gRPC call options.</param>
|
||||
/// <returns>An async enumerable of active-alarm snapshots.</returns>
|
||||
IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
|
||||
QueryActiveAlarmsRequest request,
|
||||
/// <returns>An async enumerable of alarm feed messages.</returns>
|
||||
IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
||||
StreamAlarmsRequest request,
|
||||
CallOptions callOptions);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Grpc.Core;
|
||||
using MxGateway.Contracts.Proto;
|
||||
|
||||
namespace MxGateway.Client;
|
||||
@@ -13,6 +14,7 @@ public sealed class MxGatewayAuthenticationException : MxGatewayException
|
||||
/// <param name="hResult">The HResult code, if available.</param>
|
||||
/// <param name="statuses">The MXAccess statuses, if available.</param>
|
||||
/// <param name="innerException">The underlying exception, if any.</param>
|
||||
/// <param name="statusCode">The gRPC status code reported by the failed call, if available.</param>
|
||||
public MxGatewayAuthenticationException(
|
||||
string message,
|
||||
string? sessionId = null,
|
||||
@@ -20,7 +22,8 @@ public sealed class MxGatewayAuthenticationException : MxGatewayException
|
||||
ProtocolStatus? protocolStatus = null,
|
||||
int? hResult = null,
|
||||
IReadOnlyList<MxStatusProxy>? statuses = null,
|
||||
Exception? innerException = null)
|
||||
Exception? innerException = null,
|
||||
StatusCode? statusCode = null)
|
||||
: base(
|
||||
message,
|
||||
sessionId,
|
||||
@@ -28,7 +31,8 @@ public sealed class MxGatewayAuthenticationException : MxGatewayException
|
||||
protocolStatus,
|
||||
hResult,
|
||||
statuses ?? [],
|
||||
innerException)
|
||||
innerException,
|
||||
statusCode)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Grpc.Core;
|
||||
using MxGateway.Contracts.Proto;
|
||||
|
||||
namespace MxGateway.Client;
|
||||
@@ -13,6 +14,7 @@ public sealed class MxGatewayAuthorizationException : MxGatewayException
|
||||
/// <param name="hResult">The HResult code, if available.</param>
|
||||
/// <param name="statuses">The MXAccess statuses, if available.</param>
|
||||
/// <param name="innerException">The underlying exception, if any.</param>
|
||||
/// <param name="statusCode">The gRPC status code reported by the failed call, if available.</param>
|
||||
public MxGatewayAuthorizationException(
|
||||
string message,
|
||||
string? sessionId = null,
|
||||
@@ -20,7 +22,8 @@ public sealed class MxGatewayAuthorizationException : MxGatewayException
|
||||
ProtocolStatus? protocolStatus = null,
|
||||
int? hResult = null,
|
||||
IReadOnlyList<MxStatusProxy>? statuses = null,
|
||||
Exception? innerException = null)
|
||||
Exception? innerException = null,
|
||||
StatusCode? statusCode = null)
|
||||
: base(
|
||||
message,
|
||||
sessionId,
|
||||
@@ -28,7 +31,8 @@ public sealed class MxGatewayAuthorizationException : MxGatewayException
|
||||
protocolStatus,
|
||||
hResult,
|
||||
statuses ?? [],
|
||||
innerException)
|
||||
innerException,
|
||||
statusCode)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
||||
private readonly GrpcChannel _channel;
|
||||
private readonly IMxGatewayClientTransport _transport;
|
||||
private readonly ResiliencePipeline _safeUnaryRetryPipeline;
|
||||
private bool _disposed;
|
||||
private int _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MxGatewayClient"/> with given options and transport.
|
||||
@@ -184,9 +184,10 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledges an active MXAccess alarm condition through the gateway. The
|
||||
/// gateway authenticates the request against the API key's <c>invoke:alarm-ack</c>
|
||||
/// scope and forwards the acknowledge to the worker's MXAccess session;
|
||||
/// the resulting <see cref="MxStatusProxy"/> is returned in the reply.
|
||||
/// gateway authorizes <see cref="AcknowledgeAlarmRequest"/> against the API
|
||||
/// key's <c>admin</c> scope (there is no finer-grained alarm-ack sub-scope)
|
||||
/// and forwards the acknowledge to the worker's MXAccess session; the
|
||||
/// resulting <see cref="MxStatusProxy"/> is returned in the reply.
|
||||
/// </summary>
|
||||
/// <param name="request">The acknowledge request — alarm reference, comment, operator user.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
@@ -204,24 +205,25 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Streams a snapshot of all alarms currently Active or ActiveAcked — the gateway's
|
||||
/// ConditionRefresh equivalent. Used after reconnect to seed the local Part 9 state
|
||||
/// machine, or to reconcile alarms that may have been missed during a transport
|
||||
/// blip. Optionally scoped by alarm-reference prefix
|
||||
/// (<see cref="QueryActiveAlarmsRequest.AlarmFilterPrefix"/>) so a partial refresh
|
||||
/// can target an equipment sub-tree.
|
||||
/// Attaches to the gateway's central alarm feed. The stream opens with one
|
||||
/// <see cref="AlarmFeedMessage"/> per currently-active alarm (the
|
||||
/// ConditionRefresh snapshot), then a single <c>snapshot_complete</c>, then a
|
||||
/// <c>transition</c> for every subsequent raise / acknowledge / clear. Served
|
||||
/// by the gateway's always-on alarm monitor — no worker session is opened, so
|
||||
/// any number of clients may attach. Optionally scoped by alarm-reference
|
||||
/// prefix (<see cref="StreamAlarmsRequest.AlarmFilterPrefix"/>).
|
||||
/// </summary>
|
||||
/// <param name="request">The query request, optionally scoped by alarm-reference prefix.</param>
|
||||
/// <param name="request">The stream request, optionally scoped by alarm-reference prefix.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the stream.</param>
|
||||
/// <returns>An async enumerable of active-alarm snapshots.</returns>
|
||||
public IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
|
||||
QueryActiveAlarmsRequest request,
|
||||
/// <returns>An async enumerable of alarm feed messages.</returns>
|
||||
public IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
||||
StreamAlarmsRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ThrowIfDisposed();
|
||||
|
||||
return _transport.QueryActiveAlarmsAsync(request, CreateStreamCallOptions(cancellationToken));
|
||||
return _transport.StreamAlarmsAsync(request, CreateStreamCallOptions(cancellationToken));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -229,12 +231,11 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
||||
/// </summary>
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
_channel?.Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
@@ -335,6 +336,6 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,19 @@ namespace MxGateway.Client;
|
||||
/// </summary>
|
||||
public static class MxGatewayClientContractInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the gateway gRPC protocol version compiled into this client package.
|
||||
/// A client and gateway are wire-compatible only when this value matches the
|
||||
/// gateway's advertised gateway protocol version.
|
||||
/// </summary>
|
||||
public const uint GatewayProtocolVersion =
|
||||
GatewayContractInfo.GatewayProtocolVersion;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the worker frame protocol version compiled into this client package.
|
||||
/// Exposed for diagnostics so callers can report the worker protocol the
|
||||
/// shared contracts were generated against.
|
||||
/// </summary>
|
||||
public const uint WorkerProtocolVersion =
|
||||
GatewayContractInfo.WorkerProtocolVersion;
|
||||
}
|
||||
|
||||
@@ -38,7 +38,12 @@ public sealed class MxGatewayClientOptions
|
||||
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the default timeout for unary gRPC calls.
|
||||
/// Gets the timeout budget for a unary gRPC operation. This is both the gRPC
|
||||
/// deadline stamped on each individual attempt and the overall budget for the
|
||||
/// whole safe-unary operation: for retryable calls the initial attempt, every
|
||||
/// retry, and the backoff delays between them all share this single budget.
|
||||
/// It is therefore an upper bound on the total wall-clock time a safe-unary
|
||||
/// call can take, not a fresh per-retry allowance.
|
||||
/// </summary>
|
||||
public TimeSpan DefaultCallTimeout { get; init; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
@@ -47,6 +52,11 @@ public sealed class MxGatewayClientOptions
|
||||
/// </summary>
|
||||
public TimeSpan? StreamTimeout { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum size, in bytes, of a single gRPC message the client will
|
||||
/// send or receive. Applied to both the send and receive limits of the
|
||||
/// underlying channel. Defaults to 16 MiB.
|
||||
/// </summary>
|
||||
public int MaxGrpcMessageBytes { get; init; } = 16 * 1024 * 1024;
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -61,8 +61,13 @@ internal static class MxGatewayClientRetryPolicy
|
||||
|
||||
private static bool IsTransientStatus(StatusCode statusCode)
|
||||
{
|
||||
// DeadlineExceeded is intentionally NOT treated as transient. The deadline
|
||||
// on every unary call is client-imposed (CreateCallOptions stamps the
|
||||
// DefaultCallTimeout budget), and that same budget is shared across the
|
||||
// initial attempt plus all retries plus backoff. A DeadlineExceeded means
|
||||
// the shared budget is exhausted, so an immediate retry would only fail
|
||||
// again — burning the remaining budget on a call that cannot succeed.
|
||||
return statusCode is StatusCode.Unavailable
|
||||
or StatusCode.DeadlineExceeded
|
||||
or StatusCode.ResourceExhausted;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Grpc.Core;
|
||||
using MxGateway.Contracts.Proto;
|
||||
|
||||
namespace MxGateway.Client;
|
||||
@@ -28,6 +29,20 @@ public class MxGatewayException : Exception
|
||||
Statuses = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the MxGatewayException class carrying the originating
|
||||
/// gRPC status code so callers can distinguish transient from permanent failures.
|
||||
/// </summary>
|
||||
/// <param name="message">Diagnostic message describing the failure.</param>
|
||||
/// <param name="statusCode">The gRPC status code reported by the failed call.</param>
|
||||
/// <param name="innerException">Underlying exception that caused this failure.</param>
|
||||
public MxGatewayException(string message, StatusCode statusCode, Exception? innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
Statuses = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the MxGatewayException class with full diagnostic information.
|
||||
/// </summary>
|
||||
@@ -38,6 +53,7 @@ public class MxGatewayException : Exception
|
||||
/// <param name="hResult">HRESULT code returned by the worker or MXAccess, if available.</param>
|
||||
/// <param name="statuses">List of MXAccess status codes returned by the operation.</param>
|
||||
/// <param name="innerException">Underlying exception that caused this failure.</param>
|
||||
/// <param name="statusCode">The gRPC status code reported by the failed call, if available.</param>
|
||||
public MxGatewayException(
|
||||
string message,
|
||||
string? sessionId,
|
||||
@@ -45,7 +61,8 @@ public class MxGatewayException : Exception
|
||||
ProtocolStatus? protocolStatus,
|
||||
int? hResult,
|
||||
IReadOnlyList<MxStatusProxy> statuses,
|
||||
Exception? innerException = null)
|
||||
Exception? innerException = null,
|
||||
StatusCode? statusCode = null)
|
||||
: base(message, innerException)
|
||||
{
|
||||
SessionId = sessionId;
|
||||
@@ -53,6 +70,7 @@ public class MxGatewayException : Exception
|
||||
ProtocolStatus = protocolStatus;
|
||||
HResultCode = hResult;
|
||||
Statuses = statuses;
|
||||
StatusCode = statusCode;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -79,4 +97,15 @@ public class MxGatewayException : Exception
|
||||
/// Gets the list of MXAccess status codes returned by the operation.
|
||||
/// </summary>
|
||||
public IReadOnlyList<MxStatusProxy> Statuses { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the gRPC status code reported by the failed call, if the failure originated
|
||||
/// from a gRPC <see cref="RpcException"/>. <see langword="null"/> when the exception
|
||||
/// was not produced from a gRPC status (for example, a protocol-level reply failure).
|
||||
/// Callers can inspect this to distinguish a transient outage
|
||||
/// (<see cref="Grpc.Core.StatusCode.Unavailable"/>) from a permanent error
|
||||
/// (<see cref="Grpc.Core.StatusCode.InvalidArgument"/>) without downcasting
|
||||
/// <see cref="Exception.InnerException"/>.
|
||||
/// </summary>
|
||||
public StatusCode? StatusCode { get; }
|
||||
}
|
||||
|
||||
@@ -9,7 +9,10 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
||||
{
|
||||
private readonly MxGatewayClient _client;
|
||||
private readonly SemaphoreSlim _closeLock = new(1, 1);
|
||||
private readonly object _disposeGate = new();
|
||||
private CloseSessionReply? _closeReply;
|
||||
private int _activeCloseCount;
|
||||
private bool _closeLockDisposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new session backed by the given MXAccess gateway client.
|
||||
@@ -46,23 +49,42 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
||||
return _closeReply;
|
||||
}
|
||||
|
||||
await _closeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
// Register as an in-flight closer under the dispose gate. DisposeAsync waits for
|
||||
// _activeCloseCount to drain before disposing the close lock, so the semaphore is
|
||||
// guaranteed to outlive every WaitAsync started here.
|
||||
lock (_disposeGate)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_closeLockDisposed, this);
|
||||
_activeCloseCount++;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_closeReply is not null)
|
||||
await _closeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_closeReply is not null)
|
||||
{
|
||||
return _closeReply;
|
||||
}
|
||||
|
||||
_closeReply = await _client.CloseSessionRawAsync(
|
||||
new CloseSessionRequest { SessionId = SessionId },
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return _closeReply;
|
||||
}
|
||||
|
||||
_closeReply = await _client.CloseSessionRawAsync(
|
||||
new CloseSessionRequest { SessionId = SessionId },
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return _closeReply;
|
||||
finally
|
||||
{
|
||||
_closeLock.Release();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_closeLock.Release();
|
||||
lock (_disposeGate)
|
||||
{
|
||||
_activeCloseCount--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +101,8 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
||||
MxCommandReply reply = await RegisterRawAsync(clientName, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||
return reply.Register?.ServerHandle ?? reply.ReturnValue.Int32Value;
|
||||
return reply.Register?.ServerHandle
|
||||
?? throw CreateMissingPayloadException(reply, "register");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -121,7 +144,8 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||
return reply.AddItem?.ItemHandle ?? reply.ReturnValue.Int32Value;
|
||||
return reply.AddItem?.ItemHandle
|
||||
?? throw CreateMissingPayloadException(reply, "add_item");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -172,7 +196,8 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||
return reply.AddItem2?.ItemHandle ?? reply.ReturnValue.Int32Value;
|
||||
return reply.AddItem2?.ItemHandle
|
||||
?? throw CreateMissingPayloadException(reply, "add_item2");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -502,6 +527,142 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
||||
return reply.UnsubscribeBulk?.Results.ToArray() ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulk Write — sequential MXAccess Write per entry on the worker's STA.
|
||||
/// Per-item failures appear as BulkWriteResult entries with
|
||||
/// <c>WasSuccessful = false</c>; the call never throws on per-item errors.
|
||||
/// Protocol-level failures still throw via EnsureProtocolSuccess.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<BulkWriteResult>> WriteBulkAsync(
|
||||
int serverHandle,
|
||||
IReadOnlyList<WriteBulkEntry> entries,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entries);
|
||||
|
||||
WriteBulkCommand command = new() { ServerHandle = serverHandle };
|
||||
command.Entries.Add(entries);
|
||||
|
||||
MxCommandReply reply = await InvokeCommandAsync(
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.WriteBulk,
|
||||
WriteBulk = command,
|
||||
},
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||
return reply.WriteBulk?.Results.ToArray() ?? [];
|
||||
}
|
||||
|
||||
/// <summary>Bulk Write2 — sequential MXAccess Write2 (timestamped) per entry.</summary>
|
||||
public async Task<IReadOnlyList<BulkWriteResult>> Write2BulkAsync(
|
||||
int serverHandle,
|
||||
IReadOnlyList<Write2BulkEntry> entries,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entries);
|
||||
|
||||
Write2BulkCommand command = new() { ServerHandle = serverHandle };
|
||||
command.Entries.Add(entries);
|
||||
|
||||
MxCommandReply reply = await InvokeCommandAsync(
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.Write2Bulk,
|
||||
Write2Bulk = command,
|
||||
},
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||
return reply.Write2Bulk?.Results.ToArray() ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulk WriteSecured — sequential MXAccess WriteSecured per entry.
|
||||
/// Credential-sensitive values must never reach logs; the client mirrors
|
||||
/// the single-item WriteSecured redaction contract.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<BulkWriteResult>> WriteSecuredBulkAsync(
|
||||
int serverHandle,
|
||||
IReadOnlyList<WriteSecuredBulkEntry> entries,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entries);
|
||||
|
||||
WriteSecuredBulkCommand command = new() { ServerHandle = serverHandle };
|
||||
command.Entries.Add(entries);
|
||||
|
||||
MxCommandReply reply = await InvokeCommandAsync(
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.WriteSecuredBulk,
|
||||
WriteSecuredBulk = command,
|
||||
},
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||
return reply.WriteSecuredBulk?.Results.ToArray() ?? [];
|
||||
}
|
||||
|
||||
/// <summary>Bulk WriteSecured2 — sequential MXAccess WriteSecured2 (timestamped) per entry.</summary>
|
||||
public async Task<IReadOnlyList<BulkWriteResult>> WriteSecured2BulkAsync(
|
||||
int serverHandle,
|
||||
IReadOnlyList<WriteSecured2BulkEntry> entries,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entries);
|
||||
|
||||
WriteSecured2BulkCommand command = new() { ServerHandle = serverHandle };
|
||||
command.Entries.Add(entries);
|
||||
|
||||
MxCommandReply reply = await InvokeCommandAsync(
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.WriteSecured2Bulk,
|
||||
WriteSecured2Bulk = command,
|
||||
},
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||
return reply.WriteSecured2Bulk?.Results.ToArray() ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulk Read — snapshot the current value for each requested tag.
|
||||
/// Returns the cached OnDataChange value when the tag is already advised
|
||||
/// (was_cached = true), otherwise the worker takes the full AddItem +
|
||||
/// Advise + wait + UnAdvise + RemoveItem snapshot lifecycle. Per-tag
|
||||
/// failures (timeout, invalid tag) appear as BulkReadResult entries with
|
||||
/// <c>WasSuccessful = false</c>; the call never throws on per-tag errors.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<BulkReadResult>> ReadBulkAsync(
|
||||
int serverHandle,
|
||||
IReadOnlyList<string> tagAddresses,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tagAddresses);
|
||||
|
||||
ReadBulkCommand command = new()
|
||||
{
|
||||
ServerHandle = serverHandle,
|
||||
TimeoutMs = timeout <= TimeSpan.Zero ? 0u : (uint)Math.Min(timeout.TotalMilliseconds, uint.MaxValue),
|
||||
};
|
||||
command.TagAddresses.Add(tagAddresses);
|
||||
|
||||
MxCommandReply reply = await InvokeCommandAsync(
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.ReadBulk,
|
||||
ReadBulk = command,
|
||||
},
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||
return reply.ReadBulk?.Results.ToArray() ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a value to an item on the MXAccess server.
|
||||
/// </summary>
|
||||
@@ -658,7 +819,32 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
||||
/// </summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
lock (_disposeGate)
|
||||
{
|
||||
if (_closeLockDisposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await CloseAsync().ConfigureAwait(false);
|
||||
|
||||
// Wait for every concurrent CloseAsync caller to leave the close lock before
|
||||
// disposing it; once _closeReply is set those callers return without awaiting.
|
||||
while (true)
|
||||
{
|
||||
lock (_disposeGate)
|
||||
{
|
||||
if (_activeCloseCount == 0)
|
||||
{
|
||||
_closeLockDisposed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await Task.Yield();
|
||||
}
|
||||
|
||||
_closeLock.Dispose();
|
||||
}
|
||||
|
||||
@@ -676,4 +862,21 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the exception thrown when a command reply passed protocol and
|
||||
/// MXAccess success checks but is missing the typed handle-bearing payload
|
||||
/// the command contract requires. Surfacing this as a clear error avoids
|
||||
/// silently handing a zero handle to the caller (it would otherwise fall
|
||||
/// through to <see cref="MxCommandReply.ReturnValue"/>, which is 0 when the
|
||||
/// reply carries no return value).
|
||||
/// </summary>
|
||||
private static MxGatewayException CreateMissingPayloadException(
|
||||
MxCommandReply reply,
|
||||
string expectedPayload)
|
||||
{
|
||||
return new MxGatewayException(
|
||||
$"Gateway reply for command kind={reply.Kind} reported success but is missing "
|
||||
+ $"the required '{expectedPayload}' payload; cannot resolve a handle. "
|
||||
+ $"session={reply.SessionId}; correlation={reply.CorrelationId}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using Grpc.Core;
|
||||
|
||||
namespace MxGateway.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Maps low-level <see cref="RpcException"/>s raised by the gRPC stack to the client's
|
||||
/// native exception hierarchy. Shared by every gateway and Galaxy Repository transport
|
||||
/// so the gRPC-to-native translation has exactly one implementation.
|
||||
/// </summary>
|
||||
internal static class RpcExceptionMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Translates a <see cref="RpcException"/> into the most specific native exception type.
|
||||
/// </summary>
|
||||
/// <param name="exception">The gRPC exception to translate.</param>
|
||||
/// <param name="cancellationToken">
|
||||
/// The cancellation token of the originating call; used to distinguish a caller-driven
|
||||
/// cancellation from a server-side <see cref="StatusCode.Cancelled"/> status.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// An <see cref="OperationCanceledException"/> when the call was cancelled, a typed
|
||||
/// authentication/authorization exception for auth statuses, or an
|
||||
/// <see cref="MxGatewayException"/> carrying the originating gRPC <see cref="StatusCode"/>.
|
||||
/// </returns>
|
||||
public static Exception Map(
|
||||
RpcException exception,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(exception);
|
||||
|
||||
if (cancellationToken.IsCancellationRequested || exception.StatusCode == StatusCode.Cancelled)
|
||||
{
|
||||
return new OperationCanceledException(
|
||||
exception.Status.Detail,
|
||||
exception,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
return exception.StatusCode switch
|
||||
{
|
||||
StatusCode.Unauthenticated => new MxGatewayAuthenticationException(
|
||||
exception.Status.Detail,
|
||||
statusCode: exception.StatusCode,
|
||||
innerException: exception),
|
||||
StatusCode.PermissionDenied => new MxGatewayAuthorizationException(
|
||||
exception.Status.Detail,
|
||||
statusCode: exception.StatusCode,
|
||||
innerException: exception),
|
||||
_ => new MxGatewayException(
|
||||
exception.Status.Detail,
|
||||
exception.StatusCode,
|
||||
exception),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,48 @@ 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`.
|
||||
|
||||
```csharp
|
||||
// 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.
|
||||
|
||||
@@ -112,6 +154,17 @@ 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:
|
||||
@@ -131,7 +184,8 @@ dotnet run --project clients/dotnet/MxGateway.Client.Cli -- smoke --endpoint htt
|
||||
`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 API keys supplied through `--api-key`.
|
||||
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
|
||||
|
||||
|
||||
+32
-3
@@ -76,14 +76,42 @@ client, err := mxgateway.Dial(ctx, mxgateway.Options{
|
||||
```
|
||||
|
||||
`Client.OpenSession` returns a `Session` with helpers for `Register`,
|
||||
`AddItem`, `AddItem2`, `Advise`, `Write`, `Events`, and `Close`. Prefer
|
||||
`AddItem`, `AddItem2`, `Advise`, `Write`, the full bulk family
|
||||
(`AddItemBulk`, `AdviseItemBulk`, `RemoveItemBulk`, `UnAdviseItemBulk`,
|
||||
`SubscribeBulk`, `UnsubscribeBulk`, `WriteBulk`, `Write2Bulk`,
|
||||
`WriteSecuredBulk`, `WriteSecured2Bulk`, `ReadBulk`), `Events`, and
|
||||
`Close`. Bulk variants carry a list of entries in one round-trip and
|
||||
return one result per entry; per-entry MXAccess failures appear as
|
||||
`was_successful = false` and never return as Go errors. `ReadBulk` accepts
|
||||
a `time.Duration` per-tag timeout and returns cached `OnDataChange`
|
||||
values when the tag is already advised (`WasCached = true`) without
|
||||
touching the existing subscription. Prefer
|
||||
`SubscribeEvents` or `SubscribeEventsAfter` for long-running streams because the
|
||||
returned subscription owns cancellation and exposes `Close` for deterministic
|
||||
goroutine cleanup. Raw protobuf messages remain available through the
|
||||
goroutine cleanup. `Events` and `EventsAfter` are a compatibility shim with a
|
||||
bounded internal buffer: if the consumer drains too slowly the buffer fills,
|
||||
the underlying stream is cancelled, and a terminal `EventResult` carrying
|
||||
`ErrEventBufferOverflow` is delivered as the channel's last item before it
|
||||
closes — so a slow consumer can distinguish dropped events from a normal
|
||||
end-of-stream. `SubscribeEvents` blocks instead of dropping, so use it when no
|
||||
events may be lost. Raw protobuf messages remain available through the
|
||||
`mxgateway` package aliases and the `Raw` helper methods. Typed errors support
|
||||
`errors.As` for `GatewayError`, `CommandError`, and `MxAccessError`; command
|
||||
errors preserve the raw reply.
|
||||
|
||||
`Dial` and `DialGalaxy` create the connection lazily (`grpc.NewClient`): a
|
||||
gateway that is briefly unavailable no longer turns into a hard error — the
|
||||
connection recovers once the gateway comes up. To keep fail-fast behavior,
|
||||
both run a readiness probe bounded by `DialTimeout` (default 10s, or the
|
||||
context deadline when sooner) and return a `*GatewayError` if the gateway
|
||||
cannot be reached in that window.
|
||||
|
||||
For retry, timeout, and auth handling, `GatewayError.Code()` exposes the
|
||||
wrapped gRPC `codes.Code`, and `mxgateway.IsTransient(err)` reports whether a
|
||||
failure (`Unavailable`, `DeadlineExceeded`, `ResourceExhausted`, `Aborted`)
|
||||
may succeed on retry — so callers do not have to unwrap the error and call
|
||||
`status.Code` themselves.
|
||||
|
||||
## Galaxy Repository browse
|
||||
|
||||
The `GalaxyRepository` service (proto package `galaxy_repository.v1`) is a
|
||||
@@ -158,7 +186,8 @@ The CLI exposes the same RPC via `galaxy-watch`:
|
||||
```powershell
|
||||
go run ./cmd/mxgw-go galaxy-watch -plaintext
|
||||
go run ./cmd/mxgw-go galaxy-watch -plaintext -json
|
||||
go run ./cmd/mxgw-go galaxy-watch -plaintext -last-seen-deploy-time 2026-04-28T10:00:00Z
|
||||
go run ./cmd/mxgw-go galaxy-watch -plaintext -last-seen-deploy-time 2026-04-28T10:00:00Z # whole-second RFC 3339
|
||||
go run ./cmd/mxgw-go galaxy-watch -plaintext -last-seen-deploy-time 2026-04-28T10:00:00.123Z # fractional seconds also accepted
|
||||
go run ./cmd/mxgw-go galaxy-watch -plaintext -limit 5
|
||||
```
|
||||
|
||||
|
||||
+591
-13
@@ -6,6 +6,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
@@ -89,10 +91,26 @@ 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 "bench-read-bulk":
|
||||
return runBenchReadBulk(ctx, args[1:], stdout, stderr)
|
||||
case "write":
|
||||
return runWrite(ctx, args[1:], stdout, stderr)
|
||||
case "stream-events":
|
||||
return runStreamEvents(ctx, args[1:], stdout, stderr)
|
||||
case "stream-alarms":
|
||||
return runStreamAlarms(ctx, args[1:], stdout, stderr)
|
||||
case "acknowledge-alarm":
|
||||
return runAcknowledgeAlarm(ctx, args[1:], stdout, stderr)
|
||||
case "smoke":
|
||||
return runSmoke(ctx, args[1:], stdout, stderr)
|
||||
case "galaxy-test-connection":
|
||||
@@ -103,6 +121,8 @@ func runWithIO(ctx context.Context, args []string, stdout, stderr io.Writer) err
|
||||
return runGalaxyDiscover(ctx, args[1:], stdout, stderr)
|
||||
case "galaxy-watch":
|
||||
return runGalaxyWatch(ctx, args[1:], stdout, stderr)
|
||||
case "batch":
|
||||
return runBatch(ctx, os.Stdin, stdout, stderr)
|
||||
default:
|
||||
writeUsage(stderr)
|
||||
return fmt.Errorf("unknown command %q", args[0])
|
||||
@@ -331,6 +351,11 @@ func runUnsubscribeBulk(ctx context.Context, args []string, stdout, stderr io.Wr
|
||||
return errors.New("session-id and item-handles are required")
|
||||
}
|
||||
|
||||
handles, err := parseInt32List(*itemHandles)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client, options, err := dialForCommand(ctx, common)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -338,10 +363,370 @@ func runUnsubscribeBulk(ctx context.Context, args []string, stdout, stderr io.Wr
|
||||
defer client.Close()
|
||||
|
||||
session := mxgateway.NewSessionForID(client, *sessionID)
|
||||
results, err := session.UnsubscribeBulk(ctx, int32(*serverHandle), parseInt32List(*itemHandles))
|
||||
results, err := session.UnsubscribeBulk(ctx, int32(*serverHandle), handles)
|
||||
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)
|
||||
}
|
||||
|
||||
func runWrite2Bulk(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||
return runWriteBulkVariant(ctx, args, stdout, stderr, "write2-bulk", true)
|
||||
}
|
||||
|
||||
func runWriteSecuredBulk(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||
return runWriteBulkVariant(ctx, args, stdout, stderr, "write-secured-bulk", false)
|
||||
}
|
||||
|
||||
func runWriteSecured2Bulk(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||
return runWriteBulkVariant(ctx, args, stdout, stderr, "write-secured2-bulk", true)
|
||||
}
|
||||
|
||||
// runWriteBulkVariant shares the flag-parsing + entry-build skeleton across
|
||||
// the four bulk-write families. command selects which of the four routes
|
||||
// runs; withTimestamp adds a --timestamp-value flag for the Write2 / Secured2
|
||||
// variants. Secured-only flags (--current-user-id / --verifier-user-id) are
|
||||
// only registered for the secured variants and the non-secured -user-id flag
|
||||
// is only registered for Write/Write2, so a wrong-variant flag becomes a
|
||||
// clean "flag provided but not defined" error instead of silently no-op'ing.
|
||||
func runWriteBulkVariant(ctx context.Context, args []string, stdout, stderr io.Writer, command string, withTimestamp bool) error {
|
||||
secured := command == "write-secured-bulk" || command == "write-secured2-bulk"
|
||||
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)")
|
||||
var userID, currentUserID, verifierUserID *int
|
||||
if secured {
|
||||
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)")
|
||||
} else {
|
||||
userID = flags.Int("user-id", 0, "MXAccess user id (Write/Write2 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)
|
||||
}
|
||||
return writeWriteBulkOutput(stdout, *jsonOutput, command, options, results, err)
|
||||
}
|
||||
|
||||
// runBenchReadBulk drives the cross-language ReadBulk stress benchmark from Go:
|
||||
// opens its own session, subscribes to bulk-size tags so the worker value cache
|
||||
// populates from real OnDataChange events, runs ReadBulk in a tight loop for
|
||||
// duration-seconds with per-call timing, and emits the shared JSON schema the
|
||||
// scripts/bench-read-bulk.ps1 driver collates across all five clients.
|
||||
func runBenchReadBulk(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||
flags := flag.NewFlagSet("bench-read-bulk", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
common := bindCommonFlags(flags)
|
||||
jsonOutput := flags.Bool("json", false, "write JSON output")
|
||||
clientName := flags.String("client-name", "mxgw-go-bench", "session client name")
|
||||
durationSeconds := flags.Int("duration-seconds", 30, "steady-state measurement window in seconds")
|
||||
warmupSeconds := flags.Int("warmup-seconds", 3, "warm-up window before measurement, in seconds")
|
||||
bulkSize := flags.Int("bulk-size", 6, "tags per ReadBulk call")
|
||||
tagStart := flags.Int("tag-start", 1, "first machine number")
|
||||
tagPrefix := flags.String("tag-prefix", "TestMachine_", "tag prefix (machine number appended as %03d)")
|
||||
tagAttribute := flags.String("tag-attribute", "TestChangingInt", "attribute appended to each tag prefix")
|
||||
timeoutMs := flags.Int("timeout-ms", 1500, "per-tag snapshot timeout in milliseconds")
|
||||
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if *bulkSize < 1 {
|
||||
return errors.New("bulk-size must be positive")
|
||||
}
|
||||
if *durationSeconds < 1 {
|
||||
return errors.New("duration-seconds must be positive")
|
||||
}
|
||||
|
||||
tags := make([]string, *bulkSize)
|
||||
for i := 0; i < *bulkSize; i++ {
|
||||
tags[i] = fmt.Sprintf("%s%03d.%s", *tagPrefix, *tagStart+i, *tagAttribute)
|
||||
}
|
||||
|
||||
client, options, err := dialForCommand(ctx, common)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
session, err := client.OpenSession(ctx, mxgateway.OpenSessionOptions{ClientSessionName: *clientName})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_, _ = session.Close(context.Background())
|
||||
}()
|
||||
|
||||
serverHandle, err := session.Register(ctx, *clientName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
subscribeResults, err := session.SubscribeBulk(ctx, serverHandle, tags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
itemHandles := make([]int32, 0, len(subscribeResults))
|
||||
for _, result := range subscribeResults {
|
||||
if result.GetWasSuccessful() {
|
||||
itemHandles = append(itemHandles, result.GetItemHandle())
|
||||
}
|
||||
}
|
||||
defer func() {
|
||||
if len(itemHandles) > 0 {
|
||||
_, _ = session.UnsubscribeBulk(context.Background(), serverHandle, itemHandles)
|
||||
}
|
||||
}()
|
||||
|
||||
// Warm-up: drive identical calls so any first-call JIT / connection-pool
|
||||
// setup is amortised before the measurement window opens. Honor ctx so
|
||||
// Ctrl+C or a parent-cancel (e.g. the cross-language bench driver killing
|
||||
// the child early) exits promptly rather than spinning failing calls until
|
||||
// the wall-clock deadline.
|
||||
warmupDeadline := time.Now().Add(time.Duration(*warmupSeconds) * time.Second)
|
||||
timeout := time.Duration(*timeoutMs) * time.Millisecond
|
||||
for time.Now().Before(warmupDeadline) && ctx.Err() == nil {
|
||||
_, _ = session.ReadBulk(ctx, serverHandle, tags, timeout)
|
||||
}
|
||||
|
||||
// Steady state: per-call latency captured via time.Now() deltas. Same ctx
|
||||
// guard as warm-up; on cancel we stop the loop and report the truncated
|
||||
// window faithfully.
|
||||
latenciesMs := make([]float64, 0, 65536)
|
||||
var totalReadResults int64
|
||||
var cachedReadResults int64
|
||||
var successfulCalls, failedCalls int
|
||||
steadyStart := time.Now()
|
||||
steadyDeadline := steadyStart.Add(time.Duration(*durationSeconds) * time.Second)
|
||||
|
||||
for time.Now().Before(steadyDeadline) && ctx.Err() == nil {
|
||||
callStart := time.Now()
|
||||
results, err := session.ReadBulk(ctx, serverHandle, tags, timeout)
|
||||
elapsed := time.Since(callStart)
|
||||
latenciesMs = append(latenciesMs, float64(elapsed.Nanoseconds())/1e6)
|
||||
if err != nil {
|
||||
failedCalls++
|
||||
continue
|
||||
}
|
||||
successfulCalls++
|
||||
for _, r := range results {
|
||||
totalReadResults++
|
||||
if r.GetWasCached() {
|
||||
cachedReadResults++
|
||||
}
|
||||
}
|
||||
}
|
||||
steadyElapsed := time.Since(steadyStart)
|
||||
totalCalls := successfulCalls + failedCalls
|
||||
|
||||
callsPerSecond := 0.0
|
||||
if steadyElapsed.Seconds() > 0 {
|
||||
callsPerSecond = float64(totalCalls) / steadyElapsed.Seconds()
|
||||
}
|
||||
|
||||
stats := map[string]any{
|
||||
"language": "go",
|
||||
"command": "bench-read-bulk",
|
||||
"endpoint": options.Endpoint,
|
||||
"clientName": *clientName,
|
||||
"bulkSize": *bulkSize,
|
||||
"durationSeconds": *durationSeconds,
|
||||
"warmupSeconds": *warmupSeconds,
|
||||
"durationMs": steadyElapsed.Milliseconds(),
|
||||
"tags": tags,
|
||||
"totalCalls": totalCalls,
|
||||
"successfulCalls": successfulCalls,
|
||||
"failedCalls": failedCalls,
|
||||
"totalReadResults": totalReadResults,
|
||||
"cachedReadResults": cachedReadResults,
|
||||
"callsPerSecond": roundTo(callsPerSecond, 2),
|
||||
"latencyMs": percentileSummary(latenciesMs),
|
||||
}
|
||||
if *jsonOutput {
|
||||
return writeJSON(stdout, stats)
|
||||
}
|
||||
fmt.Fprintln(stdout, callsPerSecond)
|
||||
return nil
|
||||
}
|
||||
|
||||
// percentileSummary returns the same { p50, p95, p99, max, mean } shape every
|
||||
// language bench emits, rounded to 3 decimal places so the PowerShell driver
|
||||
// sees one schema across all five clients.
|
||||
func percentileSummary(sample []float64) map[string]float64 {
|
||||
if len(sample) == 0 {
|
||||
return map[string]float64{"p50": 0, "p95": 0, "p99": 0, "max": 0, "mean": 0}
|
||||
}
|
||||
sorted := append([]float64(nil), sample...)
|
||||
sort.Float64s(sorted)
|
||||
mean := 0.0
|
||||
max := sorted[len(sorted)-1]
|
||||
for _, v := range sample {
|
||||
mean += v
|
||||
}
|
||||
mean /= float64(len(sample))
|
||||
return map[string]float64{
|
||||
"p50": roundTo(percentile(sorted, 0.50), 3),
|
||||
"p95": roundTo(percentile(sorted, 0.95), 3),
|
||||
"p99": roundTo(percentile(sorted, 0.99), 3),
|
||||
"max": roundTo(max, 3),
|
||||
"mean": roundTo(mean, 3),
|
||||
}
|
||||
}
|
||||
|
||||
// percentile uses nearest-rank with linear interpolation; matches the .NET
|
||||
// implementation so cross-language comparisons are apples-to-apples.
|
||||
func percentile(sorted []float64, quantile float64) float64 {
|
||||
if len(sorted) == 0 {
|
||||
return 0
|
||||
}
|
||||
if len(sorted) == 1 {
|
||||
return sorted[0]
|
||||
}
|
||||
rank := quantile * float64(len(sorted)-1)
|
||||
lower := int(rank)
|
||||
upper := lower + 1
|
||||
if upper >= len(sorted) {
|
||||
return sorted[lower]
|
||||
}
|
||||
fraction := rank - float64(lower)
|
||||
return sorted[lower] + (sorted[upper]-sorted[lower])*fraction
|
||||
}
|
||||
|
||||
func roundTo(value float64, digits int) float64 {
|
||||
shift := 1.0
|
||||
for i := 0; i < digits; i++ {
|
||||
shift *= 10
|
||||
}
|
||||
return float64(int64(value*shift+0.5)) / shift
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -399,8 +784,15 @@ func runStreamEvents(ctx context.Context, args []string, stdout, stderr io.Write
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Mirror runGalaxyWatch so Ctrl+C on a long-running stream-events command
|
||||
// cancels the gRPC stream cleanly (the gateway sees codes.Canceled rather
|
||||
// than a torn TCP connection) and the deferred subscription.Close() /
|
||||
// client.Close() actually run.
|
||||
signalCtx, stopSignals := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
|
||||
defer stopSignals()
|
||||
|
||||
session := mxgateway.NewSessionForID(client, *sessionID)
|
||||
streamCtx, cancelStream := context.WithCancel(ctx)
|
||||
streamCtx, cancelStream := context.WithCancel(signalCtx)
|
||||
defer cancelStream()
|
||||
subscription, err := session.SubscribeEventsAfter(streamCtx, *after)
|
||||
if err != nil {
|
||||
@@ -428,6 +820,119 @@ func runStreamEvents(ctx context.Context, args []string, stdout, stderr io.Write
|
||||
return nil
|
||||
}
|
||||
|
||||
func runStreamAlarms(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||
flags := flag.NewFlagSet("stream-alarms", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
common := bindCommonFlags(flags)
|
||||
jsonOutput := flags.Bool("json", false, "write JSON output")
|
||||
filterPrefix := flags.String("filter-prefix", "", "alarm-reference prefix scoping the feed; empty means unscoped")
|
||||
limit := flags.Int("limit", 0, "maximum feed messages to read; 0 means unbounded")
|
||||
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client, _, err := dialForCommand(ctx, common)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Mirror runStreamEvents so Ctrl+C on a long-running stream-alarms command
|
||||
// cancels the gRPC stream cleanly (the gateway sees codes.Canceled rather
|
||||
// than a torn TCP connection) and the deferred client.Close() actually runs.
|
||||
signalCtx, stopSignals := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
|
||||
defer stopSignals()
|
||||
|
||||
streamCtx, cancelStream := context.WithCancel(signalCtx)
|
||||
defer cancelStream()
|
||||
stream, err := client.StreamAlarms(streamCtx, &mxgateway.StreamAlarmsRequest{AlarmFilterPrefix: *filterPrefix})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
count := 0
|
||||
for {
|
||||
message, err := stream.Recv()
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if *jsonOutput {
|
||||
fmt.Fprintln(stdout, string(mustMarshalProto(message)))
|
||||
} else {
|
||||
fmt.Fprintln(stdout, formatAlarmFeedMessage(message))
|
||||
}
|
||||
count++
|
||||
if *limit > 0 && count >= *limit {
|
||||
cancelStream()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// formatAlarmFeedMessage renders one AlarmFeedMessage in the CLI's plain-text
|
||||
// output style, distinguishing the active-alarm snapshot, snapshot-complete
|
||||
// sentinel, and transition cases of the message's payload oneof.
|
||||
func formatAlarmFeedMessage(message *mxgateway.AlarmFeedMessage) string {
|
||||
switch {
|
||||
case message.GetActiveAlarm() != nil:
|
||||
alarm := message.GetActiveAlarm()
|
||||
return fmt.Sprintf("active-alarm %s state=%s severity=%d", alarm.GetAlarmFullReference(), alarm.GetCurrentState(), alarm.GetSeverity())
|
||||
case message.GetSnapshotComplete():
|
||||
return "snapshot-complete"
|
||||
case message.GetTransition() != nil:
|
||||
transition := message.GetTransition()
|
||||
return fmt.Sprintf("transition %s kind=%s severity=%d", transition.GetAlarmFullReference(), transition.GetTransitionKind(), transition.GetSeverity())
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func runAcknowledgeAlarm(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||
flags := flag.NewFlagSet("acknowledge-alarm", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
common := bindCommonFlags(flags)
|
||||
jsonOutput := flags.Bool("json", false, "write JSON output")
|
||||
reference := flags.String("reference", "", "full alarm reference to acknowledge")
|
||||
comment := flags.String("comment", "", "operator acknowledge comment")
|
||||
operator := flags.String("operator", "", "operator user performing the acknowledge")
|
||||
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if *reference == "" {
|
||||
return errors.New("reference is required")
|
||||
}
|
||||
|
||||
client, options, err := dialForCommand(ctx, common)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
reply, err := client.AcknowledgeAlarm(ctx, &mxgateway.AcknowledgeAlarmRequest{
|
||||
AlarmFullReference: *reference,
|
||||
Comment: *comment,
|
||||
OperatorUser: *operator,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if *jsonOutput {
|
||||
return writeJSON(stdout, commandReplyOutput{
|
||||
Command: "acknowledge-alarm",
|
||||
Options: options,
|
||||
Reply: mustMarshalProto(reply),
|
||||
})
|
||||
}
|
||||
|
||||
fmt.Fprintln(stdout, reply.GetHresult())
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSmoke(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||
flags := flag.NewFlagSet("smoke", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
@@ -514,7 +1019,7 @@ func parseStringList(value string) []string {
|
||||
return items
|
||||
}
|
||||
|
||||
func parseInt32List(value string) []int32 {
|
||||
func parseInt32List(value string) ([]int32, error) {
|
||||
parts := strings.Split(value, ",")
|
||||
items := make([]int32, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
@@ -524,11 +1029,11 @@ func parseInt32List(value string) []int32 {
|
||||
}
|
||||
parsed, err := strconv.ParseInt(item, 10, 32)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
return nil, fmt.Errorf("invalid item handle %q: %w", item, err)
|
||||
}
|
||||
items = append(items, int32(parsed))
|
||||
}
|
||||
return items
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func bindCommonFlags(flags *flag.FlagSet) *commonOptions {
|
||||
@@ -583,31 +1088,31 @@ func parseValue(valueType, valueText string) (*mxgateway.MxValue, error) {
|
||||
case "bool":
|
||||
value, err := strconv.ParseBool(valueText)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("invalid -value for -type %s: %q: %w", valueType, valueText, err)
|
||||
}
|
||||
return mxgateway.BoolValue(value), nil
|
||||
case "int32":
|
||||
value, err := strconv.ParseInt(valueText, 10, 32)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("invalid -value for -type %s: %q: %w", valueType, valueText, err)
|
||||
}
|
||||
return mxgateway.Int32Value(int32(value)), nil
|
||||
case "int64":
|
||||
value, err := strconv.ParseInt(valueText, 10, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("invalid -value for -type %s: %q: %w", valueType, valueText, err)
|
||||
}
|
||||
return mxgateway.Int64Value(value), nil
|
||||
case "float":
|
||||
value, err := strconv.ParseFloat(valueText, 32)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("invalid -value for -type %s: %q: %w", valueType, valueText, err)
|
||||
}
|
||||
return mxgateway.FloatValue(float32(value)), nil
|
||||
case "double":
|
||||
value, err := strconv.ParseFloat(valueText, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("invalid -value for -type %s: %q: %w", valueType, valueText, err)
|
||||
}
|
||||
return mxgateway.DoubleValue(value), nil
|
||||
case "string":
|
||||
@@ -647,6 +1152,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("", " ")
|
||||
@@ -665,8 +1200,44 @@ type protojsonMessage interface {
|
||||
ProtoReflect() protoreflect.Message
|
||||
}
|
||||
|
||||
// batchEOR is the end-of-result sentinel emitted to stdout after every command
|
||||
// in batch mode, regardless of success or failure.
|
||||
const batchEOR = "__MXGW_BATCH_EOR__"
|
||||
|
||||
// runBatch reads one command line at a time from in, dispatches each via the
|
||||
// normal runWithIO routing, and writes a batchEOR sentinel to stdout after
|
||||
// every result. Errors are serialised as JSON to stdout (not stderr) so the
|
||||
// harness can parse them without interleaving stderr. The loop never terminates
|
||||
// on command error; only stdin EOF (or an empty line) ends the session.
|
||||
func runBatch(ctx context.Context, in io.Reader, stdout, stderr io.Writer) error {
|
||||
bw := bufio.NewWriter(stdout)
|
||||
scanner := bufio.NewScanner(in)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
break
|
||||
}
|
||||
args := strings.Fields(line)
|
||||
if len(args) == 0 {
|
||||
continue
|
||||
}
|
||||
if err := runWithIO(ctx, args, bw, stderr); err != nil {
|
||||
// Write error as JSON to stdout (bw) so the harness sees it in the
|
||||
// same stream as normal output, framed by the EOR sentinel.
|
||||
errPayload := map[string]string{
|
||||
"error": err.Error(),
|
||||
"type": "error",
|
||||
}
|
||||
_ = writeJSON(bw, errPayload)
|
||||
}
|
||||
_, _ = fmt.Fprintln(bw, batchEOR)
|
||||
_ = bw.Flush()
|
||||
}
|
||||
return scanner.Err()
|
||||
}
|
||||
|
||||
func writeUsage(writer io.Writer) {
|
||||
fmt.Fprintln(writer, "usage: mxgw-go <version|open-session|close-session|register|add-item|advise|subscribe-bulk|unsubscribe-bulk|write|stream-events|smoke|galaxy-test-connection|galaxy-last-deploy|galaxy-discover|galaxy-watch>")
|
||||
fmt.Fprintln(writer, "usage: mxgw-go <version|open-session|close-session|register|add-item|advise|subscribe-bulk|unsubscribe-bulk|read-bulk|write-bulk|write2-bulk|write-secured-bulk|write-secured2-bulk|bench-read-bulk|write|stream-events|stream-alarms|acknowledge-alarm|smoke|galaxy-test-connection|galaxy-last-deploy|galaxy-discover|galaxy-watch|batch>")
|
||||
}
|
||||
|
||||
func dialGalaxyForCommand(ctx context.Context, common *commonOptions) (*mxgateway.GalaxyClient, commonOptions, error) {
|
||||
@@ -798,7 +1369,7 @@ func runGalaxyWatch(ctx context.Context, args []string, stdout, stderr io.Writer
|
||||
flags.SetOutput(stderr)
|
||||
common := bindCommonFlags(flags)
|
||||
jsonOutput := flags.Bool("json", false, "write JSON output")
|
||||
lastSeen := flags.String("last-seen-deploy-time", "", "RFC3339 timestamp; when set, suppresses the bootstrap event")
|
||||
lastSeen := flags.String("last-seen-deploy-time", "", "RFC 3339 timestamp (with optional fractional seconds); when set, suppresses the bootstrap event")
|
||||
limit := flags.Int("limit", 0, "maximum events to read; 0 means unbounded (Ctrl+C to stop)")
|
||||
|
||||
if err := flags.Parse(args); err != nil {
|
||||
@@ -807,7 +1378,11 @@ func runGalaxyWatch(ctx context.Context, args []string, stdout, stderr io.Writer
|
||||
|
||||
var lastSeenPtr *time.Time
|
||||
if *lastSeen != "" {
|
||||
parsed, err := time.Parse(time.RFC3339, *lastSeen)
|
||||
// Use RFC3339Nano so values copy-pasted from galaxy-watch -json output
|
||||
// (which formatDeployEvent emits with fractional seconds) round-trip;
|
||||
// RFC3339Nano also accepts whole-second values, so the layout switch is
|
||||
// strictly broader than the previous time.RFC3339 parse.
|
||||
parsed, err := time.Parse(time.RFC3339Nano, *lastSeen)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid -last-seen-deploy-time: %w", err)
|
||||
}
|
||||
@@ -850,6 +1425,9 @@ func runGalaxyWatch(ctx context.Context, args []string, stdout, stderr io.Writer
|
||||
count++
|
||||
if *limit > 0 && count >= *limit {
|
||||
cancelStream()
|
||||
// Allow goroutine to drain.
|
||||
for range events {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
case streamErr, ok := <-errs:
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -56,3 +57,195 @@ func TestParseValueBuildsTypedValue(t *testing.T) {
|
||||
t.Fatalf("int32 value = %d, want 123", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInt32ListParsesValidTokens(t *testing.T) {
|
||||
items, err := parseInt32List("1, 2 ,3")
|
||||
if err != nil {
|
||||
t.Fatalf("parseInt32List() error = %v", err)
|
||||
}
|
||||
want := []int32{1, 2, 3}
|
||||
if len(items) != len(want) {
|
||||
t.Fatalf("parseInt32List() = %v, want %v", items, want)
|
||||
}
|
||||
for i := range want {
|
||||
if items[i] != want[i] {
|
||||
t.Fatalf("parseInt32List()[%d] = %d, want %d", i, items[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInt32ListReturnsErrorOnMalformedToken(t *testing.T) {
|
||||
items, err := parseInt32List("1,foo")
|
||||
if err == nil {
|
||||
t.Fatalf("parseInt32List() error = nil, want a parse error; items = %v", items)
|
||||
}
|
||||
if items != nil {
|
||||
t.Fatalf("parseInt32List() items = %v, want nil on error", items)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "foo") {
|
||||
t.Fatalf("parseInt32List() error = %q, want it to name the bad token", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseValueWrapsStrconvErrorWithFlagContext pins Client.Go-017: each
|
||||
// typed branch of parseValue wraps the bare strconv error with `%w` and names
|
||||
// the offending flag and value, so the CLI surface is consistent with
|
||||
// parseInt32List ("invalid item handle %q: %w") and parseRfc3339Timestamp
|
||||
// ("invalid RFC 3339 timestamp %q: %w").
|
||||
func TestParseValueWrapsStrconvErrorWithFlagContext(t *testing.T) {
|
||||
cases := []struct {
|
||||
valueType string
|
||||
valueText string
|
||||
}{
|
||||
{"bool", "notabool"},
|
||||
{"int32", "foo"},
|
||||
{"int64", "foo"},
|
||||
{"float", "notafloat"},
|
||||
{"double", "notadouble"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.valueType, func(t *testing.T) {
|
||||
_, err := parseValue(tc.valueType, tc.valueText)
|
||||
if err == nil {
|
||||
t.Fatalf("parseValue(%q, %q) error = nil, want a parse error", tc.valueType, tc.valueText)
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "-value") {
|
||||
t.Fatalf("parseValue() error = %q, want it to name the -value flag", msg)
|
||||
}
|
||||
if !strings.Contains(msg, tc.valueType) {
|
||||
t.Fatalf("parseValue() error = %q, want it to name the type %q", msg, tc.valueType)
|
||||
}
|
||||
if !strings.Contains(msg, tc.valueText) {
|
||||
t.Fatalf("parseValue() error = %q, want it to name the bad token %q", msg, tc.valueText)
|
||||
}
|
||||
// errors.Unwrap must reach the underlying strconv error so callers
|
||||
// can still errors.Is/As against strconv.ErrSyntax if they care.
|
||||
if errors.Unwrap(err) == nil {
|
||||
t.Fatalf("parseValue() returned unwrapped error %q, want a %%w wrap", msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunWriteBulkVariantGatesSecuredFlags pins the Client.Go-015 fix at the
|
||||
// CLI surface: secured-only flags (-current-user-id, -verifier-user-id) must
|
||||
// not be registered on the non-secured variants, and -user-id must not be
|
||||
// registered on the secured variants. The flag package rejects an unknown
|
||||
// flag with "flag provided but not defined", which a future refactor that
|
||||
// re-broadens flag registration would silently undo without this test.
|
||||
func TestRunWriteBulkVariantGatesSecuredFlags(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
command string
|
||||
flag string
|
||||
}{
|
||||
{"write-bulk rejects -current-user-id", "write-bulk", "-current-user-id"},
|
||||
{"write-bulk rejects -verifier-user-id", "write-bulk", "-verifier-user-id"},
|
||||
{"write2-bulk rejects -current-user-id", "write2-bulk", "-current-user-id"},
|
||||
{"write-secured-bulk rejects -user-id", "write-secured-bulk", "-user-id"},
|
||||
{"write-secured2-bulk rejects -user-id", "write-secured2-bulk", "-user-id"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
err := runWithIO(t.Context(), []string{
|
||||
tc.command,
|
||||
"-plaintext",
|
||||
"-session-id", "sess",
|
||||
"-server-handle", "1",
|
||||
"-item-handles", "1",
|
||||
"-values", "1",
|
||||
tc.flag, "1",
|
||||
}, &stdout, &stderr)
|
||||
if err == nil {
|
||||
t.Fatalf("runWithIO(%s %s) error = nil, want flag-not-defined", tc.command, tc.flag)
|
||||
}
|
||||
combined := err.Error() + stderr.String()
|
||||
if !strings.Contains(combined, "flag provided but not defined") {
|
||||
t.Fatalf("runWithIO(%s %s) error/stderr = %q, want 'flag provided but not defined'", tc.command, tc.flag, combined)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunReadBulkRejectsMissingArgs pins the "session-id and items are
|
||||
// required" validation in runReadBulk before any network dial happens.
|
||||
func TestRunReadBulkRejectsMissingArgs(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{"no flags", []string{"read-bulk"}},
|
||||
{"missing items", []string{"read-bulk", "-plaintext", "-session-id", "sess"}},
|
||||
{"missing session-id", []string{"read-bulk", "-plaintext", "-items", "Tag.Attr"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
err := runWithIO(t.Context(), tc.args, &stdout, &stderr)
|
||||
if err == nil {
|
||||
t.Fatalf("runWithIO(%v) error = nil, want validation error", tc.args)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "session-id and items are required") {
|
||||
t.Fatalf("runWithIO(%v) error = %q, want 'session-id and items are required'", tc.args, err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunBenchReadBulkRejectsNonPositiveBulkSize pins the bulk-size>=1 check
|
||||
// at runBenchReadBulk's flag-parsing stage so a future refactor cannot drop
|
||||
// the positivity guard without breaking this test.
|
||||
func TestRunBenchReadBulkRejectsNonPositiveBulkSize(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
err := runWithIO(t.Context(), []string{
|
||||
"bench-read-bulk",
|
||||
"-plaintext",
|
||||
"-bulk-size", "0",
|
||||
}, &stdout, &stderr)
|
||||
if err == nil {
|
||||
t.Fatalf("runWithIO(bench-read-bulk -bulk-size 0) error = nil, want positivity error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "bulk-size must be positive") {
|
||||
t.Fatalf("runWithIO error = %q, want 'bulk-size must be positive'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunBenchReadBulkRejectsNonPositiveDuration pins the duration-seconds>=1
|
||||
// check at runBenchReadBulk's flag-parsing stage.
|
||||
func TestRunBenchReadBulkRejectsNonPositiveDuration(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
err := runWithIO(t.Context(), []string{
|
||||
"bench-read-bulk",
|
||||
"-plaintext",
|
||||
"-duration-seconds", "0",
|
||||
}, &stdout, &stderr)
|
||||
if err == nil {
|
||||
t.Fatalf("runWithIO(bench-read-bulk -duration-seconds 0) error = nil, want positivity error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "duration-seconds must be positive") {
|
||||
t.Fatalf("runWithIO error = %q, want 'duration-seconds must be positive'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunWriteBulkVariantRejectsMismatchedHandlesAndValues pins the explicit
|
||||
// "item-handles count ... does not match values count ..." check at the CLI
|
||||
// surface so the validation error surfaces before any dial happens.
|
||||
func TestRunWriteBulkVariantRejectsMismatchedHandlesAndValues(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
err := runWithIO(t.Context(), []string{
|
||||
"write-bulk",
|
||||
"-plaintext",
|
||||
"-session-id", "sess",
|
||||
"-server-handle", "1",
|
||||
"-item-handles", "1,2,3",
|
||||
"-values", "10,20",
|
||||
}, &stdout, &stderr)
|
||||
if err == nil {
|
||||
t.Fatalf("runWithIO(write-bulk mismatched counts) error = nil, want mismatch error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "item-handles count") || !strings.Contains(err.Error(), "values count") {
|
||||
t.Fatalf("runWithIO error = %q, want 'item-handles count ... values count ...'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -687,18 +687,32 @@ func (x *GalaxyObject) GetAttributes() []*GalaxyAttribute {
|
||||
}
|
||||
|
||||
type GalaxyAttribute struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
AttributeName string `protobuf:"bytes,1,opt,name=attribute_name,json=attributeName,proto3" json:"attribute_name,omitempty"`
|
||||
FullTagReference string `protobuf:"bytes,2,opt,name=full_tag_reference,json=fullTagReference,proto3" json:"full_tag_reference,omitempty"`
|
||||
MxDataType int32 `protobuf:"varint,3,opt,name=mx_data_type,json=mxDataType,proto3" json:"mx_data_type,omitempty"`
|
||||
DataTypeName string `protobuf:"bytes,4,opt,name=data_type_name,json=dataTypeName,proto3" json:"data_type_name,omitempty"`
|
||||
IsArray bool `protobuf:"varint,5,opt,name=is_array,json=isArray,proto3" json:"is_array,omitempty"`
|
||||
ArrayDimension int32 `protobuf:"varint,6,opt,name=array_dimension,json=arrayDimension,proto3" json:"array_dimension,omitempty"`
|
||||
ArrayDimensionPresent bool `protobuf:"varint,7,opt,name=array_dimension_present,json=arrayDimensionPresent,proto3" json:"array_dimension_present,omitempty"`
|
||||
MxAttributeCategory int32 `protobuf:"varint,8,opt,name=mx_attribute_category,json=mxAttributeCategory,proto3" json:"mx_attribute_category,omitempty"`
|
||||
SecurityClassification int32 `protobuf:"varint,9,opt,name=security_classification,json=securityClassification,proto3" json:"security_classification,omitempty"`
|
||||
IsHistorized bool `protobuf:"varint,10,opt,name=is_historized,json=isHistorized,proto3" json:"is_historized,omitempty"`
|
||||
IsAlarm bool `protobuf:"varint,11,opt,name=is_alarm,json=isAlarm,proto3" json:"is_alarm,omitempty"`
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
AttributeName string `protobuf:"bytes,1,opt,name=attribute_name,json=attributeName,proto3" json:"attribute_name,omitempty"`
|
||||
FullTagReference string `protobuf:"bytes,2,opt,name=full_tag_reference,json=fullTagReference,proto3" json:"full_tag_reference,omitempty"`
|
||||
// Raw Galaxy SQL `dbo.data_type` identifier, passed through unchanged.
|
||||
// This is NOT a member of `mxaccess_gateway.v1.MxDataType` — Galaxy's
|
||||
// type enumeration is distinct from MXAccess's wire data-type enum and
|
||||
// the two must not be cast or compared. The GalaxyRepository service is
|
||||
// metadata-only and deliberately does not share types with
|
||||
// mxaccess_gateway.proto. See docs/GalaxyRepository.md.
|
||||
MxDataType int32 `protobuf:"varint,3,opt,name=mx_data_type,json=mxDataType,proto3" json:"mx_data_type,omitempty"`
|
||||
// Human-readable name from Galaxy's `dbo.data_type` table (e.g. "Float",
|
||||
// "Integer", "Boolean"). Free-form Galaxy text; not a stable enum.
|
||||
DataTypeName string `protobuf:"bytes,4,opt,name=data_type_name,json=dataTypeName,proto3" json:"data_type_name,omitempty"`
|
||||
IsArray bool `protobuf:"varint,5,opt,name=is_array,json=isArray,proto3" json:"is_array,omitempty"`
|
||||
ArrayDimension int32 `protobuf:"varint,6,opt,name=array_dimension,json=arrayDimension,proto3" json:"array_dimension,omitempty"`
|
||||
ArrayDimensionPresent bool `protobuf:"varint,7,opt,name=array_dimension_present,json=arrayDimensionPresent,proto3" json:"array_dimension_present,omitempty"`
|
||||
// Raw Galaxy SQL attribute-category identifier, passed through unchanged.
|
||||
// Galaxy-specific; not mapped to any gateway enum. See
|
||||
// docs/GalaxyRepository.md.
|
||||
MxAttributeCategory int32 `protobuf:"varint,8,opt,name=mx_attribute_category,json=mxAttributeCategory,proto3" json:"mx_attribute_category,omitempty"`
|
||||
// Raw Galaxy SQL security-classification identifier, passed through
|
||||
// unchanged. Galaxy-specific; not mapped to any gateway enum. See
|
||||
// docs/GalaxyRepository.md.
|
||||
SecurityClassification int32 `protobuf:"varint,9,opt,name=security_classification,json=securityClassification,proto3" json:"security_classification,omitempty"`
|
||||
IsHistorized bool `protobuf:"varint,10,opt,name=is_historized,json=isHistorized,proto3" json:"is_historized,omitempty"`
|
||||
IsAlarm bool `protobuf:"varint,11,opt,name=is_alarm,json=isAlarm,proto3" json:"is_alarm,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,12 +19,12 @@ import (
|
||||
const _ = grpc.SupportPackageIsVersion9
|
||||
|
||||
const (
|
||||
MxAccessGateway_OpenSession_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/OpenSession"
|
||||
MxAccessGateway_CloseSession_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/CloseSession"
|
||||
MxAccessGateway_Invoke_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/Invoke"
|
||||
MxAccessGateway_StreamEvents_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/StreamEvents"
|
||||
MxAccessGateway_AcknowledgeAlarm_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/AcknowledgeAlarm"
|
||||
MxAccessGateway_QueryActiveAlarms_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/QueryActiveAlarms"
|
||||
MxAccessGateway_OpenSession_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/OpenSession"
|
||||
MxAccessGateway_CloseSession_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/CloseSession"
|
||||
MxAccessGateway_Invoke_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/Invoke"
|
||||
MxAccessGateway_StreamEvents_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/StreamEvents"
|
||||
MxAccessGateway_AcknowledgeAlarm_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/AcknowledgeAlarm"
|
||||
MxAccessGateway_StreamAlarms_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/StreamAlarms"
|
||||
)
|
||||
|
||||
// MxAccessGatewayClient is the client API for MxAccessGateway service.
|
||||
@@ -38,7 +38,12 @@ type MxAccessGatewayClient interface {
|
||||
Invoke(ctx context.Context, in *MxCommandRequest, opts ...grpc.CallOption) (*MxCommandReply, error)
|
||||
StreamEvents(ctx context.Context, in *StreamEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[MxEvent], error)
|
||||
AcknowledgeAlarm(ctx context.Context, in *AcknowledgeAlarmRequest, opts ...grpc.CallOption) (*AcknowledgeAlarmReply, error)
|
||||
QueryActiveAlarms(ctx context.Context, in *QueryActiveAlarmsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ActiveAlarmSnapshot], error)
|
||||
// Session-less central alarm feed. The stream opens with the current
|
||||
// active-alarm snapshot (one `active_alarm` per alarm), then a single
|
||||
// `snapshot_complete`, then a `transition` for every subsequent change.
|
||||
// Served by the gateway's always-on alarm monitor; any number of clients
|
||||
// fan out from the single monitor without opening a worker session.
|
||||
StreamAlarms(ctx context.Context, in *StreamAlarmsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[AlarmFeedMessage], error)
|
||||
}
|
||||
|
||||
type mxAccessGatewayClient struct {
|
||||
@@ -108,13 +113,13 @@ func (c *mxAccessGatewayClient) AcknowledgeAlarm(ctx context.Context, in *Acknow
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *mxAccessGatewayClient) QueryActiveAlarms(ctx context.Context, in *QueryActiveAlarmsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ActiveAlarmSnapshot], error) {
|
||||
func (c *mxAccessGatewayClient) StreamAlarms(ctx context.Context, in *StreamAlarmsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[AlarmFeedMessage], error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
stream, err := c.cc.NewStream(ctx, &MxAccessGateway_ServiceDesc.Streams[1], MxAccessGateway_QueryActiveAlarms_FullMethodName, cOpts...)
|
||||
stream, err := c.cc.NewStream(ctx, &MxAccessGateway_ServiceDesc.Streams[1], MxAccessGateway_StreamAlarms_FullMethodName, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x := &grpc.GenericClientStream[QueryActiveAlarmsRequest, ActiveAlarmSnapshot]{ClientStream: stream}
|
||||
x := &grpc.GenericClientStream[StreamAlarmsRequest, AlarmFeedMessage]{ClientStream: stream}
|
||||
if err := x.ClientStream.SendMsg(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -125,7 +130,7 @@ func (c *mxAccessGatewayClient) QueryActiveAlarms(ctx context.Context, in *Query
|
||||
}
|
||||
|
||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||
type MxAccessGateway_QueryActiveAlarmsClient = grpc.ServerStreamingClient[ActiveAlarmSnapshot]
|
||||
type MxAccessGateway_StreamAlarmsClient = grpc.ServerStreamingClient[AlarmFeedMessage]
|
||||
|
||||
// MxAccessGatewayServer is the server API for MxAccessGateway service.
|
||||
// All implementations must embed UnimplementedMxAccessGatewayServer
|
||||
@@ -138,7 +143,12 @@ type MxAccessGatewayServer interface {
|
||||
Invoke(context.Context, *MxCommandRequest) (*MxCommandReply, error)
|
||||
StreamEvents(*StreamEventsRequest, grpc.ServerStreamingServer[MxEvent]) error
|
||||
AcknowledgeAlarm(context.Context, *AcknowledgeAlarmRequest) (*AcknowledgeAlarmReply, error)
|
||||
QueryActiveAlarms(*QueryActiveAlarmsRequest, grpc.ServerStreamingServer[ActiveAlarmSnapshot]) error
|
||||
// Session-less central alarm feed. The stream opens with the current
|
||||
// active-alarm snapshot (one `active_alarm` per alarm), then a single
|
||||
// `snapshot_complete`, then a `transition` for every subsequent change.
|
||||
// Served by the gateway's always-on alarm monitor; any number of clients
|
||||
// fan out from the single monitor without opening a worker session.
|
||||
StreamAlarms(*StreamAlarmsRequest, grpc.ServerStreamingServer[AlarmFeedMessage]) error
|
||||
mustEmbedUnimplementedMxAccessGatewayServer()
|
||||
}
|
||||
|
||||
@@ -164,8 +174,8 @@ func (UnimplementedMxAccessGatewayServer) StreamEvents(*StreamEventsRequest, grp
|
||||
func (UnimplementedMxAccessGatewayServer) AcknowledgeAlarm(context.Context, *AcknowledgeAlarmRequest) (*AcknowledgeAlarmReply, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method AcknowledgeAlarm not implemented")
|
||||
}
|
||||
func (UnimplementedMxAccessGatewayServer) QueryActiveAlarms(*QueryActiveAlarmsRequest, grpc.ServerStreamingServer[ActiveAlarmSnapshot]) error {
|
||||
return status.Error(codes.Unimplemented, "method QueryActiveAlarms not implemented")
|
||||
func (UnimplementedMxAccessGatewayServer) StreamAlarms(*StreamAlarmsRequest, grpc.ServerStreamingServer[AlarmFeedMessage]) error {
|
||||
return status.Error(codes.Unimplemented, "method StreamAlarms not implemented")
|
||||
}
|
||||
func (UnimplementedMxAccessGatewayServer) mustEmbedUnimplementedMxAccessGatewayServer() {}
|
||||
func (UnimplementedMxAccessGatewayServer) testEmbeddedByValue() {}
|
||||
@@ -271,16 +281,16 @@ func _MxAccessGateway_AcknowledgeAlarm_Handler(srv interface{}, ctx context.Cont
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _MxAccessGateway_QueryActiveAlarms_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||
m := new(QueryActiveAlarmsRequest)
|
||||
func _MxAccessGateway_StreamAlarms_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||
m := new(StreamAlarmsRequest)
|
||||
if err := stream.RecvMsg(m); err != nil {
|
||||
return err
|
||||
}
|
||||
return srv.(MxAccessGatewayServer).QueryActiveAlarms(m, &grpc.GenericServerStream[QueryActiveAlarmsRequest, ActiveAlarmSnapshot]{ServerStream: stream})
|
||||
return srv.(MxAccessGatewayServer).StreamAlarms(m, &grpc.GenericServerStream[StreamAlarmsRequest, AlarmFeedMessage]{ServerStream: stream})
|
||||
}
|
||||
|
||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||
type MxAccessGateway_QueryActiveAlarmsServer = grpc.ServerStreamingServer[ActiveAlarmSnapshot]
|
||||
type MxAccessGateway_StreamAlarmsServer = grpc.ServerStreamingServer[AlarmFeedMessage]
|
||||
|
||||
// MxAccessGateway_ServiceDesc is the grpc.ServiceDesc for MxAccessGateway service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
@@ -313,8 +323,8 @@ var MxAccessGateway_ServiceDesc = grpc.ServiceDesc{
|
||||
ServerStreams: true,
|
||||
},
|
||||
{
|
||||
StreamName: "QueryActiveAlarms",
|
||||
Handler: _MxAccessGateway_QueryActiveAlarms_Handler,
|
||||
StreamName: "StreamAlarms",
|
||||
Handler: _MxAccessGateway_StreamAlarms_Handler,
|
||||
ServerStreams: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package mxgateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// AcknowledgeAlarm acknowledges an active MXAccess alarm condition through the
|
||||
// gateway. The gateway authenticates the request against the API key's
|
||||
// invoke:alarm-ack scope and forwards the acknowledge to the worker's MXAccess
|
||||
// session; the resulting native MxStatus is returned in the reply.
|
||||
//
|
||||
// Acks are idempotent — re-acking an already-acked condition is a no-op at
|
||||
// the MxAccess layer.
|
||||
func (c *Client) AcknowledgeAlarm(ctx context.Context, req *AcknowledgeAlarmRequest) (*AcknowledgeAlarmReply, error) {
|
||||
if req == nil {
|
||||
return nil, errors.New("mxgateway: acknowledge alarm request is required")
|
||||
}
|
||||
|
||||
callCtx, cancel := c.callContext(ctx)
|
||||
defer cancel()
|
||||
|
||||
reply, err := c.raw.AcknowledgeAlarm(callCtx, req)
|
||||
if err != nil {
|
||||
return nil, &GatewayError{Op: "acknowledge alarm", Err: err}
|
||||
}
|
||||
if err := EnsureProtocolSuccess("acknowledge alarm", reply.GetProtocolStatus(), nil); err != nil {
|
||||
return reply, err
|
||||
}
|
||||
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
// StreamAlarms attaches to the gateway's central alarm feed. The stream opens
|
||||
// with one AlarmFeedMessage per currently-active alarm (the ConditionRefresh
|
||||
// snapshot), then a single snapshot-complete sentinel, then a transition for
|
||||
// every subsequent raise / acknowledge / clear. It is served by the gateway's
|
||||
// always-on alarm monitor — no worker session is opened — so any number of
|
||||
// clients may attach.
|
||||
//
|
||||
// The returned stream is owned by the caller; cancel ctx to release it.
|
||||
// Optional alarm-reference prefix scoping (req.AlarmFilterPrefix) limits the
|
||||
// stream to a sub-tree.
|
||||
func (c *Client) StreamAlarms(ctx context.Context, req *StreamAlarmsRequest) (StreamAlarmsClient, error) {
|
||||
if req == nil {
|
||||
return nil, errors.New("mxgateway: stream alarms request is required")
|
||||
}
|
||||
|
||||
stream, err := c.raw.StreamAlarms(ctx, req)
|
||||
if err != nil {
|
||||
return nil, &GatewayError{Op: "stream alarms", Err: err}
|
||||
}
|
||||
|
||||
return stream, nil
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package mxgateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/grpc/test/bufconn"
|
||||
)
|
||||
|
||||
// Pins the Go SDK surface for the alarm RPCs: AcknowledgeAlarm + StreamAlarms.
|
||||
|
||||
func TestAcknowledgeAlarmSendsRequestAndReturnsReply(t *testing.T) {
|
||||
fake := &fakeGatewayWithAlarms{
|
||||
acknowledgeReply: &pb.AcknowledgeAlarmReply{
|
||||
CorrelationId: "corr-1",
|
||||
ProtocolStatus: &pb.ProtocolStatus{
|
||||
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
|
||||
},
|
||||
Status: &pb.MxStatusProxy{
|
||||
Success: 1,
|
||||
Category: pb.MxStatusCategory_MX_STATUS_CATEGORY_OK,
|
||||
},
|
||||
},
|
||||
}
|
||||
client, cleanup := newBufconnClientWithAlarms(t, fake)
|
||||
defer cleanup()
|
||||
|
||||
reply, err := client.AcknowledgeAlarm(context.Background(), &pb.AcknowledgeAlarmRequest{
|
||||
ClientCorrelationId: "corr-1",
|
||||
AlarmFullReference: "Tank01.Level.HiHi",
|
||||
Comment: "investigating",
|
||||
OperatorUser: "alice",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AcknowledgeAlarm() error = %v", err)
|
||||
}
|
||||
if reply.GetProtocolStatus().GetCode() != pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK {
|
||||
t.Fatalf("protocol status = %v", reply.GetProtocolStatus().GetCode())
|
||||
}
|
||||
if got := fake.acknowledgeRequest.GetAlarmFullReference(); got != "Tank01.Level.HiHi" {
|
||||
t.Fatalf("captured alarm reference = %q", got)
|
||||
}
|
||||
if got := fake.acknowledgeRequest.GetComment(); got != "investigating" {
|
||||
t.Fatalf("captured comment = %q", got)
|
||||
}
|
||||
if got := fake.acknowledgeAuth; got != "Bearer test-api-key" {
|
||||
t.Fatalf("authorization metadata = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcknowledgeAlarmRejectsNilRequest(t *testing.T) {
|
||||
fake := &fakeGatewayWithAlarms{}
|
||||
client, cleanup := newBufconnClientWithAlarms(t, fake)
|
||||
defer cleanup()
|
||||
|
||||
_, err := client.AcknowledgeAlarm(context.Background(), nil)
|
||||
if err == nil {
|
||||
t.Fatalf("AcknowledgeAlarm(nil) returned no error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcknowledgeAlarmMapsUnauthenticated(t *testing.T) {
|
||||
fake := &fakeGatewayWithAlarms{
|
||||
acknowledgeError: status.Error(codes.Unauthenticated, "expired key"),
|
||||
}
|
||||
client, cleanup := newBufconnClientWithAlarms(t, fake)
|
||||
defer cleanup()
|
||||
|
||||
_, err := client.AcknowledgeAlarm(context.Background(), &pb.AcknowledgeAlarmRequest{
|
||||
AlarmFullReference: "Tank01.Level.HiHi",
|
||||
OperatorUser: "alice",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("AcknowledgeAlarm() returned no error on Unauthenticated")
|
||||
}
|
||||
var gwErr *GatewayError
|
||||
if !errors.As(err, &gwErr) {
|
||||
t.Fatalf("error %T does not unwrap to *GatewayError", err)
|
||||
}
|
||||
if got, _ := status.FromError(gwErr.Err); got.Code() != codes.Unauthenticated {
|
||||
t.Fatalf("inner status code = %v", got.Code())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamAlarmsStreamsSnapshotThenSnapshotComplete(t *testing.T) {
|
||||
fake := &fakeGatewayWithAlarms{
|
||||
activeSnapshots: []*pb.ActiveAlarmSnapshot{
|
||||
{
|
||||
AlarmFullReference: "Tank01.Level.HiHi",
|
||||
CurrentState: pb.AlarmConditionState_ALARM_CONDITION_STATE_ACTIVE,
|
||||
Severity: 750,
|
||||
},
|
||||
{
|
||||
AlarmFullReference: "Tank02.Level.HiHi",
|
||||
CurrentState: pb.AlarmConditionState_ALARM_CONDITION_STATE_ACTIVE_ACKED,
|
||||
Severity: 750,
|
||||
},
|
||||
},
|
||||
}
|
||||
client, cleanup := newBufconnClientWithAlarms(t, fake)
|
||||
defer cleanup()
|
||||
|
||||
stream, err := client.StreamAlarms(context.Background(), &pb.StreamAlarmsRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamAlarms() error = %v", err)
|
||||
}
|
||||
|
||||
var received []*pb.AlarmFeedMessage
|
||||
for {
|
||||
msg, err := stream.Recv()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("stream.Recv() error = %v", err)
|
||||
}
|
||||
received = append(received, msg)
|
||||
}
|
||||
if len(received) != 3 {
|
||||
t.Fatalf("message count = %d, want 3", len(received))
|
||||
}
|
||||
if received[0].GetActiveAlarm().GetAlarmFullReference() != "Tank01.Level.HiHi" {
|
||||
t.Fatalf("message[0] ref = %q", received[0].GetActiveAlarm().GetAlarmFullReference())
|
||||
}
|
||||
if received[1].GetActiveAlarm().GetCurrentState() != pb.AlarmConditionState_ALARM_CONDITION_STATE_ACTIVE_ACKED {
|
||||
t.Fatalf("message[1] state = %v", received[1].GetActiveAlarm().GetCurrentState())
|
||||
}
|
||||
if !received[2].GetSnapshotComplete() {
|
||||
t.Fatalf("final message is not snapshot_complete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamAlarmsPassesFilterPrefix(t *testing.T) {
|
||||
fake := &fakeGatewayWithAlarms{}
|
||||
client, cleanup := newBufconnClientWithAlarms(t, fake)
|
||||
defer cleanup()
|
||||
|
||||
stream, err := client.StreamAlarms(context.Background(), &pb.StreamAlarmsRequest{
|
||||
AlarmFilterPrefix: "Tank01.",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamAlarms() error = %v", err)
|
||||
}
|
||||
for {
|
||||
_, err := stream.Recv()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("stream.Recv() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if got := fake.streamRequest.GetAlarmFilterPrefix(); got != "Tank01." {
|
||||
t.Fatalf("captured filter prefix = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeGatewayWithAlarms struct {
|
||||
pb.UnimplementedMxAccessGatewayServer
|
||||
|
||||
acknowledgeRequest *pb.AcknowledgeAlarmRequest
|
||||
acknowledgeReply *pb.AcknowledgeAlarmReply
|
||||
acknowledgeError error
|
||||
acknowledgeAuth string
|
||||
|
||||
streamRequest *pb.StreamAlarmsRequest
|
||||
activeSnapshots []*pb.ActiveAlarmSnapshot
|
||||
}
|
||||
|
||||
func (s *fakeGatewayWithAlarms) AcknowledgeAlarm(ctx context.Context, req *pb.AcknowledgeAlarmRequest) (*pb.AcknowledgeAlarmReply, error) {
|
||||
s.acknowledgeRequest = req
|
||||
s.acknowledgeAuth = authorizationFromContext(ctx)
|
||||
if s.acknowledgeError != nil {
|
||||
return nil, s.acknowledgeError
|
||||
}
|
||||
if s.acknowledgeReply != nil {
|
||||
return s.acknowledgeReply, nil
|
||||
}
|
||||
return &pb.AcknowledgeAlarmReply{
|
||||
ProtocolStatus: &pb.ProtocolStatus{
|
||||
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *fakeGatewayWithAlarms) StreamAlarms(req *pb.StreamAlarmsRequest, stream grpc.ServerStreamingServer[pb.AlarmFeedMessage]) error {
|
||||
s.streamRequest = req
|
||||
for _, snap := range s.activeSnapshots {
|
||||
if err := stream.Send(&pb.AlarmFeedMessage{
|
||||
Payload: &pb.AlarmFeedMessage_ActiveAlarm{ActiveAlarm: snap},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return stream.Send(&pb.AlarmFeedMessage{
|
||||
Payload: &pb.AlarmFeedMessage_SnapshotComplete{SnapshotComplete: true},
|
||||
})
|
||||
}
|
||||
|
||||
func newBufconnClientWithAlarms(t *testing.T, fake *fakeGatewayWithAlarms) (*Client, func()) {
|
||||
t.Helper()
|
||||
listener := bufconn.Listen(bufSize)
|
||||
server := grpc.NewServer()
|
||||
pb.RegisterMxAccessGatewayServer(server, fake)
|
||||
go func() {
|
||||
_ = server.Serve(listener)
|
||||
}()
|
||||
dialer := func(ctx context.Context, _ string) (net.Conn, error) {
|
||||
return listener.DialContext(ctx)
|
||||
}
|
||||
// grpc.NewClient defaults to the dns scheme; use passthrough so the
|
||||
// bufconn fake target reaches the context dialer unresolved.
|
||||
client, err := Dial(context.Background(), Options{
|
||||
Endpoint: "passthrough:///bufnet",
|
||||
APIKey: "test-api-key",
|
||||
Plaintext: true,
|
||||
DialOptions: []grpc.DialOption{grpc.WithContextDialer(dialer)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Dial() error = %v", err)
|
||||
}
|
||||
return client, func() {
|
||||
client.Close()
|
||||
server.Stop()
|
||||
listener.Close()
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
|
||||
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/connectivity"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/protobuf/types/known/durationpb"
|
||||
@@ -36,22 +37,36 @@ type Client struct {
|
||||
opts Options
|
||||
}
|
||||
|
||||
// Dial opens a gRPC connection to the gateway and configures auth metadata,
|
||||
// transport security, and blocking dial cancellation from ctx.
|
||||
// Dial opens a gRPC connection to the gateway and configures auth metadata
|
||||
// and transport security.
|
||||
//
|
||||
// The connection is created lazily with grpc.NewClient: the channel is not
|
||||
// established until the first RPC (or the readiness probe below) needs it, so
|
||||
// a gateway that is briefly unavailable at Dial time no longer turns into a
|
||||
// hard error — the connection recovers when the gateway comes up. To preserve
|
||||
// fail-fast behavior, Dial then runs an explicit readiness probe bounded by
|
||||
// DialTimeout (default 10s, or ctx's deadline when sooner): it triggers the
|
||||
// initial connect and waits for the channel to reach Ready, returning a
|
||||
// *GatewayError if the gateway cannot be reached in that window. Cancelling
|
||||
// ctx aborts the probe.
|
||||
func Dial(ctx context.Context, opts Options) (*Client, error) {
|
||||
conn, err := dial(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return NewClient(conn, opts), nil
|
||||
}
|
||||
|
||||
// dial builds the shared gRPC connection used by both Client and GalaxyClient:
|
||||
// it resolves transport credentials, assembles dial options, creates a lazy
|
||||
// connection with grpc.NewClient, and runs the DialTimeout-bounded readiness
|
||||
// probe so callers still fail fast when the gateway is unreachable.
|
||||
func dial(ctx context.Context, opts Options) (*grpc.ClientConn, error) {
|
||||
if opts.Endpoint == "" {
|
||||
return nil, errors.New("mxgateway: endpoint is required")
|
||||
}
|
||||
|
||||
dialCtx := ctx
|
||||
cancel := func() {}
|
||||
if opts.DialTimeout > 0 {
|
||||
dialCtx, cancel = context.WithTimeout(ctx, opts.DialTimeout)
|
||||
} else if _, ok := ctx.Deadline(); !ok {
|
||||
dialCtx, cancel = context.WithTimeout(ctx, defaultDialTimeout)
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
transportCredentials, err := resolveTransportCredentials(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -61,16 +76,46 @@ func Dial(ctx context.Context, opts Options) (*Client, error) {
|
||||
grpc.WithTransportCredentials(transportCredentials),
|
||||
grpc.WithUnaryInterceptor(unaryAuthInterceptor(opts.APIKey)),
|
||||
grpc.WithStreamInterceptor(streamAuthInterceptor(opts.APIKey)),
|
||||
grpc.WithBlock(),
|
||||
}
|
||||
dialOptions = append(dialOptions, opts.DialOptions...)
|
||||
|
||||
conn, err := grpc.DialContext(dialCtx, opts.Endpoint, dialOptions...)
|
||||
conn, err := grpc.NewClient(opts.Endpoint, dialOptions...)
|
||||
if err != nil {
|
||||
return nil, &GatewayError{Op: "dial", Err: err}
|
||||
}
|
||||
|
||||
return NewClient(conn, opts), nil
|
||||
if err := waitForReady(ctx, conn, opts.DialTimeout); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, &GatewayError{Op: "dial", Err: err}
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// waitForReady triggers the initial connect on conn and blocks until the
|
||||
// channel reaches connectivity.Ready, the timeout elapses, or ctx is
|
||||
// cancelled. The wait is bounded by dialTimeout when positive, otherwise by
|
||||
// ctx's existing deadline, otherwise by defaultDialTimeout.
|
||||
func waitForReady(ctx context.Context, conn *grpc.ClientConn, dialTimeout time.Duration) error {
|
||||
probeCtx := ctx
|
||||
cancel := func() {}
|
||||
if dialTimeout > 0 {
|
||||
probeCtx, cancel = context.WithTimeout(ctx, dialTimeout)
|
||||
} else if _, ok := ctx.Deadline(); !ok {
|
||||
probeCtx, cancel = context.WithTimeout(ctx, defaultDialTimeout)
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
conn.Connect()
|
||||
for {
|
||||
state := conn.GetState()
|
||||
if state == connectivity.Ready {
|
||||
return nil
|
||||
}
|
||||
if !conn.WaitForStateChange(probeCtx, state) {
|
||||
return probeCtx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NewClient wraps an existing gRPC connection. The caller owns closing conn
|
||||
@@ -188,7 +233,15 @@ func (c *Client) Close() error {
|
||||
}
|
||||
|
||||
func (c *Client) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
timeout := c.opts.CallTimeout
|
||||
return callContext(ctx, c.opts.CallTimeout)
|
||||
}
|
||||
|
||||
// callContext derives a per-RPC context from ctx, applying callTimeout: zero
|
||||
// uses defaultCallTimeout, a negative value disables the bound entirely, and a
|
||||
// caller-supplied deadline that is already sooner than the derived timeout is
|
||||
// kept as-is rather than being lengthened.
|
||||
func callContext(ctx context.Context, callTimeout time.Duration) (context.Context, context.CancelFunc) {
|
||||
timeout := callTimeout
|
||||
if timeout == 0 {
|
||||
timeout = defaultCallTimeout
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ func TestEventsAfterCancelsStreamWhenCompatibilityChannelIsAbandoned(t *testing.
|
||||
fake := &fakeGatewayServer{
|
||||
streamStarted: make(chan struct{}),
|
||||
streamDone: make(chan struct{}),
|
||||
streamEventCount: 64,
|
||||
streamEventCount: 256,
|
||||
}
|
||||
client, cleanup := newBufconnClient(t, fake)
|
||||
defer cleanup()
|
||||
@@ -135,12 +135,25 @@ func TestEventsAfterCancelsStreamWhenCompatibilityChannelIsAbandoned(t *testing.
|
||||
t.Fatal("compatibility event stream did not stop after result channel filled")
|
||||
}
|
||||
|
||||
// A slow consumer that abandons the buffer must still receive an explicit
|
||||
// terminal overflow error before the channel closes, so it can tell
|
||||
// "events dropped" apart from "stream ended normally".
|
||||
var sawOverflow bool
|
||||
for {
|
||||
select {
|
||||
case _, ok := <-events:
|
||||
case result, ok := <-events:
|
||||
if !ok {
|
||||
if !sawOverflow {
|
||||
t.Fatal("compatibility event channel closed without an ErrEventBufferOverflow result")
|
||||
}
|
||||
return
|
||||
}
|
||||
if result.Err != nil {
|
||||
if !errors.Is(result.Err, ErrEventBufferOverflow) {
|
||||
t.Fatalf("terminal result error = %v, want ErrEventBufferOverflow", result.Err)
|
||||
}
|
||||
sawOverflow = true
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("compatibility event channel did not close")
|
||||
}
|
||||
@@ -230,6 +243,87 @@ func TestSubscribeBulkBuildsOneBulkCommandAndReturnsResults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteBulkBuildsOneBulkCommandAndReturnsPerEntryResults(t *testing.T) {
|
||||
fake := &fakeGatewayServer{
|
||||
invokeReply: &pb.MxCommandReply{
|
||||
SessionId: "session-1",
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_BULK,
|
||||
ProtocolStatus: &pb.ProtocolStatus{
|
||||
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
|
||||
},
|
||||
Payload: &pb.MxCommandReply_WriteBulk{
|
||||
WriteBulk: &pb.BulkWriteReply{
|
||||
Results: []*pb.BulkWriteResult{
|
||||
{ServerHandle: 12, ItemHandle: 901, WasSuccessful: true},
|
||||
{ServerHandle: 12, ItemHandle: 902, WasSuccessful: false, ErrorMessage: "invalid handle"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
client, cleanup := newBufconnClient(t, fake)
|
||||
defer cleanup()
|
||||
session := NewSessionForID(client, "session-1")
|
||||
|
||||
results, err := session.WriteBulk(context.Background(), 12, []*pb.WriteBulkEntry{
|
||||
{ItemHandle: 901, UserId: 5, Value: &pb.MxValue{DataType: pb.MxDataType_MX_DATA_TYPE_INTEGER, Kind: &pb.MxValue_Int32Value{Int32Value: 11}}},
|
||||
{ItemHandle: 902, UserId: 5, Value: &pb.MxValue{DataType: pb.MxDataType_MX_DATA_TYPE_INTEGER, Kind: &pb.MxValue_Int32Value{Int32Value: 22}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteBulk() error = %v", err)
|
||||
}
|
||||
if len(results) != 2 || !results[0].GetWasSuccessful() || results[1].GetWasSuccessful() {
|
||||
t.Fatalf("results = %#v, want [success, failure]", results)
|
||||
}
|
||||
req := fake.invokeRequest
|
||||
if req.GetCommand().GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_WRITE_BULK {
|
||||
t.Fatalf("command kind = %s", req.GetCommand().GetKind())
|
||||
}
|
||||
if got := req.GetCommand().GetWriteBulk().GetEntries(); len(got) != 2 {
|
||||
t.Fatalf("entries = %#v, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadBulkForwardsTimeoutAndUnpacksCachedFlag(t *testing.T) {
|
||||
fake := &fakeGatewayServer{
|
||||
invokeReply: &pb.MxCommandReply{
|
||||
SessionId: "session-1",
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_READ_BULK,
|
||||
ProtocolStatus: &pb.ProtocolStatus{
|
||||
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
|
||||
},
|
||||
Payload: &pb.MxCommandReply_ReadBulk{
|
||||
ReadBulk: &pb.BulkReadReply{
|
||||
Results: []*pb.BulkReadResult{
|
||||
{
|
||||
ServerHandle: 12,
|
||||
TagAddress: "Area001.Pump001.Speed",
|
||||
ItemHandle: 34,
|
||||
WasSuccessful: true,
|
||||
WasCached: true,
|
||||
Value: &pb.MxValue{DataType: pb.MxDataType_MX_DATA_TYPE_INTEGER, Kind: &pb.MxValue_Int32Value{Int32Value: 99}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
client, cleanup := newBufconnClient(t, fake)
|
||||
defer cleanup()
|
||||
session := NewSessionForID(client, "session-1")
|
||||
|
||||
results, err := session.ReadBulk(context.Background(), 12, []string{"Area001.Pump001.Speed"}, 750*time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadBulk() error = %v", err)
|
||||
}
|
||||
if len(results) != 1 || !results[0].GetWasCached() || results[0].GetValue().GetInt32Value() != 99 {
|
||||
t.Fatalf("results = %#v", results)
|
||||
}
|
||||
if got := fake.invokeRequest.GetCommand().GetReadBulk().GetTimeoutMs(); got != 750 {
|
||||
t.Fatalf("timeout_ms = %d, want 750", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvokeReturnsTypedMxAccessErrorWithRawReply(t *testing.T) {
|
||||
hresult := int32(-2147467259)
|
||||
fake := &fakeGatewayServer{
|
||||
@@ -279,8 +373,11 @@ func newBufconnClient(t *testing.T, fake *fakeGatewayServer) (*Client, func()) {
|
||||
dialer := func(ctx context.Context, _ string) (net.Conn, error) {
|
||||
return listener.DialContext(ctx)
|
||||
}
|
||||
// grpc.NewClient defaults the target scheme to dns; the bufconn fake name
|
||||
// is not DNS-resolvable, so use the passthrough scheme to hand the target
|
||||
// straight to the context dialer.
|
||||
client, err := Dial(context.Background(), Options{
|
||||
Endpoint: "bufnet",
|
||||
Endpoint: "passthrough:///bufnet",
|
||||
APIKey: "test-api-key",
|
||||
Plaintext: true,
|
||||
DialOptions: []grpc.DialOption{
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
package mxgateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
// --- Client.Go-008: resolveTransportCredentials precedence -----------------
|
||||
|
||||
// TestResolveTransportCredentialsPrecedence covers every branch of
|
||||
// resolveTransportCredentials, which previously only had the Plaintext path
|
||||
// exercised.
|
||||
func TestResolveTransportCredentialsPrecedence(t *testing.T) {
|
||||
custom := insecure.NewCredentials()
|
||||
|
||||
t.Run("TransportCredentialsWins", func(t *testing.T) {
|
||||
creds, err := resolveTransportCredentials(Options{
|
||||
TransportCredentials: custom,
|
||||
Plaintext: true, // must be ignored
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if creds != custom {
|
||||
t.Fatal("expected the explicit TransportCredentials to be returned as-is")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Plaintext", func(t *testing.T) {
|
||||
creds, err := resolveTransportCredentials(Options{Plaintext: true})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := creds.Info().SecurityProtocol; got != "insecure" {
|
||||
t.Fatalf("expected insecure credentials, got security protocol %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CACertFileMissingErrors", func(t *testing.T) {
|
||||
_, err := resolveTransportCredentials(Options{CACertFile: "does-not-exist.pem"})
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for a missing CA cert file")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("TLSConfigWithServerNameOverride", func(t *testing.T) {
|
||||
creds, err := resolveTransportCredentials(Options{
|
||||
TLSConfig: &tls.Config{MinVersion: tls.VersionTLS13},
|
||||
ServerNameOverride: "gateway.internal",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := creds.Info().ServerName; got != "gateway.internal" {
|
||||
t.Fatalf("expected ServerName override to be applied, got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DefaultTLSFloor", func(t *testing.T) {
|
||||
creds, err := resolveTransportCredentials(Options{ServerNameOverride: "host"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := creds.Info().SecurityProtocol; got != "tls" {
|
||||
t.Fatalf("expected the default TLS credentials, got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestResolveTransportCredentialsDoesNotMutateTLSConfig confirms the supplied
|
||||
// TLSConfig is cloned, not mutated, when ServerNameOverride is applied.
|
||||
func TestResolveTransportCredentialsDoesNotMutateTLSConfig(t *testing.T) {
|
||||
cfg := &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
if _, err := resolveTransportCredentials(Options{
|
||||
TLSConfig: cfg,
|
||||
ServerNameOverride: "override",
|
||||
}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg.ServerName != "" {
|
||||
t.Fatalf("resolveTransportCredentials mutated the caller's TLSConfig (ServerName=%q)", cfg.ServerName)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Client.Go-008: callContext deadline arithmetic ------------------------
|
||||
|
||||
// TestCallContextDeadlineArithmetic covers the shared callContext deadline
|
||||
// logic, including the negative-timeout disable case and the
|
||||
// caller-deadline-is-sooner case.
|
||||
func TestCallContextDeadlineArithmetic(t *testing.T) {
|
||||
t.Run("ZeroUsesDefault", func(t *testing.T) {
|
||||
ctx, cancel := callContext(context.Background(), 0)
|
||||
defer cancel()
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
t.Fatal("expected a deadline for the default timeout")
|
||||
}
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 || remaining > defaultCallTimeout+time.Second {
|
||||
t.Fatalf("default deadline out of range: %v", remaining)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NegativeDisablesBound", func(t *testing.T) {
|
||||
base := context.Background()
|
||||
ctx, cancel := callContext(base, -1)
|
||||
defer cancel()
|
||||
if _, ok := ctx.Deadline(); ok {
|
||||
t.Fatal("a negative timeout must disable the deadline entirely")
|
||||
}
|
||||
if ctx != base {
|
||||
t.Fatal("a negative timeout must return the caller context unchanged")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PositiveAppliesTimeout", func(t *testing.T) {
|
||||
ctx, cancel := callContext(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
t.Fatal("expected a deadline")
|
||||
}
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 || remaining > 5*time.Second+time.Second {
|
||||
t.Fatalf("deadline out of range: %v", remaining)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CallerDeadlineSoonerIsKept", func(t *testing.T) {
|
||||
base, baseCancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer baseCancel()
|
||||
ctx, cancel := callContext(base, 30*time.Second)
|
||||
defer cancel()
|
||||
if ctx != base {
|
||||
t.Fatal("a caller deadline sooner than the timeout must be kept as-is")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CallerDeadlineLaterIsShortened", func(t *testing.T) {
|
||||
base, baseCancel := context.WithTimeout(context.Background(), time.Hour)
|
||||
defer baseCancel()
|
||||
ctx, cancel := callContext(base, time.Second)
|
||||
defer cancel()
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
t.Fatal("expected a deadline")
|
||||
}
|
||||
if remaining := time.Until(deadline); remaining > 2*time.Second {
|
||||
t.Fatalf("expected the shorter timeout to win, got %v remaining", remaining)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- Client.Go-008: NativeValue / NativeArray edge branches ----------------
|
||||
|
||||
// TestNativeValueEdgeKinds covers the array, raw-bytes, null, and
|
||||
// nil-input branches of NativeValue.
|
||||
func TestNativeValueEdgeKinds(t *testing.T) {
|
||||
t.Run("NilInput", func(t *testing.T) {
|
||||
got, err := NativeValue(nil)
|
||||
if err != nil || got != nil {
|
||||
t.Fatalf("NativeValue(nil) = (%v, %v), want (nil, nil)", got, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ExplicitNull", func(t *testing.T) {
|
||||
got, err := NativeValue(&pb.MxValue{IsNull: true})
|
||||
if err != nil || got != nil {
|
||||
t.Fatalf("NativeValue(null) = (%v, %v), want (nil, nil)", got, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RawBytes", func(t *testing.T) {
|
||||
raw := []byte{0x01, 0x02, 0x03}
|
||||
got, err := NativeValue(&pb.MxValue{Kind: &pb.MxValue_RawValue{RawValue: raw}})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
gotBytes, ok := got.([]byte)
|
||||
if !ok || !reflect.DeepEqual(gotBytes, raw) {
|
||||
t.Fatalf("NativeValue raw = %v, want %v", got, raw)
|
||||
}
|
||||
// The result must be a copy, not aliasing the protobuf field.
|
||||
gotBytes[0] = 0xFF
|
||||
if raw[0] != 0x01 {
|
||||
t.Fatal("NativeValue raw result aliases the protobuf backing array")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ArrayValue", func(t *testing.T) {
|
||||
value := &pb.MxValue{Kind: &pb.MxValue_ArrayValue{
|
||||
ArrayValue: &pb.MxArray{Values: &pb.MxArray_Int32Values{
|
||||
Int32Values: &pb.Int32Array{Values: []int32{7, 8}},
|
||||
}},
|
||||
}}
|
||||
got, err := NativeValue(value)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, []int32{7, 8}) {
|
||||
t.Fatalf("NativeValue array = %v, want [7 8]", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestNativeArrayEdgeKinds covers the nil, raw-bytes, timestamp-with-nil, and
|
||||
// unsupported-kind branches of NativeArray.
|
||||
func TestNativeArrayEdgeKinds(t *testing.T) {
|
||||
t.Run("NilInput", func(t *testing.T) {
|
||||
got, err := NativeArray(nil)
|
||||
if err != nil || got != nil {
|
||||
t.Fatalf("NativeArray(nil) = (%v, %v), want (nil, nil)", got, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RawValues", func(t *testing.T) {
|
||||
got, err := NativeArray(&pb.MxArray{Values: &pb.MxArray_RawValues{
|
||||
RawValues: &pb.RawArray{Values: [][]byte{{0x0A}, {0x0B}}},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := [][]byte{{0x0A}, {0x0B}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("NativeArray raw = %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("TimestampWithNilEntry", func(t *testing.T) {
|
||||
got, err := NativeArray(&pb.MxArray{Values: &pb.MxArray_TimestampValues{
|
||||
TimestampValues: &pb.TimestampArray{Values: []*timestamppb.Timestamp{nil}},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
times, ok := got.([]time.Time)
|
||||
if !ok || len(times) != 1 || !times[0].IsZero() {
|
||||
t.Fatalf("NativeArray timestamp-with-nil = %v, want [zero-time]", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("UnsupportedKind", func(t *testing.T) {
|
||||
// An MxArray with no oneof set hits the default branch.
|
||||
_, err := NativeArray(&pb.MxArray{})
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for an MxArray with no values set")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unsupported array value kind") {
|
||||
t.Fatalf("unexpected error text: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestNativeValueUnsupportedKind covers the default branch of NativeValue.
|
||||
func TestNativeValueUnsupportedKind(t *testing.T) {
|
||||
// An MxValue with no oneof Kind set and IsNull false hits the default.
|
||||
_, err := NativeValue(&pb.MxValue{})
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for an MxValue with no kind set")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unsupported value kind") {
|
||||
t.Fatalf("unexpected error text: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Client.Go-005: dial migration -----------------------------------------
|
||||
|
||||
// TestDialFailsFastWhenGatewayUnreachable confirms that after the migration to
|
||||
// grpc.NewClient the DialTimeout-bounded readiness probe still fails fast (and
|
||||
// wraps the failure in *GatewayError) when the gateway cannot be reached.
|
||||
func TestDialFailsFastWhenGatewayUnreachable(t *testing.T) {
|
||||
dialer := func(ctx context.Context, _ string) (net.Conn, error) {
|
||||
return nil, errors.New("connection refused")
|
||||
}
|
||||
start := time.Now()
|
||||
client, err := Dial(context.Background(), Options{
|
||||
Endpoint: "passthrough:///unreachable",
|
||||
APIKey: "k",
|
||||
Plaintext: true,
|
||||
DialTimeout: 500 * time.Millisecond,
|
||||
DialOptions: []grpc.DialOption{grpc.WithContextDialer(dialer)},
|
||||
})
|
||||
elapsed := time.Since(start)
|
||||
if err == nil {
|
||||
client.Close()
|
||||
t.Fatal("expected Dial to fail for an unreachable gateway")
|
||||
}
|
||||
var gwErr *GatewayError
|
||||
if !errors.As(err, &gwErr) || gwErr.Op != "dial" {
|
||||
t.Fatalf("expected a *GatewayError with Op=dial, got %#v", err)
|
||||
}
|
||||
if elapsed > 5*time.Second {
|
||||
t.Fatalf("Dial did not honor DialTimeout: took %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDialReadinessProbeReachesReady confirms the readiness probe succeeds
|
||||
// against a live (bufconn) gateway, i.e. the lazy grpc.NewClient connection is
|
||||
// driven to Ready before Dial returns.
|
||||
func TestDialReadinessProbeReachesReady(t *testing.T) {
|
||||
client, cleanup := newBufconnClient(t, &fakeGatewayServer{
|
||||
openReply: &pb.OpenSessionReply{},
|
||||
})
|
||||
defer cleanup()
|
||||
if client == nil {
|
||||
t.Fatal("expected a connected client")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Client.Go-006: error taxonomy ----------------------------------------
|
||||
|
||||
// TestGatewayErrorCode confirms GatewayError.Code surfaces the wrapped gRPC
|
||||
// status code without the caller unwrapping it.
|
||||
func TestGatewayErrorCode(t *testing.T) {
|
||||
var nilErr *GatewayError
|
||||
if got := nilErr.Code(); got != codes.OK {
|
||||
t.Fatalf("nil GatewayError.Code() = %v, want OK", got)
|
||||
}
|
||||
|
||||
gwErr := &GatewayError{Op: "invoke", Err: status.Error(codes.Unavailable, "down")}
|
||||
if got := gwErr.Code(); got != codes.Unavailable {
|
||||
t.Fatalf("GatewayError.Code() = %v, want Unavailable", got)
|
||||
}
|
||||
|
||||
plain := &GatewayError{Op: "dial", Err: errors.New("boom")}
|
||||
if got := plain.Code(); got != codes.Unknown {
|
||||
t.Fatalf("GatewayError.Code() for a non-status error = %v, want Unknown", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsTransient verifies the transient/permanent classification including
|
||||
// the unwrap-through-GatewayError path.
|
||||
func TestIsTransient(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{name: "nil", err: nil, want: false},
|
||||
{name: "unavailable wrapped", err: &GatewayError{Op: "invoke", Err: status.Error(codes.Unavailable, "x")}, want: true},
|
||||
{name: "deadline wrapped", err: &GatewayError{Op: "invoke", Err: status.Error(codes.DeadlineExceeded, "x")}, want: true},
|
||||
{name: "resource exhausted", err: &GatewayError{Err: status.Error(codes.ResourceExhausted, "x")}, want: true},
|
||||
{name: "unauthenticated permanent", err: &GatewayError{Err: status.Error(codes.Unauthenticated, "x")}, want: false},
|
||||
{name: "invalid argument permanent", err: &GatewayError{Err: status.Error(codes.InvalidArgument, "x")}, want: false},
|
||||
{name: "bare status unavailable", err: status.Error(codes.Unavailable, "x"), want: true},
|
||||
{name: "plain error", err: errors.New("nope"), want: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := IsTransient(tt.err); got != tt.want {
|
||||
t.Fatalf("IsTransient(%v) = %v, want %v", tt.err, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Client.Go-007: correlation id fallback --------------------------------
|
||||
|
||||
// TestNewCorrelationIDUsesRandEntropy confirms the happy path yields a
|
||||
// 32-hex-character id.
|
||||
func TestNewCorrelationIDUsesRandEntropy(t *testing.T) {
|
||||
id := newCorrelationID()
|
||||
if len(id) != 32 {
|
||||
t.Fatalf("expected a 32-char hex id, got %q (len %d)", id, len(id))
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewCorrelationIDFallsBackOnRandFailure reproduces Client.Go-007: when
|
||||
// crypto/rand fails, newCorrelationID must not return an empty string but a
|
||||
// unique, non-empty fallback id so the command stays traceable.
|
||||
func TestNewCorrelationIDFallsBackOnRandFailure(t *testing.T) {
|
||||
original := randRead
|
||||
randRead = func([]byte) (int, error) { return 0, errors.New("entropy unavailable") }
|
||||
defer func() { randRead = original }()
|
||||
|
||||
first := newCorrelationID()
|
||||
second := newCorrelationID()
|
||||
|
||||
if first == "" || second == "" {
|
||||
t.Fatal("newCorrelationID returned an empty id on rand failure")
|
||||
}
|
||||
if !strings.HasPrefix(first, "fallback-") {
|
||||
t.Fatalf("expected a fallback- prefixed id, got %q", first)
|
||||
}
|
||||
if first == second {
|
||||
t.Fatalf("fallback correlation ids must be unique, got %q twice", first)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,22 @@
|
||||
package mxgateway
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// ErrEventBufferOverflow is the terminal error delivered on the compatibility
|
||||
// event channel returned by Session.Events / Session.EventsAfter when a slow
|
||||
// consumer lets the bounded result buffer fill. It signals that the stream was
|
||||
// cancelled and events were dropped, so a consumer can tell an overflow apart
|
||||
// from a normal end-of-stream. Use Session.SubscribeEvents to block instead of
|
||||
// dropping.
|
||||
var ErrEventBufferOverflow = errors.New("mxgateway: event buffer overflow; compatibility stream cancelled and events dropped")
|
||||
|
||||
// GatewayError wraps transport-level gRPC failures.
|
||||
type GatewayError struct {
|
||||
// Op names the operation that failed (for example "dial" or "invoke").
|
||||
@@ -33,6 +44,45 @@ func (e *GatewayError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// Code returns the gRPC status code of the wrapped transport error. It returns
|
||||
// codes.OK when the error is nil and codes.Unknown when the wrapped error does
|
||||
// not carry a gRPC status. Callers can use it to write retry, timeout, and
|
||||
// auth handling without manually unwrapping and re-parsing the error.
|
||||
func (e *GatewayError) Code() codes.Code {
|
||||
if e == nil || e.Err == nil {
|
||||
return codes.OK
|
||||
}
|
||||
return status.Code(e.Err)
|
||||
}
|
||||
|
||||
// IsTransient reports whether err is a transport failure that may succeed on
|
||||
// retry — for example a gateway that is briefly Unavailable or a call that
|
||||
// hit a DeadlineExceeded. Permanent failures (Unauthenticated, PermissionDenied,
|
||||
// InvalidArgument, NotFound, and similar) return false. It unwraps through
|
||||
// *GatewayError and any other error chain carrying a gRPC status, so callers
|
||||
// do not need to call status.Code themselves.
|
||||
func IsTransient(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
switch transientCode(err) {
|
||||
case codes.Unavailable, codes.DeadlineExceeded, codes.ResourceExhausted, codes.Aborted:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// transientCode extracts a gRPC status code from err, preferring a wrapped
|
||||
// *GatewayError's Code and otherwise falling back to status.Code on the chain.
|
||||
func transientCode(err error) codes.Code {
|
||||
var gatewayErr *GatewayError
|
||||
if errors.As(err, &gatewayErr) {
|
||||
return gatewayErr.Code()
|
||||
}
|
||||
return status.Code(err)
|
||||
}
|
||||
|
||||
// CommandError reports a non-OK gateway protocol status and keeps the raw
|
||||
// command reply when one exists.
|
||||
type CommandError struct {
|
||||
@@ -85,8 +135,12 @@ func (e *MxAccessError) Error() string {
|
||||
}
|
||||
|
||||
// Unwrap returns the wrapped CommandError, when one is present.
|
||||
//
|
||||
// When Command is nil (the HRESULT / MxStatusProxy path) it returns an
|
||||
// untyped nil rather than a typed-nil *CommandError, so errors.As does not
|
||||
// bind a nil pointer that a caller would then panic on.
|
||||
func (e *MxAccessError) Unwrap() error {
|
||||
if e == nil {
|
||||
if e == nil || e.Command == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Command
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package mxgateway
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMxAccessErrorUnwrapHResultPathNoTypedNilCommandError reproduces
|
||||
// Client.Go-001: an MxAccessError built via the HRESULT / MxStatusProxy path
|
||||
// leaves Command nil. Unwrap must not hand back a typed-nil *CommandError,
|
||||
// because errors.As would then succeed while binding a nil pointer and a
|
||||
// caller dereferencing it would panic.
|
||||
func TestMxAccessErrorUnwrapHResultPathNoTypedNilCommandError(t *testing.T) {
|
||||
hresult := int32(-2147467259) // 0x80004005, a failing HRESULT.
|
||||
reply := &MxCommandReply{Hresult: &hresult}
|
||||
|
||||
err := EnsureMxAccessSuccess("invoke", reply)
|
||||
if err == nil {
|
||||
t.Fatal("expected MxAccessError for a failing HRESULT, got nil")
|
||||
}
|
||||
|
||||
var ce *CommandError
|
||||
if errors.As(err, &ce) {
|
||||
t.Fatalf("errors.As bound *CommandError from an HRESULT-only MxAccessError (ce=%v); "+
|
||||
"a caller dereferencing ce.Status would panic", ce)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMxAccessErrorUnwrapPopulatedCommand confirms the non-nil Command path
|
||||
// still unwraps to the wrapped *CommandError.
|
||||
func TestMxAccessErrorUnwrapPopulatedCommand(t *testing.T) {
|
||||
command := &CommandError{Op: "invoke"}
|
||||
err := &MxAccessError{Command: command}
|
||||
|
||||
var ce *CommandError
|
||||
if !errors.As(err, &ce) {
|
||||
t.Fatal("errors.As failed to bind the populated *CommandError")
|
||||
}
|
||||
if ce != command {
|
||||
t.Fatalf("errors.As bound an unexpected *CommandError: got %v want %v", ce, command)
|
||||
}
|
||||
}
|
||||
@@ -56,39 +56,13 @@ type GalaxyClient struct {
|
||||
|
||||
// DialGalaxy opens a gRPC connection to the gateway for the Galaxy Repository
|
||||
// service. It applies the same authentication metadata, transport security,
|
||||
// and dial-timeout behavior as Dial.
|
||||
// lazy connection, and DialTimeout-bounded readiness probe as Dial.
|
||||
func DialGalaxy(ctx context.Context, opts Options) (*GalaxyClient, error) {
|
||||
if opts.Endpoint == "" {
|
||||
return nil, errors.New("mxgateway: endpoint is required")
|
||||
}
|
||||
|
||||
dialCtx := ctx
|
||||
cancel := func() {}
|
||||
if opts.DialTimeout > 0 {
|
||||
dialCtx, cancel = context.WithTimeout(ctx, opts.DialTimeout)
|
||||
} else if _, ok := ctx.Deadline(); !ok {
|
||||
dialCtx, cancel = context.WithTimeout(ctx, defaultDialTimeout)
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
transportCredentials, err := resolveTransportCredentials(opts)
|
||||
conn, err := dial(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dialOptions := []grpc.DialOption{
|
||||
grpc.WithTransportCredentials(transportCredentials),
|
||||
grpc.WithUnaryInterceptor(unaryAuthInterceptor(opts.APIKey)),
|
||||
grpc.WithStreamInterceptor(streamAuthInterceptor(opts.APIKey)),
|
||||
grpc.WithBlock(),
|
||||
}
|
||||
dialOptions = append(dialOptions, opts.DialOptions...)
|
||||
|
||||
conn, err := grpc.DialContext(dialCtx, opts.Endpoint, dialOptions...)
|
||||
if err != nil {
|
||||
return nil, &GatewayError{Op: "dial", Err: err}
|
||||
}
|
||||
|
||||
return NewGalaxyClient(conn, opts), nil
|
||||
}
|
||||
|
||||
@@ -213,7 +187,7 @@ func (c *GalaxyClient) WatchDeployEvents(
|
||||
}
|
||||
continue
|
||||
}
|
||||
if recvErr == io.EOF {
|
||||
if errors.Is(recvErr, io.EOF) {
|
||||
return
|
||||
}
|
||||
if status.Code(recvErr) == codes.Canceled || ctx.Err() != nil {
|
||||
@@ -239,18 +213,5 @@ func (c *GalaxyClient) Close() error {
|
||||
}
|
||||
|
||||
func (c *GalaxyClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
timeout := c.opts.CallTimeout
|
||||
if timeout == 0 {
|
||||
timeout = defaultCallTimeout
|
||||
}
|
||||
if timeout < 0 {
|
||||
return ctx, func() {}
|
||||
}
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
timeoutDeadline := time.Now().Add(timeout)
|
||||
if deadline.Before(timeoutDeadline) {
|
||||
return ctx, func() {}
|
||||
}
|
||||
}
|
||||
return context.WithTimeout(ctx, timeout)
|
||||
return callContext(ctx, c.opts.CallTimeout)
|
||||
}
|
||||
|
||||
@@ -55,8 +55,8 @@ func TestGalaxyGetLastDeployTimeReturnsTimestampWhenPresent(t *testing.T) {
|
||||
want := time.Date(2026, 4, 28, 12, 34, 56, 0, time.UTC)
|
||||
fake := &fakeGalaxyServer{
|
||||
deployReply: &pb.GetLastDeployTimeReply{
|
||||
Present: true,
|
||||
TimeOfLastDeploy: timestamppb.New(want),
|
||||
Present: true,
|
||||
TimeOfLastDeploy: timestamppb.New(want),
|
||||
},
|
||||
}
|
||||
client, cleanup := newGalaxyBufconnClient(t, fake)
|
||||
@@ -348,8 +348,10 @@ func newGalaxyBufconnClient(t *testing.T, fake *fakeGalaxyServer) (*GalaxyClient
|
||||
dialer := func(ctx context.Context, _ string) (net.Conn, error) {
|
||||
return listener.DialContext(ctx)
|
||||
}
|
||||
// grpc.NewClient defaults to the dns scheme; use passthrough so the
|
||||
// bufconn fake target reaches the context dialer unresolved.
|
||||
client, err := DialGalaxy(context.Background(), Options{
|
||||
Endpoint: "bufnet",
|
||||
Endpoint: "passthrough:///bufnet",
|
||||
APIKey: "test-api-key",
|
||||
Plaintext: true,
|
||||
DialOptions: []grpc.DialOption{
|
||||
@@ -370,15 +372,14 @@ func newGalaxyBufconnClient(t *testing.T, fake *fakeGalaxyServer) (*GalaxyClient
|
||||
type fakeGalaxyServer struct {
|
||||
pb.UnimplementedGalaxyRepositoryServer
|
||||
|
||||
testReply *pb.TestConnectionReply
|
||||
testAuth string
|
||||
failTest bool
|
||||
deployReply *pb.GetLastDeployTimeReply
|
||||
discoverReply *pb.DiscoverHierarchyReply
|
||||
watchEvents []*pb.DeployEvent
|
||||
watchRequest *pb.WatchDeployEventsRequest
|
||||
watchSendInterval time.Duration
|
||||
watchHoldOpen bool
|
||||
testReply *pb.TestConnectionReply
|
||||
testAuth string
|
||||
failTest bool
|
||||
deployReply *pb.GetLastDeployTimeReply
|
||||
discoverReply *pb.DiscoverHierarchyReply
|
||||
watchEvents []*pb.DeployEvent
|
||||
watchRequest *pb.WatchDeployEventsRequest
|
||||
watchHoldOpen bool
|
||||
}
|
||||
|
||||
func (s *fakeGalaxyServer) TestConnection(ctx context.Context, req *pb.TestConnectionRequest) (*pb.TestConnectionReply, error) {
|
||||
@@ -412,13 +413,6 @@ func (s *fakeGalaxyServer) WatchDeployEvents(req *pb.WatchDeployEventsRequest, s
|
||||
if err := stream.Send(event); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.watchSendInterval > 0 {
|
||||
select {
|
||||
case <-time.After(s.watchSendInterval):
|
||||
case <-stream.Context().Done():
|
||||
return stream.Context().Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.watchHoldOpen {
|
||||
<-stream.Context().Done()
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||
"google.golang.org/grpc/codes"
|
||||
@@ -387,6 +389,142 @@ func (s *Session) UnsubscribeBulk(ctx context.Context, serverHandle int32, itemH
|
||||
return reply.GetUnsubscribeBulk().GetResults(), nil
|
||||
}
|
||||
|
||||
// WriteBulk invokes MXAccess Write sequentially for each entry inside one gateway command.
|
||||
// Per-entry failures appear as BulkWriteResult entries with WasSuccessful=false; the call
|
||||
// never returns an error for per-entry MXAccess failures (it returns an error only for
|
||||
// protocol-level failures or transport errors).
|
||||
func (s *Session) WriteBulk(ctx context.Context, serverHandle int32, entries []*WriteBulkEntry) ([]*BulkWriteResult, error) {
|
||||
if entries == nil {
|
||||
return nil, errors.New("mxgateway: write bulk entries are required")
|
||||
}
|
||||
if err := ensureBulkSize("write bulk entries", len(entries)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_BULK,
|
||||
Payload: &pb.MxCommand_WriteBulk{
|
||||
WriteBulk: &pb.WriteBulkCommand{
|
||||
ServerHandle: serverHandle,
|
||||
Entries: entries,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return reply.GetWriteBulk().GetResults(), nil
|
||||
}
|
||||
|
||||
// Write2Bulk invokes MXAccess Write2 (timestamped) for each entry inside one gateway command.
|
||||
func (s *Session) Write2Bulk(ctx context.Context, serverHandle int32, entries []*Write2BulkEntry) ([]*BulkWriteResult, error) {
|
||||
if entries == nil {
|
||||
return nil, errors.New("mxgateway: write2 bulk entries are required")
|
||||
}
|
||||
if err := ensureBulkSize("write2 bulk entries", len(entries)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE2_BULK,
|
||||
Payload: &pb.MxCommand_Write2Bulk{
|
||||
Write2Bulk: &pb.Write2BulkCommand{
|
||||
ServerHandle: serverHandle,
|
||||
Entries: entries,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return reply.GetWrite2Bulk().GetResults(), nil
|
||||
}
|
||||
|
||||
// WriteSecuredBulk invokes MXAccess WriteSecured for each entry. Credential-sensitive
|
||||
// values must not be logged by callers; mirrors the single-item WriteSecured contract.
|
||||
func (s *Session) WriteSecuredBulk(ctx context.Context, serverHandle int32, entries []*WriteSecuredBulkEntry) ([]*BulkWriteResult, error) {
|
||||
if entries == nil {
|
||||
return nil, errors.New("mxgateway: write-secured bulk entries are required")
|
||||
}
|
||||
if err := ensureBulkSize("write-secured bulk entries", len(entries)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED_BULK,
|
||||
Payload: &pb.MxCommand_WriteSecuredBulk{
|
||||
WriteSecuredBulk: &pb.WriteSecuredBulkCommand{
|
||||
ServerHandle: serverHandle,
|
||||
Entries: entries,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return reply.GetWriteSecuredBulk().GetResults(), nil
|
||||
}
|
||||
|
||||
// WriteSecured2Bulk invokes MXAccess WriteSecured2 (timestamped) for each entry.
|
||||
func (s *Session) WriteSecured2Bulk(ctx context.Context, serverHandle int32, entries []*WriteSecured2BulkEntry) ([]*BulkWriteResult, error) {
|
||||
if entries == nil {
|
||||
return nil, errors.New("mxgateway: write-secured2 bulk entries are required")
|
||||
}
|
||||
if err := ensureBulkSize("write-secured2 bulk entries", len(entries)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED2_BULK,
|
||||
Payload: &pb.MxCommand_WriteSecured2Bulk{
|
||||
WriteSecured2Bulk: &pb.WriteSecured2BulkCommand{
|
||||
ServerHandle: serverHandle,
|
||||
Entries: entries,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return reply.GetWriteSecured2Bulk().GetResults(), nil
|
||||
}
|
||||
|
||||
// ReadBulk snapshots the current value of each requested tag.
|
||||
//
|
||||
// MXAccess COM has no synchronous Read; the worker satisfies this by returning the
|
||||
// most recent cached OnDataChange value when the tag is already advised (WasCached=true),
|
||||
// or by taking a full AddItem + Advise + wait + UnAdvise + RemoveItem snapshot lifecycle
|
||||
// otherwise. timeout bounds the wait per tag in the snapshot case; pass zero to use the
|
||||
// worker default. Per-tag failures (timeout, invalid tag) appear as BulkReadResult entries
|
||||
// with WasSuccessful=false; the call never returns an error for per-tag MXAccess failures.
|
||||
func (s *Session) ReadBulk(ctx context.Context, serverHandle int32, tagAddresses []string, timeout time.Duration) ([]*BulkReadResult, error) {
|
||||
if tagAddresses == nil {
|
||||
return nil, errors.New("mxgateway: tag addresses are required")
|
||||
}
|
||||
if err := ensureBulkSize("tag addresses", len(tagAddresses)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var timeoutMs uint32
|
||||
if timeout > 0 {
|
||||
ms := timeout.Milliseconds()
|
||||
if ms > int64(^uint32(0)) {
|
||||
timeoutMs = ^uint32(0)
|
||||
} else {
|
||||
timeoutMs = uint32(ms)
|
||||
}
|
||||
}
|
||||
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_READ_BULK,
|
||||
Payload: &pb.MxCommand_ReadBulk{
|
||||
ReadBulk: &pb.ReadBulkCommand{
|
||||
ServerHandle: serverHandle,
|
||||
TagAddresses: tagAddresses,
|
||||
TimeoutMs: timeoutMs,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return reply.GetReadBulk().GetResults(), nil
|
||||
}
|
||||
|
||||
// Write invokes MXAccess Write.
|
||||
func (s *Session) Write(ctx context.Context, serverHandle, itemHandle int32, value *MxValue, userID int32) error {
|
||||
_, err := s.WriteRaw(ctx, serverHandle, itemHandle, value, userID)
|
||||
@@ -461,7 +599,7 @@ func (s *Session) subscribeEventsAfter(ctx context.Context, afterWorkerSequence
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err == io.EOF || status.Code(err) == codes.Canceled || streamCtx.Err() != nil {
|
||||
if errors.Is(err, io.EOF) || status.Code(err) == codes.Canceled || streamCtx.Err() != nil {
|
||||
return
|
||||
}
|
||||
sendEventResult(
|
||||
@@ -490,7 +628,7 @@ func ensureBulkSize(name string, length int) error {
|
||||
|
||||
func sendEventResult(
|
||||
ctx context.Context,
|
||||
results chan<- EventResult,
|
||||
results chan EventResult,
|
||||
result EventResult,
|
||||
cancelWhenBufferFull bool,
|
||||
cancel context.CancelFunc,
|
||||
@@ -502,7 +640,12 @@ func sendEventResult(
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
default:
|
||||
// The bounded compatibility buffer is full. Cancel the stream and
|
||||
// deliver an explicit terminal overflow error so a slow consumer
|
||||
// can tell dropped events apart from a normal end-of-stream,
|
||||
// rather than seeing the channel close silently.
|
||||
cancel()
|
||||
deliverTerminalResult(results, EventResult{Err: ErrEventBufferOverflow})
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -515,6 +658,25 @@ func sendEventResult(
|
||||
}
|
||||
}
|
||||
|
||||
// deliverTerminalResult places result on a full buffered channel by evicting
|
||||
// one of the oldest buffered events to make room. The caller closes results
|
||||
// afterwards, so the terminal result becomes the consumer's last item.
|
||||
func deliverTerminalResult(results chan EventResult, result EventResult) {
|
||||
for {
|
||||
select {
|
||||
case results <- result:
|
||||
return
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case <-results:
|
||||
default:
|
||||
// Another receiver drained the channel between the send and
|
||||
// receive attempts; retry the send.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) invokeCommand(ctx context.Context, command *MxCommand) (*MxCommandReply, error) {
|
||||
return s.client.Invoke(ctx, &pb.MxCommandRequest{
|
||||
SessionId: s.ID(),
|
||||
@@ -523,10 +685,25 @@ func (s *Session) invokeCommand(ctx context.Context, command *MxCommand) (*MxCom
|
||||
})
|
||||
}
|
||||
|
||||
// correlationIDCounter backs the deterministic fallback id used when
|
||||
// crypto/rand is unavailable, so every command still carries a unique,
|
||||
// traceable correlation id.
|
||||
var correlationIDCounter atomic.Uint64
|
||||
|
||||
// randRead is the entropy source for newCorrelationID. It is a package
|
||||
// variable solely so tests can simulate a crypto/rand failure.
|
||||
var randRead = rand.Read
|
||||
|
||||
// newCorrelationID returns a unique correlation id for an MxCommandRequest.
|
||||
// It prefers 16 bytes of crypto/rand entropy; if rand.Read fails (rare) it
|
||||
// falls back to a "fallback-" prefixed id built from the current time and a
|
||||
// process-wide monotonic counter rather than returning an empty string, which
|
||||
// would leave the command untraceable in gateway logs.
|
||||
func newCorrelationID() string {
|
||||
var buffer [16]byte
|
||||
if _, err := rand.Read(buffer[:]); err != nil {
|
||||
return ""
|
||||
if _, err := randRead(buffer[:]); err != nil {
|
||||
return fmt.Sprintf("fallback-%x-%x",
|
||||
time.Now().UnixNano(), correlationIDCounter.Add(1))
|
||||
}
|
||||
return hex.EncodeToString(buffer[:])
|
||||
}
|
||||
|
||||
@@ -70,6 +70,32 @@ type (
|
||||
WriteCommand = pb.WriteCommand
|
||||
// Write2Command is the payload of an MXAccess Write2 command.
|
||||
Write2Command = pb.Write2Command
|
||||
// WriteBulkCommand carries one bulk-Write request.
|
||||
WriteBulkCommand = pb.WriteBulkCommand
|
||||
// WriteBulkEntry is one (item_handle, value, user_id) tuple in a WriteBulk request.
|
||||
WriteBulkEntry = pb.WriteBulkEntry
|
||||
// Write2BulkCommand carries one bulk-Write2 (timestamped) request.
|
||||
Write2BulkCommand = pb.Write2BulkCommand
|
||||
// Write2BulkEntry is one (item_handle, value, timestamp_value, user_id) tuple in a Write2Bulk request.
|
||||
Write2BulkEntry = pb.Write2BulkEntry
|
||||
// WriteSecuredBulkCommand carries one bulk-WriteSecured request. Values are credential-sensitive.
|
||||
WriteSecuredBulkCommand = pb.WriteSecuredBulkCommand
|
||||
// WriteSecuredBulkEntry is one entry in a WriteSecuredBulk request.
|
||||
WriteSecuredBulkEntry = pb.WriteSecuredBulkEntry
|
||||
// WriteSecured2BulkCommand carries one bulk-WriteSecured2 (timestamped) request.
|
||||
WriteSecured2BulkCommand = pb.WriteSecured2BulkCommand
|
||||
// WriteSecured2BulkEntry is one entry in a WriteSecured2Bulk request.
|
||||
WriteSecured2BulkEntry = pb.WriteSecured2BulkEntry
|
||||
// ReadBulkCommand carries one bulk-Read request.
|
||||
ReadBulkCommand = pb.ReadBulkCommand
|
||||
// BulkWriteResult is one per-entry result in a bulk-write reply.
|
||||
BulkWriteResult = pb.BulkWriteResult
|
||||
// BulkWriteReply aggregates BulkWriteResult entries for a bulk-write command.
|
||||
BulkWriteReply = pb.BulkWriteReply
|
||||
// BulkReadResult is one per-tag result in a bulk-read reply (carries the snapshot value plus a was_cached flag).
|
||||
BulkReadResult = pb.BulkReadResult
|
||||
// BulkReadReply aggregates BulkReadResult entries for a ReadBulk command.
|
||||
BulkReadReply = pb.BulkReadReply
|
||||
// RegisterReply carries the ServerHandle returned by Register.
|
||||
RegisterReply = pb.RegisterReply
|
||||
// AddItemReply carries the ItemHandle returned by AddItem.
|
||||
@@ -80,8 +106,33 @@ type (
|
||||
SubscribeResult = pb.SubscribeResult
|
||||
// BulkSubscribeReply aggregates SubscribeResult entries for a bulk command.
|
||||
BulkSubscribeReply = pb.BulkSubscribeReply
|
||||
// AcknowledgeAlarmRequest is the gateway AcknowledgeAlarm request message.
|
||||
AcknowledgeAlarmRequest = pb.AcknowledgeAlarmRequest
|
||||
// AcknowledgeAlarmReply is the gateway AcknowledgeAlarm reply message.
|
||||
AcknowledgeAlarmReply = pb.AcknowledgeAlarmReply
|
||||
// StreamAlarmsRequest is the gateway StreamAlarms request message.
|
||||
StreamAlarmsRequest = pb.StreamAlarmsRequest
|
||||
// AlarmFeedMessage is one message on the StreamAlarms feed — an
|
||||
// active-alarm snapshot row, a snapshot-complete sentinel, or a transition.
|
||||
AlarmFeedMessage = pb.AlarmFeedMessage
|
||||
// ActiveAlarmSnapshot is one currently-active alarm in the feed snapshot.
|
||||
ActiveAlarmSnapshot = pb.ActiveAlarmSnapshot
|
||||
// OnAlarmTransitionEvent is the body carried by alarm-transition MxEvents.
|
||||
OnAlarmTransitionEvent = pb.OnAlarmTransitionEvent
|
||||
)
|
||||
|
||||
// AlarmTransitionKind discriminates raise / acknowledge / clear / retrigger
|
||||
// transitions on an OnAlarmTransitionEvent.
|
||||
type AlarmTransitionKind = pb.AlarmTransitionKind
|
||||
|
||||
// AlarmConditionState reports the current state of an active alarm in a
|
||||
// ConditionRefresh snapshot.
|
||||
type AlarmConditionState = pb.AlarmConditionState
|
||||
|
||||
// StreamAlarmsClient is the generated server-streaming client for the
|
||||
// StreamAlarms RPC.
|
||||
type StreamAlarmsClient = pb.MxAccessGateway_StreamAlarmsClient
|
||||
|
||||
// Enumerations from the generated contract re-exported for client callers.
|
||||
type (
|
||||
// MxCommandKind discriminates which MXAccess command an MxCommand carries.
|
||||
@@ -133,6 +184,16 @@ const (
|
||||
CommandKindWrite = pb.MxCommandKind_MX_COMMAND_KIND_WRITE
|
||||
// CommandKindWrite2 selects the MXAccess Write2 command.
|
||||
CommandKindWrite2 = pb.MxCommandKind_MX_COMMAND_KIND_WRITE2
|
||||
// CommandKindWriteBulk selects the bulk Write command.
|
||||
CommandKindWriteBulk = pb.MxCommandKind_MX_COMMAND_KIND_WRITE_BULK
|
||||
// CommandKindWrite2Bulk selects the bulk Write2 (timestamped) command.
|
||||
CommandKindWrite2Bulk = pb.MxCommandKind_MX_COMMAND_KIND_WRITE2_BULK
|
||||
// CommandKindWriteSecuredBulk selects the bulk WriteSecured command.
|
||||
CommandKindWriteSecuredBulk = pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED_BULK
|
||||
// CommandKindWriteSecured2Bulk selects the bulk WriteSecured2 (timestamped) command.
|
||||
CommandKindWriteSecured2Bulk = pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED2_BULK
|
||||
// CommandKindReadBulk selects the bulk Read command (cached-or-snapshot per tag).
|
||||
CommandKindReadBulk = pb.MxCommandKind_MX_COMMAND_KIND_READ_BULK
|
||||
|
||||
// DataTypeUnknown denotes an unrecognized MXAccess data type.
|
||||
DataTypeUnknown = pb.MxDataType_MX_DATA_TYPE_UNKNOWN
|
||||
|
||||
@@ -7,7 +7,7 @@ const (
|
||||
|
||||
// GatewayProtocolVersion matches GatewayContractInfo.GatewayProtocolVersion
|
||||
// in the shared .NET contracts.
|
||||
GatewayProtocolVersion uint32 = 1
|
||||
GatewayProtocolVersion uint32 = 3
|
||||
|
||||
// WorkerProtocolVersion matches GatewayContractInfo.WorkerProtocolVersion
|
||||
// and is exposed for fake-worker and parity tests.
|
||||
|
||||
+51
-1
@@ -62,10 +62,60 @@ underlying protobuf messages. `MxGatewayCommandException` and
|
||||
`MxAccessException` preserve the raw `MxCommandReply` when the gateway returns a
|
||||
data-bearing MXAccess failure.
|
||||
|
||||
`MxGatewaySession` exposes the full bulk family — `addItemBulk`,
|
||||
`adviseItemBulk`, `removeItemBulk`, `unAdviseItemBulk`, `subscribeBulk`,
|
||||
`unsubscribeBulk`, `writeBulk`, `write2Bulk`, `writeSecuredBulk`,
|
||||
`writeSecured2Bulk`, and `readBulk`. Each carries one round-trip with a
|
||||
`List<*Entry>` (or `List<String>` / `List<Integer>` for the legacy bulk
|
||||
shapes) and returns `List<SubscribeResult>` / `List<BulkWriteResult>` /
|
||||
`List<BulkReadResult>`; per-entry MXAccess failures populate
|
||||
`wasSuccessful == false` and never throw. `readBulk` takes a per-tag
|
||||
`timeoutMs` (0 = worker default) and returns cached `OnDataChange` values
|
||||
when the tag is already advised (`wasCached == true`) without touching the
|
||||
existing subscription.
|
||||
|
||||
`openSession` verifies the gateway's reported `gateway_protocol_version` against
|
||||
the version this client was generated for and throws `MxGatewayException` on a
|
||||
mismatch, so an incompatible client fails fast with a clear message instead of
|
||||
issuing commands that fail downstream. A gateway that does not populate the
|
||||
field is accepted unchanged.
|
||||
|
||||
`MxGatewaySession` implements `AutoCloseable`. The try-with-resources `close()`
|
||||
performs a `CloseSession` network RPC but swallows (and logs) any failure of
|
||||
that RPC so a close-time error never replaces the exception a try-with-resources
|
||||
body is already propagating. Call `closeRaw()` explicitly when you need to
|
||||
observe the close result or handle a close-time failure.
|
||||
|
||||
`MxGatewayClient` and `GalaxyRepositoryClient` implement `AutoCloseable`. For a
|
||||
client that owns its channel (built with `connect`), the try-with-resources
|
||||
`close()` shuts the channel down and waits up to the configured
|
||||
`shutdownTimeout` (default 10 s, independent of `connectTimeout`) for
|
||||
termination, forcibly shutting it down on timeout, so in-flight calls and
|
||||
Netty event-loop threads are not left running after the block exits. If the
|
||||
calling thread is interrupted while waiting, the channel is forcibly shut down
|
||||
and the interrupt flag is restored. `closeAndAwaitTermination()` does the same
|
||||
but throws `InterruptedException` for callers that want a checked,
|
||||
blocking-aware shutdown. `close()` is a no-op for a caller-managed channel.
|
||||
|
||||
`MxEventStream` implements `Iterator<MxEvent>` and `AutoCloseable`. Closing it
|
||||
cancels the underlying gRPC stream. Canceling or timing out a Java client call
|
||||
only stops the client from waiting; it does not abort an in-flight MXAccess COM
|
||||
call on the worker STA.
|
||||
call on the worker STA. Closing an `MxEventStream` *before* the gRPC call has
|
||||
attached its observer (a real race when callers cancel immediately after
|
||||
subscribing) is safe — the close is replayed in the observer's `beforeStart`
|
||||
and the underlying call is cancelled, matching `DeployEventStream` behaviour.
|
||||
The event stream uses gRPC's default auto-inbound flow control with a fixed
|
||||
1024-element buffer and no client-side flow control: this is the gateway's
|
||||
documented fail-fast event-backpressure model, so a consumer that stalls long
|
||||
enough to fill the buffer triggers an overflow that cancels the subscription
|
||||
and surfaces an `MxGatewayException` from the next `next()` call. Drain events
|
||||
promptly and be prepared to resubscribe with a resume cursor.
|
||||
|
||||
Cancellation of `CompletableFuture` results from `openSessionAsync`,
|
||||
`invokeAsync`, `acknowledgeAlarmAsync`, `getLastDeployTimeAsync`,
|
||||
`testConnectionAsync`, and `discoverHierarchyAsync` forwards to the underlying
|
||||
gRPC call: calling `cancel(true)` on the returned future aborts the in-flight
|
||||
RPC instead of merely detaching the future from its result.
|
||||
|
||||
## Galaxy Repository Browse
|
||||
|
||||
|
||||
+885
-15
File diff suppressed because it is too large
Load Diff
+530
-3
@@ -4,24 +4,43 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.PrintWriter;
|
||||
import java.io.StringWriter;
|
||||
import com.dohertylan.mxgateway.client.MxGatewayAlarmFeedSubscription;
|
||||
import io.grpc.stub.StreamObserver;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AddItemReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AlarmConditionState;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AlarmTransitionKind;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.BulkReadResult;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.BulkWriteResult;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxCommandKind;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxValue;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.OnAlarmTransitionEvent;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.RegisterReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.SessionState;
|
||||
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 org.junit.jupiter.api.Test;
|
||||
|
||||
final class MxGatewayCliTests {
|
||||
@@ -32,7 +51,7 @@ final class MxGatewayCliTests {
|
||||
assertEquals(0, run.exitCode());
|
||||
assertEquals("", run.errors());
|
||||
assertTrue(run.output().contains("mxgateway-java 0.1.0"));
|
||||
assertTrue(run.output().contains("gatewayProtocolVersion=1"));
|
||||
assertTrue(run.output().contains("gatewayProtocolVersion=3"));
|
||||
assertTrue(run.output().contains("workerProtocolVersion=1"));
|
||||
}
|
||||
|
||||
@@ -42,7 +61,7 @@ final class MxGatewayCliTests {
|
||||
|
||||
assertEquals(0, run.exitCode());
|
||||
assertTrue(run.output().contains("\"clientVersion\":\"0.1.0\""));
|
||||
assertTrue(run.output().contains("\"gatewayProtocolVersion\":1"));
|
||||
assertTrue(run.output().contains("\"gatewayProtocolVersion\":3"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -62,8 +81,10 @@ final class MxGatewayCliTests {
|
||||
assertEquals(0, run.exitCode());
|
||||
assertTrue(run.output().contains("\"command\":\"open-session\""));
|
||||
assertTrue(run.output().contains("\"sessionId\":\"session-cli\""));
|
||||
assertTrue(run.output().contains("mxgw***********cret"));
|
||||
// Only the non-secret mxgw_<key-id>_ prefix survives; the secret is fully masked.
|
||||
assertTrue(run.output().contains("mxgw_visible_***"));
|
||||
assertFalse(run.output().contains("visible_secret"));
|
||||
assertFalse(run.output().contains("cret"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -122,6 +143,40 @@ final class MxGatewayCliTests {
|
||||
assertTrue(run.output().contains("\"tagAddress\":\"TestMachine_002.TestChangingInt\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deployEventSequenceRendersAsUnsignedForHighUint64() {
|
||||
// Client.Java-020 regression: galaxy-watch text output now uses
|
||||
// Long.toUnsignedString to format the proto uint64 sequence field, so
|
||||
// values past 2^63 render as positive decimal strings instead of the
|
||||
// negative signed-long interpretation the old "%d" produced.
|
||||
long highUnsigned = -1L; // bit-pattern for 2^64 - 1, i.e. 18446744073709551615 unsigned
|
||||
String text = String.format(
|
||||
"seq=%s observed=%s deployTime=%s objects=%d attributes=%d",
|
||||
Long.toUnsignedString(highUnsigned),
|
||||
"2026-05-20T00:00:00Z",
|
||||
"(none)",
|
||||
0,
|
||||
0);
|
||||
|
||||
assertTrue(text.contains("seq=18446744073709551615"), "expected unsigned rendering, got: " + text);
|
||||
assertFalse(text.contains("seq=-1"), "must not render as signed -1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void streamEventsWorkerSequenceRendersAsUnsignedForHighUint64() {
|
||||
// Client.Java-023 regression: stream-events text output now uses
|
||||
// Long.toUnsignedString to format the proto uint64 worker_sequence
|
||||
// field, mirroring the Client.Java-020 fix for DeployEvent.sequence.
|
||||
long highUnsigned = -1L; // bit-pattern for 2^64 - 1, i.e. 18446744073709551615 unsigned
|
||||
String text = String.format(
|
||||
"%s %s",
|
||||
Long.toUnsignedString(highUnsigned),
|
||||
"MX_EVENT_FAMILY_DATA_CHANGE");
|
||||
|
||||
assertTrue(text.startsWith("18446744073709551615 "), "expected unsigned rendering, got: " + text);
|
||||
assertFalse(text.startsWith("-1 "), "must not render as signed -1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unsubscribeBulkCommandPrintsResults() {
|
||||
CliRun run = execute(
|
||||
@@ -141,6 +196,357 @@ final class MxGatewayCliTests {
|
||||
assertTrue(run.output().contains("\"wasSuccessful\":true"));
|
||||
}
|
||||
|
||||
// ---- Client.Java-026: CLI-level coverage for bulk subcommands ----
|
||||
|
||||
@Test
|
||||
void readBulkCommandForwardsTimeoutAndPrintsResults() {
|
||||
FakeClientFactory factory = new FakeClientFactory();
|
||||
CliRun run = execute(
|
||||
factory,
|
||||
"read-bulk",
|
||||
"--session-id",
|
||||
"session-cli",
|
||||
"--server-handle",
|
||||
"42",
|
||||
"--items",
|
||||
"TestMachine_001.TestChangingInt,TestMachine_002.TestChangingInt",
|
||||
"--timeout-ms",
|
||||
"750",
|
||||
"--json");
|
||||
|
||||
assertEquals(0, run.exitCode());
|
||||
assertEquals(750, factory.client.session.lastReadBulkTimeoutMs);
|
||||
assertEquals(2, factory.client.session.lastReadBulkItems.size());
|
||||
assertTrue(run.output().contains("\"command\":\"read-bulk\""));
|
||||
assertTrue(run.output().contains("\"tagAddress\":\"TestMachine_001.TestChangingInt\""));
|
||||
assertTrue(run.output().contains("\"itemHandle\":200"));
|
||||
assertTrue(run.output().contains("\"wasCached\":true"));
|
||||
assertTrue(run.output().contains("\"quality\":192"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeBulkCommandParsesTypedValuesAndPrintsResults() {
|
||||
FakeClientFactory factory = new FakeClientFactory();
|
||||
CliRun run = execute(
|
||||
factory,
|
||||
"write-bulk",
|
||||
"--session-id",
|
||||
"session-cli",
|
||||
"--server-handle",
|
||||
"42",
|
||||
"--item-handles",
|
||||
"100,101",
|
||||
"--type",
|
||||
"int32",
|
||||
"--values",
|
||||
"111,222",
|
||||
"--user-id",
|
||||
"5",
|
||||
"--json");
|
||||
|
||||
assertEquals(0, run.exitCode());
|
||||
assertEquals(2, factory.client.session.lastWriteBulkEntries.size());
|
||||
assertEquals(111, factory.client.session.lastWriteBulkEntries.get(0).getValue().getInt32Value());
|
||||
assertEquals(222, factory.client.session.lastWriteBulkEntries.get(1).getValue().getInt32Value());
|
||||
assertEquals(5, factory.client.session.lastWriteBulkEntries.get(0).getUserId());
|
||||
assertTrue(run.output().contains("\"command\":\"write-bulk\""));
|
||||
assertTrue(run.output().contains("\"itemHandle\":100"));
|
||||
assertTrue(run.output().contains("\"wasSuccessful\":true"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void write2BulkCommandForwardsTimestampAndPrintsResults() {
|
||||
FakeClientFactory factory = new FakeClientFactory();
|
||||
CliRun run = execute(
|
||||
factory,
|
||||
"write2-bulk",
|
||||
"--session-id",
|
||||
"session-cli",
|
||||
"--server-handle",
|
||||
"42",
|
||||
"--item-handles",
|
||||
"100",
|
||||
"--type",
|
||||
"string",
|
||||
"--values",
|
||||
"hello",
|
||||
"--timestamp",
|
||||
"2026-05-20T00:00:00Z",
|
||||
"--json");
|
||||
|
||||
assertEquals(0, run.exitCode());
|
||||
assertEquals(1, factory.client.session.lastWrite2BulkEntries.size());
|
||||
assertEquals(
|
||||
"hello",
|
||||
factory.client.session.lastWrite2BulkEntries.get(0).getValue().getStringValue());
|
||||
assertTrue(
|
||||
factory.client.session.lastWrite2BulkEntries.get(0).hasTimestampValue(),
|
||||
"expected timestampValue to be forwarded");
|
||||
assertTrue(run.output().contains("\"command\":\"write2-bulk\""));
|
||||
assertTrue(run.output().contains("\"itemHandle\":100"));
|
||||
assertTrue(run.output().contains("\"wasSuccessful\":true"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeSecuredBulkCommandForwardsUserIdsAndPrintsResults() {
|
||||
FakeClientFactory factory = new FakeClientFactory();
|
||||
CliRun run = execute(
|
||||
factory,
|
||||
"write-secured-bulk",
|
||||
"--session-id",
|
||||
"session-cli",
|
||||
"--server-handle",
|
||||
"42",
|
||||
"--item-handles",
|
||||
"100",
|
||||
"--type",
|
||||
"int32",
|
||||
"--values",
|
||||
"9",
|
||||
"--current-user-id",
|
||||
"7",
|
||||
"--verifier-user-id",
|
||||
"8",
|
||||
"--json");
|
||||
|
||||
assertEquals(0, run.exitCode());
|
||||
assertEquals(1, factory.client.session.lastWriteSecuredBulkEntries.size());
|
||||
assertEquals(7, factory.client.session.lastWriteSecuredBulkEntries.get(0).getCurrentUserId());
|
||||
assertEquals(8, factory.client.session.lastWriteSecuredBulkEntries.get(0).getVerifierUserId());
|
||||
assertEquals(9, factory.client.session.lastWriteSecuredBulkEntries.get(0).getValue().getInt32Value());
|
||||
assertTrue(run.output().contains("\"command\":\"write-secured-bulk\""));
|
||||
assertTrue(run.output().contains("\"wasSuccessful\":true"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeSecured2BulkCommandForwardsTimestampAndUserIdsAndPrintsResults() {
|
||||
FakeClientFactory factory = new FakeClientFactory();
|
||||
CliRun run = execute(
|
||||
factory,
|
||||
"write-secured2-bulk",
|
||||
"--session-id",
|
||||
"session-cli",
|
||||
"--server-handle",
|
||||
"42",
|
||||
"--item-handles",
|
||||
"100",
|
||||
"--type",
|
||||
"string",
|
||||
"--values",
|
||||
"value",
|
||||
"--timestamp",
|
||||
"2026-05-20T00:00:00Z",
|
||||
"--current-user-id",
|
||||
"7",
|
||||
"--verifier-user-id",
|
||||
"8",
|
||||
"--json");
|
||||
|
||||
assertEquals(0, run.exitCode());
|
||||
assertEquals(1, factory.client.session.lastWriteSecured2BulkEntries.size());
|
||||
assertEquals(7, factory.client.session.lastWriteSecured2BulkEntries.get(0).getCurrentUserId());
|
||||
assertEquals(8, factory.client.session.lastWriteSecured2BulkEntries.get(0).getVerifierUserId());
|
||||
assertTrue(
|
||||
factory.client.session.lastWriteSecured2BulkEntries.get(0).hasTimestampValue(),
|
||||
"expected timestampValue to be forwarded");
|
||||
assertTrue(run.output().contains("\"command\":\"write-secured2-bulk\""));
|
||||
assertTrue(run.output().contains("\"wasSuccessful\":true"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void benchReadBulkCommandEmitsJsonSchemaKeys() {
|
||||
// Short bench window (1 s steady, 0 s warmup) keeps the test fast; we assert
|
||||
// the JSON schema rather than numeric values so the cross-language matrix
|
||||
// (.NET / Go / Rust / Python) and the Java path agree on the output shape.
|
||||
FakeClientFactory factory = new FakeClientFactory();
|
||||
CliRun run = execute(
|
||||
factory,
|
||||
"bench-read-bulk",
|
||||
"--duration-seconds",
|
||||
"1",
|
||||
"--warmup-seconds",
|
||||
"0",
|
||||
"--bulk-size",
|
||||
"2",
|
||||
"--tag-start",
|
||||
"1",
|
||||
"--tag-prefix",
|
||||
"TestMachine_",
|
||||
"--tag-attribute",
|
||||
"TestChangingInt",
|
||||
"--timeout-ms",
|
||||
"100",
|
||||
"--json");
|
||||
|
||||
assertEquals(0, run.exitCode());
|
||||
String output = run.output();
|
||||
assertTrue(output.contains("\"language\":\"java\""), output);
|
||||
assertTrue(output.contains("\"command\":\"bench-read-bulk\""), output);
|
||||
assertTrue(output.contains("\"bulkSize\":2"), output);
|
||||
assertTrue(output.contains("\"durationSeconds\":1"), output);
|
||||
assertTrue(output.contains("\"warmupSeconds\":0"), output);
|
||||
assertTrue(output.contains("\"totalCalls\":"), output);
|
||||
assertTrue(output.contains("\"successfulCalls\":"), output);
|
||||
assertTrue(output.contains("\"failedCalls\":"), output);
|
||||
assertTrue(output.contains("\"callsPerSecond\":"), output);
|
||||
assertTrue(output.contains("\"latencyMs\":"), output);
|
||||
assertTrue(output.contains("\"p50\":"), output);
|
||||
assertTrue(output.contains("\"p95\":"), output);
|
||||
assertTrue(output.contains("\"p99\":"), output);
|
||||
assertTrue(output.contains("\"tags\":"), output);
|
||||
// Bench tag synthesis: TestMachine_001.TestChangingInt, TestMachine_002.TestChangingInt.
|
||||
assertTrue(output.contains("TestMachine_001.TestChangingInt"), output);
|
||||
assertTrue(output.contains("TestMachine_002.TestChangingInt"), output);
|
||||
}
|
||||
|
||||
// ---- stream-alarms / acknowledge-alarm subcommands ----
|
||||
|
||||
@Test
|
||||
void streamAlarmsCommandForwardsFilterPrefixAndPrintsFeedMessages() {
|
||||
FakeClientFactory factory = new FakeClientFactory();
|
||||
CliRun run = execute(factory, "stream-alarms", "--filter-prefix", "Tank01");
|
||||
|
||||
assertEquals(0, run.exitCode());
|
||||
assertEquals("Tank01", factory.client.lastStreamAlarmsRequest.getAlarmFilterPrefix());
|
||||
String out = run.output();
|
||||
assertTrue(out.contains("active-alarm Tank01.Level.HiHi"), out);
|
||||
assertTrue(out.contains("snapshot-complete"), out);
|
||||
assertTrue(out.contains("transition Tank01.Level.HiHi"), out);
|
||||
}
|
||||
|
||||
@Test
|
||||
void streamAlarmsCommandHonoursLimit() {
|
||||
FakeClientFactory factory = new FakeClientFactory();
|
||||
CliRun run = execute(factory, "stream-alarms", "--limit", "1");
|
||||
|
||||
assertEquals(0, run.exitCode());
|
||||
long lines = run.output().lines().filter(line -> !line.isBlank()).count();
|
||||
assertEquals(1, lines, "expected exactly one feed message with --limit 1, got: " + run.output());
|
||||
}
|
||||
|
||||
@Test
|
||||
void streamAlarmsCommandPrintsJson() {
|
||||
FakeClientFactory factory = new FakeClientFactory();
|
||||
CliRun run = execute(factory, "stream-alarms", "--json");
|
||||
|
||||
assertEquals(0, run.exitCode());
|
||||
assertTrue(run.output().contains("\"activeAlarm\""), run.output());
|
||||
assertTrue(run.output().contains("\"snapshotComplete\""), run.output());
|
||||
}
|
||||
|
||||
@Test
|
||||
void acknowledgeAlarmCommandForwardsOptionsAndPrintsReply() {
|
||||
FakeClientFactory factory = new FakeClientFactory();
|
||||
CliRun run = execute(
|
||||
factory,
|
||||
"acknowledge-alarm",
|
||||
"--reference",
|
||||
"Tank01.Level.HiHi",
|
||||
"--comment",
|
||||
"checked",
|
||||
"--operator",
|
||||
"operator1",
|
||||
"--json");
|
||||
|
||||
assertEquals(0, run.exitCode());
|
||||
assertEquals("Tank01.Level.HiHi", factory.client.lastAcknowledgeAlarmRequest.getAlarmFullReference());
|
||||
assertEquals("checked", factory.client.lastAcknowledgeAlarmRequest.getComment());
|
||||
assertEquals("operator1", factory.client.lastAcknowledgeAlarmRequest.getOperatorUser());
|
||||
assertTrue(run.output().contains("\"command\":\"acknowledge-alarm\""), run.output());
|
||||
}
|
||||
|
||||
@Test
|
||||
void acknowledgeAlarmCommandRequiresReference() {
|
||||
CliRun run = execute(new FakeClientFactory(), "acknowledge-alarm", "--comment", "checked");
|
||||
|
||||
assertFalse(run.exitCode() == 0, "expected non-zero exit without --reference");
|
||||
assertTrue(run.errors().contains("--reference"), run.errors());
|
||||
}
|
||||
|
||||
// ---- Client.Java-027: batch subcommand ----
|
||||
|
||||
@Test
|
||||
void batchCommandExecutesTwoCommandsAndEmitsEorAfterEach() {
|
||||
String stdin = "version --json\nversion --json\n";
|
||||
CliRun run = executeBatch(new FakeClientFactory(), stdin);
|
||||
|
||||
assertEquals(0, run.exitCode());
|
||||
String out = run.output();
|
||||
// Two EOR sentinels — one per input line.
|
||||
int firstEor = out.indexOf(MxGatewayCli.BATCH_EOR);
|
||||
int lastEor = out.lastIndexOf(MxGatewayCli.BATCH_EOR);
|
||||
assertTrue(firstEor >= 0, "expected at least one EOR sentinel");
|
||||
assertTrue(lastEor > firstEor, "expected two distinct EOR sentinels");
|
||||
// Both results contain version JSON.
|
||||
assertTrue(out.contains("\"clientVersion\""), out);
|
||||
}
|
||||
|
||||
@Test
|
||||
void batchCommandEmitsEorOnFailedCommand() {
|
||||
// "open-session" without --endpoint / --api-key-env will fail against
|
||||
// the FakeClientFactory (missing required option --session-id for
|
||||
// close-session, for example). Use an unknown subcommand to provoke a
|
||||
// picocli parse error which produces a non-zero exit code without
|
||||
// hitting the gateway.
|
||||
String stdin = "no-such-subcommand\nversion --json\n";
|
||||
CliRun run = executeBatch(new FakeClientFactory(), stdin);
|
||||
|
||||
assertEquals(0, run.exitCode());
|
||||
String out = run.output();
|
||||
// Two EOR sentinels even though the first command failed.
|
||||
int firstEor = out.indexOf(MxGatewayCli.BATCH_EOR);
|
||||
int lastEor = out.lastIndexOf(MxGatewayCli.BATCH_EOR);
|
||||
assertTrue(firstEor >= 0, "expected EOR after failed command");
|
||||
assertTrue(lastEor > firstEor, "expected EOR after second (successful) command");
|
||||
// The second command's result is present.
|
||||
assertTrue(out.contains("\"clientVersion\""), out);
|
||||
}
|
||||
|
||||
@Test
|
||||
void batchCommandExitsZeroOnEmptyLine() {
|
||||
// An empty line signals EOF-equivalent; loop exits immediately.
|
||||
CliRun run = executeBatch(new FakeClientFactory(), "\n");
|
||||
|
||||
assertEquals(0, run.exitCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void batchCommandExitsZeroOnActualEof() {
|
||||
CliRun run = executeBatch(new FakeClientFactory(), "");
|
||||
|
||||
assertEquals(0, run.exitCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void batchCommandDoesNotTerminateAfterFailedCommand() {
|
||||
// Three lines: good, bad, good — all three EORs must appear and the
|
||||
// third command must produce its output.
|
||||
String stdin = "version --json\nno-such-subcommand\nversion --json\n";
|
||||
CliRun run = executeBatch(new FakeClientFactory(), stdin);
|
||||
|
||||
assertEquals(0, run.exitCode());
|
||||
String out = run.output();
|
||||
long eorCount = out.lines()
|
||||
.filter(l -> l.equals(MxGatewayCli.BATCH_EOR))
|
||||
.count();
|
||||
assertEquals(3, eorCount, "expected exactly 3 EOR sentinels, got: " + eorCount + "\nOutput:\n" + out);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the CLI with {@code batch} as the subcommand, using the provided
|
||||
* string as standard input content. Temporarily replaces {@link System#in}
|
||||
* for the duration of the call.
|
||||
*/
|
||||
private static CliRun executeBatch(MxGatewayCli.MxGatewayCliClientFactory factory, String stdinContent) {
|
||||
InputStream originalIn = System.in;
|
||||
try {
|
||||
System.setIn(new ByteArrayInputStream(stdinContent.getBytes(StandardCharsets.UTF_8)));
|
||||
return execute(factory, "batch");
|
||||
} finally {
|
||||
System.setIn(originalIn);
|
||||
}
|
||||
}
|
||||
|
||||
private static CliRun execute(MxGatewayCli.MxGatewayCliClientFactory factory, String... args) {
|
||||
StringWriter output = new StringWriter();
|
||||
StringWriter errors = new StringWriter();
|
||||
@@ -169,6 +575,8 @@ final class MxGatewayCliTests {
|
||||
private final PrintWriter out;
|
||||
private final FakeSession session = new FakeSession();
|
||||
private boolean closeCalled;
|
||||
private AcknowledgeAlarmRequest lastAcknowledgeAlarmRequest;
|
||||
private StreamAlarmsRequest lastStreamAlarmsRequest;
|
||||
|
||||
private FakeClient(PrintWriter out) {
|
||||
this.out = out;
|
||||
@@ -202,6 +610,40 @@ final class MxGatewayCliTests {
|
||||
return session;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AcknowledgeAlarmReply acknowledgeAlarm(AcknowledgeAlarmRequest request) {
|
||||
lastAcknowledgeAlarmRequest = request;
|
||||
return AcknowledgeAlarmReply.newBuilder()
|
||||
.setCorrelationId(request.getClientCorrelationId())
|
||||
.setProtocolStatus(ok())
|
||||
.setHresult(0)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public MxGatewayAlarmFeedSubscription streamAlarms(
|
||||
StreamAlarmsRequest request, StreamObserver<AlarmFeedMessage> observer) {
|
||||
lastStreamAlarmsRequest = request;
|
||||
// Replay a deterministic active-alarm snapshot, snapshot-complete
|
||||
// sentinel, transition, then complete the feed so the CLI command
|
||||
// drains a bounded stream without contacting a live gateway.
|
||||
observer.onNext(AlarmFeedMessage.newBuilder()
|
||||
.setActiveAlarm(ActiveAlarmSnapshot.newBuilder()
|
||||
.setAlarmFullReference("Tank01.Level.HiHi")
|
||||
.setCurrentState(AlarmConditionState.ALARM_CONDITION_STATE_ACTIVE)
|
||||
.setSeverity(700))
|
||||
.build());
|
||||
observer.onNext(AlarmFeedMessage.newBuilder().setSnapshotComplete(true).build());
|
||||
observer.onNext(AlarmFeedMessage.newBuilder()
|
||||
.setTransition(OnAlarmTransitionEvent.newBuilder()
|
||||
.setAlarmFullReference("Tank01.Level.HiHi")
|
||||
.setTransitionKind(AlarmTransitionKind.ALARM_TRANSITION_KIND_ACKNOWLEDGE)
|
||||
.setSeverity(700))
|
||||
.build());
|
||||
observer.onCompleted();
|
||||
return new MxGatewayAlarmFeedSubscription();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
}
|
||||
@@ -295,6 +737,91 @@ final class MxGatewayCliTests {
|
||||
return results;
|
||||
}
|
||||
|
||||
// Recorded so tests can assert the CLI forwarded the parsed options through to
|
||||
// the session interface. The bulk subcommands return at least one result so the
|
||||
// JSON output assertions exercise the *Map serialisers in MxGatewayCli.
|
||||
|
||||
private int lastReadBulkTimeoutMs;
|
||||
private List<String> lastReadBulkItems = new ArrayList<>();
|
||||
private List<WriteBulkEntry> lastWriteBulkEntries = new ArrayList<>();
|
||||
private List<Write2BulkEntry> lastWrite2BulkEntries = new ArrayList<>();
|
||||
private List<WriteSecuredBulkEntry> lastWriteSecuredBulkEntries = new ArrayList<>();
|
||||
private List<WriteSecured2BulkEntry> lastWriteSecured2BulkEntries = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public List<BulkReadResult> readBulk(int serverHandle, List<String> items, int timeoutMs) {
|
||||
lastReadBulkTimeoutMs = timeoutMs;
|
||||
lastReadBulkItems = items;
|
||||
List<BulkReadResult> results = new ArrayList<>();
|
||||
for (int index = 0; index < items.size(); index++) {
|
||||
results.add(BulkReadResult.newBuilder()
|
||||
.setServerHandle(serverHandle)
|
||||
.setTagAddress(items.get(index))
|
||||
.setItemHandle(200 + index)
|
||||
.setWasSuccessful(true)
|
||||
.setWasCached(index % 2 == 0)
|
||||
.setQuality(192)
|
||||
.build());
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<BulkWriteResult> writeBulk(int serverHandle, List<WriteBulkEntry> entries) {
|
||||
lastWriteBulkEntries = entries;
|
||||
List<BulkWriteResult> results = new ArrayList<>();
|
||||
for (WriteBulkEntry entry : entries) {
|
||||
results.add(BulkWriteResult.newBuilder()
|
||||
.setServerHandle(serverHandle)
|
||||
.setItemHandle(entry.getItemHandle())
|
||||
.setWasSuccessful(true)
|
||||
.build());
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<BulkWriteResult> write2Bulk(int serverHandle, List<Write2BulkEntry> entries) {
|
||||
lastWrite2BulkEntries = entries;
|
||||
List<BulkWriteResult> results = new ArrayList<>();
|
||||
for (Write2BulkEntry entry : entries) {
|
||||
results.add(BulkWriteResult.newBuilder()
|
||||
.setServerHandle(serverHandle)
|
||||
.setItemHandle(entry.getItemHandle())
|
||||
.setWasSuccessful(true)
|
||||
.build());
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<BulkWriteResult> writeSecuredBulk(int serverHandle, List<WriteSecuredBulkEntry> entries) {
|
||||
lastWriteSecuredBulkEntries = entries;
|
||||
List<BulkWriteResult> results = new ArrayList<>();
|
||||
for (WriteSecuredBulkEntry entry : entries) {
|
||||
results.add(BulkWriteResult.newBuilder()
|
||||
.setServerHandle(serverHandle)
|
||||
.setItemHandle(entry.getItemHandle())
|
||||
.setWasSuccessful(true)
|
||||
.build());
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<BulkWriteResult> writeSecured2Bulk(int serverHandle, List<WriteSecured2BulkEntry> entries) {
|
||||
lastWriteSecured2BulkEntries = entries;
|
||||
List<BulkWriteResult> results = new ArrayList<>();
|
||||
for (WriteSecured2BulkEntry entry : entries) {
|
||||
results.add(BulkWriteResult.newBuilder()
|
||||
.setServerHandle(serverHandle)
|
||||
.setItemHandle(entry.getItemHandle())
|
||||
.setWasSuccessful(true)
|
||||
.build());
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
@Override
|
||||
public com.dohertylan.mxgateway.client.MxEventStream streamEventsAfter(long afterWorkerSequence) {
|
||||
throw new UnsupportedOperationException("stream-events is covered by client tests");
|
||||
|
||||
+50
-13
@@ -11,20 +11,29 @@ import java.util.NoSuchElementException;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* Iterator-style adaptor over the {@code WatchDeployEvents} server-streaming
|
||||
* RPC. Mirrors {@link MxEventStream}: events arrive on a background gRPC thread
|
||||
* and are buffered in a bounded blocking queue; the iterator drains them.
|
||||
* Closing the stream cancels the underlying gRPC call.
|
||||
*
|
||||
* <p><strong>Threading:</strong> the iterator methods ({@link #hasNext()} and
|
||||
* {@link #next()}) are <em>not</em> thread-safe and must be driven by a single
|
||||
* consumer thread. {@link #close()} may be called from any thread. Terminal
|
||||
* state transitions (queue overflow, server completion, and {@code close()})
|
||||
* are serialised so that the first terminal condition wins deterministically:
|
||||
* once an overflow exception has been observed it is never silently replaced
|
||||
* by an end-of-stream marker.
|
||||
*/
|
||||
public final class DeployEventStream implements Iterator<DeployEvent>, AutoCloseable {
|
||||
private static final Object END = new Object();
|
||||
|
||||
private final BlockingQueue<Object> queue;
|
||||
private final AtomicBoolean closed = new AtomicBoolean();
|
||||
private final Object terminalLock = new Object();
|
||||
private volatile ClientCallStreamObserver<WatchDeployEventsRequest> requestStream;
|
||||
private volatile boolean closed;
|
||||
private boolean terminated;
|
||||
private Object next;
|
||||
|
||||
DeployEventStream(int capacity) {
|
||||
@@ -36,7 +45,7 @@ public final class DeployEventStream implements Iterator<DeployEvent>, AutoClose
|
||||
@Override
|
||||
public void beforeStart(ClientCallStreamObserver<WatchDeployEventsRequest> requestStream) {
|
||||
DeployEventStream.this.requestStream = requestStream;
|
||||
if (closed.get()) {
|
||||
if (closed) {
|
||||
requestStream.cancel("client cancelled deploy event stream", null);
|
||||
}
|
||||
}
|
||||
@@ -48,7 +57,7 @@ public final class DeployEventStream implements Iterator<DeployEvent>, AutoClose
|
||||
|
||||
@Override
|
||||
public void onError(Throwable error) {
|
||||
if (Status.fromThrowable(error).getCode() == Status.Code.CANCELLED && closed.get()) {
|
||||
if (Status.fromThrowable(error).getCode() == Status.Code.CANCELLED && closed) {
|
||||
offer(END);
|
||||
return;
|
||||
}
|
||||
@@ -94,12 +103,12 @@ public final class DeployEventStream implements Iterator<DeployEvent>, AutoClose
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
closed.set(true);
|
||||
closed = true;
|
||||
ClientCallStreamObserver<WatchDeployEventsRequest> stream = requestStream;
|
||||
if (stream != null) {
|
||||
stream.cancel("client cancelled deploy event stream", null);
|
||||
}
|
||||
offer(END);
|
||||
terminate(null);
|
||||
}
|
||||
|
||||
private Object take() {
|
||||
@@ -117,10 +126,7 @@ public final class DeployEventStream implements Iterator<DeployEvent>, AutoClose
|
||||
private void offer(Object value) {
|
||||
Objects.requireNonNull(value, "value");
|
||||
if (value == END) {
|
||||
if (!queue.offer(value)) {
|
||||
queue.clear();
|
||||
queue.offer(value);
|
||||
}
|
||||
terminate(null);
|
||||
return;
|
||||
}
|
||||
if (!queue.offer(value)) {
|
||||
@@ -128,9 +134,40 @@ public final class DeployEventStream implements Iterator<DeployEvent>, AutoClose
|
||||
if (stream != null) {
|
||||
stream.cancel("client deploy event stream queue overflowed", null);
|
||||
}
|
||||
queue.clear();
|
||||
queue.offer(new MxGatewayException("galaxy watch deploy events queue overflowed"));
|
||||
queue.offer(END);
|
||||
terminate(new MxGatewayException("galaxy watch deploy events queue overflowed"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the single terminal transition. The first caller wins: a later
|
||||
* end-of-stream or {@code close()} cannot overwrite or discard an overflow
|
||||
* exception that has already been published to the consumer. Mirrors the
|
||||
* {@link MxEventStream#terminate} contract — see Client.Java-002 for the
|
||||
* race this guards against.
|
||||
*
|
||||
* @param fault the fault to surface to the consumer, or {@code null} for a
|
||||
* clean end-of-stream
|
||||
*/
|
||||
private void terminate(MxGatewayException fault) {
|
||||
synchronized (terminalLock) {
|
||||
if (terminated) {
|
||||
return;
|
||||
}
|
||||
terminated = true;
|
||||
if (fault != null) {
|
||||
// Make room for the fault marker; the consumer only needs the
|
||||
// terminal signal, queued data events are no longer relevant.
|
||||
queue.clear();
|
||||
queue.offer(fault);
|
||||
queue.offer(END);
|
||||
return;
|
||||
}
|
||||
// Clean end-of-stream: ensure the END marker is delivered even when
|
||||
// the queue is currently full of undrained data events.
|
||||
if (!queue.offer(END)) {
|
||||
queue.clear();
|
||||
queue.offer(END);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+81
-102
@@ -1,8 +1,5 @@
|
||||
package com.dohertylan.mxgateway.client;
|
||||
|
||||
import com.google.common.util.concurrent.FutureCallback;
|
||||
import com.google.common.util.concurrent.Futures;
|
||||
import com.google.common.util.concurrent.MoreExecutors;
|
||||
import galaxy_repository.v1.GalaxyRepositoryGrpc;
|
||||
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
|
||||
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply;
|
||||
@@ -17,8 +14,6 @@ import com.google.protobuf.Timestamp;
|
||||
import io.grpc.Channel;
|
||||
import io.grpc.ClientInterceptors;
|
||||
import io.grpc.ManagedChannel;
|
||||
import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts;
|
||||
import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder;
|
||||
import io.grpc.stub.StreamObserver;
|
||||
import java.time.Instant;
|
||||
import java.util.Iterator;
|
||||
@@ -26,8 +21,7 @@ import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import javax.net.ssl.SSLException;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
* Thin wrapper around the generated {@link GalaxyRepositoryGrpc} stubs that
|
||||
@@ -78,7 +72,8 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
|
||||
* @return a connected client
|
||||
*/
|
||||
public static GalaxyRepositoryClient connect(MxGatewayClientOptions options) {
|
||||
return new GalaxyRepositoryClient(createChannel(options), options);
|
||||
return new GalaxyRepositoryClient(
|
||||
MxGatewayChannels.createChannel(options, "failed to configure galaxy repository TLS"), options);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,7 +82,7 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
|
||||
* @return the blocking stub
|
||||
*/
|
||||
public GalaxyRepositoryGrpc.GalaxyRepositoryBlockingStub rawBlockingStub() {
|
||||
return withDeadline(blockingStub);
|
||||
return MxGatewayChannels.withDeadline(blockingStub, options);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,7 +91,7 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
|
||||
* @return the future stub
|
||||
*/
|
||||
public GalaxyRepositoryGrpc.GalaxyRepositoryFutureStub rawFutureStub() {
|
||||
return withDeadline(futureStub);
|
||||
return MxGatewayChannels.withDeadline(futureStub, options);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,8 +128,14 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
|
||||
* exceptionally with {@link MxGatewayException} on failure
|
||||
*/
|
||||
public CompletableFuture<Boolean> testConnectionAsync() {
|
||||
return toCompletable(rawFutureStub().testConnection(TestConnectionRequest.getDefaultInstance()))
|
||||
.thenApply(TestConnectionReply::getOk);
|
||||
// Apply the projection inside toCompletable rather than via .thenApply
|
||||
// so the user-visible future is the same future cancellation is bound
|
||||
// to; a downstream .thenApply stage would not forward cancel() to the
|
||||
// source RPC.
|
||||
return MxGatewayChannels.toCompletable(
|
||||
rawFutureStub().testConnection(TestConnectionRequest.getDefaultInstance()),
|
||||
"galaxy test connection",
|
||||
TestConnectionReply::getOk);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,8 +166,10 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
|
||||
* completed exceptionally with {@link MxGatewayException} on failure
|
||||
*/
|
||||
public CompletableFuture<Optional<Instant>> getLastDeployTimeAsync() {
|
||||
return toCompletable(rawFutureStub().getLastDeployTime(GetLastDeployTimeRequest.getDefaultInstance()))
|
||||
.thenApply(GalaxyRepositoryClient::mapDeployTime);
|
||||
return MxGatewayChannels.toCompletable(
|
||||
rawFutureStub().getLastDeployTime(GetLastDeployTimeRequest.getDefaultInstance()),
|
||||
"galaxy get last deploy time",
|
||||
GalaxyRepositoryClient::mapDeployTime);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -210,7 +213,33 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
|
||||
* exceptionally with {@link MxGatewayException} on failure
|
||||
*/
|
||||
public CompletableFuture<List<GalaxyObject>> discoverHierarchyAsync() {
|
||||
return discoverHierarchyPageAsync("", new java.util.ArrayList<>(), new java.util.HashSet<>());
|
||||
// The recursive page chain produces a fresh in-flight RPC per page.
|
||||
// Track the current in-flight stage in an AtomicReference and return a
|
||||
// user-facing future whose cancel() forwards to that current stage —
|
||||
// otherwise cancelling the chained CompletableFuture would not abort
|
||||
// the in-flight gRPC call. Without this, .thenCompose creates new
|
||||
// stages whose cancel() does not propagate upstream.
|
||||
AtomicReference<CompletableFuture<?>> currentStage = new AtomicReference<>();
|
||||
CompletableFuture<List<GalaxyObject>> userFuture = new CompletableFuture<>() {
|
||||
@Override
|
||||
public boolean cancel(boolean mayInterruptIfRunning) {
|
||||
boolean cancelled = super.cancel(mayInterruptIfRunning);
|
||||
CompletableFuture<?> stage = currentStage.get();
|
||||
if (stage != null) {
|
||||
stage.cancel(mayInterruptIfRunning);
|
||||
}
|
||||
return cancelled;
|
||||
}
|
||||
};
|
||||
discoverHierarchyPageAsync("", new java.util.ArrayList<>(), new java.util.HashSet<>(), currentStage)
|
||||
.whenComplete((result, error) -> {
|
||||
if (error != null) {
|
||||
userFuture.completeExceptionally(error);
|
||||
} else {
|
||||
userFuture.complete(result);
|
||||
}
|
||||
});
|
||||
return userFuture;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -224,7 +253,8 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
|
||||
*/
|
||||
public DeployEventStream watchDeployEvents(Instant lastSeenDeployTime) {
|
||||
DeployEventStream stream = new DeployEventStream(16);
|
||||
withStreamDeadline(rawAsyncStub()).watchDeployEvents(buildWatchRequest(lastSeenDeployTime), stream.observer());
|
||||
MxGatewayChannels.withStreamDeadline(rawAsyncStub(), options)
|
||||
.watchDeployEvents(buildWatchRequest(lastSeenDeployTime), stream.observer());
|
||||
return stream;
|
||||
}
|
||||
|
||||
@@ -253,7 +283,7 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
|
||||
Instant lastSeenDeployTime, StreamObserver<DeployEvent> observer) {
|
||||
Objects.requireNonNull(observer, "observer");
|
||||
DeployEventSubscription subscription = new DeployEventSubscription();
|
||||
withStreamDeadline(rawAsyncStub())
|
||||
MxGatewayChannels.withStreamDeadline(rawAsyncStub(), options)
|
||||
.watchDeployEvents(buildWatchRequest(lastSeenDeployTime), subscription.wrap(observer));
|
||||
return subscription;
|
||||
}
|
||||
@@ -269,34 +299,35 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
private <T extends io.grpc.stub.AbstractStub<T>> T withStreamDeadline(T stub) {
|
||||
if (options.streamTimeout() == null || options.streamTimeout().isNegative()) {
|
||||
return stub;
|
||||
}
|
||||
return stub.withDeadlineAfter(options.streamTimeout().toNanos(), TimeUnit.NANOSECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuts the owned channel down and awaits termination so try-with-resources
|
||||
* callers do not leave in-flight calls or Netty event-loop threads running
|
||||
* after the block exits.
|
||||
*
|
||||
* <p>Waits up to {@link MxGatewayClientOptions#shutdownTimeout()} for
|
||||
* graceful termination and forcibly shuts the channel down on timeout. If
|
||||
* the calling thread is interrupted while waiting, the channel is forcibly
|
||||
* shut down and the thread's interrupt flag is restored. No-op for clients
|
||||
* that do not own their channel. For an explicitly checked, blocking-aware
|
||||
* shutdown call {@link #closeAndAwaitTermination()}. Delegates to the
|
||||
* shared {@link MxGatewayChannels#shutdown} so behavior stays in lockstep
|
||||
* with {@link MxGatewayClient}.
|
||||
*/
|
||||
@Override
|
||||
public void close() {
|
||||
if (ownedChannel != null) {
|
||||
ownedChannel.shutdown();
|
||||
}
|
||||
MxGatewayChannels.shutdown(ownedChannel, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuts the owned channel down and waits up to the configured connect
|
||||
* timeout for termination, forcibly shutting it down on timeout. No-op
|
||||
* for clients that do not own their channel.
|
||||
* Shuts the owned channel down and waits up to
|
||||
* {@link MxGatewayClientOptions#shutdownTimeout()} for termination,
|
||||
* forcibly shutting it down on timeout. No-op for clients that do not own
|
||||
* their channel.
|
||||
*
|
||||
* @throws InterruptedException if the calling thread is interrupted while waiting
|
||||
*/
|
||||
public void closeAndAwaitTermination() throws InterruptedException {
|
||||
if (ownedChannel != null) {
|
||||
ownedChannel.shutdown();
|
||||
if (!ownedChannel.awaitTermination(options.connectTimeout().toMillis(), TimeUnit.MILLISECONDS)) {
|
||||
ownedChannel.shutdownNow();
|
||||
}
|
||||
}
|
||||
MxGatewayChannels.shutdownAndAwaitTermination(ownedChannel, options);
|
||||
}
|
||||
|
||||
private static Optional<Instant> mapDeployTime(GetLastDeployTimeReply reply) {
|
||||
@@ -307,47 +338,22 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
|
||||
return Optional.of(Instant.ofEpochSecond(ts.getSeconds(), ts.getNanos()));
|
||||
}
|
||||
|
||||
private static ManagedChannel createChannel(MxGatewayClientOptions options) {
|
||||
NettyChannelBuilder builder = NettyChannelBuilder.forTarget(options.endpoint())
|
||||
.maxInboundMessageSize(options.maxGrpcMessageBytes());
|
||||
if (!options.connectTimeout().isNegative()) {
|
||||
builder.withOption(
|
||||
io.grpc.netty.shaded.io.netty.channel.ChannelOption.CONNECT_TIMEOUT_MILLIS,
|
||||
Math.toIntExact(options.connectTimeout().toMillis()));
|
||||
}
|
||||
if (options.plaintext()) {
|
||||
builder.usePlaintext();
|
||||
} else if (options.caCertificatePath() != null) {
|
||||
try {
|
||||
builder.sslContext(GrpcSslContexts.forClient()
|
||||
.trustManager(options.caCertificatePath().toFile())
|
||||
.build());
|
||||
} catch (SSLException error) {
|
||||
throw new MxGatewayException("failed to configure galaxy repository TLS", error);
|
||||
}
|
||||
} else {
|
||||
builder.useTransportSecurity();
|
||||
}
|
||||
if (!options.serverNameOverride().isBlank()) {
|
||||
builder.overrideAuthority(options.serverNameOverride());
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
private <T extends io.grpc.stub.AbstractStub<T>> T withDeadline(T stub) {
|
||||
if (options.callTimeout().isNegative()) {
|
||||
return stub;
|
||||
}
|
||||
return stub.withDeadlineAfter(options.callTimeout().toNanos(), TimeUnit.NANOSECONDS);
|
||||
}
|
||||
|
||||
private CompletableFuture<List<GalaxyObject>> discoverHierarchyPageAsync(
|
||||
String pageToken, java.util.ArrayList<GalaxyObject> objects, java.util.HashSet<String> seenPageTokens) {
|
||||
String pageToken,
|
||||
java.util.ArrayList<GalaxyObject> objects,
|
||||
java.util.HashSet<String> seenPageTokens,
|
||||
AtomicReference<CompletableFuture<?>> currentStage) {
|
||||
DiscoverHierarchyRequest request = DiscoverHierarchyRequest.newBuilder()
|
||||
.setPageSize(DISCOVER_HIERARCHY_PAGE_SIZE)
|
||||
.setPageToken(pageToken)
|
||||
.build();
|
||||
return toCompletable(rawFutureStub().discoverHierarchy(request)).thenCompose(reply -> {
|
||||
CompletableFuture<DiscoverHierarchyReply> pageFuture = MxGatewayChannels.toCompletable(
|
||||
rawFutureStub().discoverHierarchy(request), "galaxy discover hierarchy");
|
||||
// Publish the in-flight page future so a user cancellation can abort
|
||||
// the current outstanding RPC (the recursion replaces this reference
|
||||
// before each subsequent page).
|
||||
currentStage.set(pageFuture);
|
||||
return pageFuture.thenCompose(reply -> {
|
||||
objects.addAll(reply.getObjectsList());
|
||||
if (reply.getNextPageToken().isBlank()) {
|
||||
return CompletableFuture.completedFuture(objects);
|
||||
@@ -355,38 +361,11 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
|
||||
if (!seenPageTokens.add(reply.getNextPageToken())) {
|
||||
CompletableFuture<List<GalaxyObject>> failed = new CompletableFuture<>();
|
||||
failed.completeExceptionally(new MxGatewayException(
|
||||
"galaxy discover hierarchy returned repeated page token: " + reply.getNextPageToken()));
|
||||
"galaxy discover hierarchy returned repeated page token: "
|
||||
+ reply.getNextPageToken()));
|
||||
return failed;
|
||||
}
|
||||
return discoverHierarchyPageAsync(reply.getNextPageToken(), objects, seenPageTokens);
|
||||
return discoverHierarchyPageAsync(reply.getNextPageToken(), objects, seenPageTokens, currentStage);
|
||||
});
|
||||
}
|
||||
|
||||
private static <T> CompletableFuture<T> toCompletable(com.google.common.util.concurrent.ListenableFuture<T> source) {
|
||||
CompletableFuture<T> target = new CompletableFuture<>();
|
||||
Futures.addCallback(
|
||||
source,
|
||||
new FutureCallback<>() {
|
||||
@Override
|
||||
public void onSuccess(T result) {
|
||||
target.complete(result);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(Throwable error) {
|
||||
if (error instanceof RuntimeException runtimeException) {
|
||||
target.completeExceptionally(MxGatewayErrors.fromGrpc("galaxy async call", runtimeException));
|
||||
return;
|
||||
}
|
||||
target.completeExceptionally(error);
|
||||
}
|
||||
},
|
||||
MoreExecutors.directExecutor());
|
||||
target.whenComplete((ignoredResult, ignoredError) -> {
|
||||
if (target.isCancelled()) {
|
||||
source.cancel(true);
|
||||
}
|
||||
});
|
||||
return target;
|
||||
}
|
||||
}
|
||||
|
||||
+68
-8
@@ -21,13 +21,38 @@ import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
|
||||
* stream cancels the underlying gRPC call. If the queue overflows the call is
|
||||
* cancelled and a follow-up call to {@link #next()} throws
|
||||
* {@link MxGatewayException}.
|
||||
*
|
||||
* <p><strong>Backpressure (fail-fast):</strong> this adaptor relies on gRPC's
|
||||
* default auto-inbound flow control — the async stub auto-requests messages, so
|
||||
* the gateway can push events faster than the consumer drains the bounded
|
||||
* 1024-element buffer (the buffer capacity is a constructor parameter; the
|
||||
* production caller {@code MxGatewayClient.streamEvents} passes {@code 1024} to
|
||||
* absorb the gateway's session-backlog replay burst). There is intentionally
|
||||
* <em>no</em> real client flow control: a consumer that stalls long enough to
|
||||
* let the buffer fill triggers an immediate overflow that cancels the
|
||||
* subscription and surfaces an {@link MxGatewayException} on the next
|
||||
* {@link #next()} call. This matches the gateway's documented fail-fast
|
||||
* event-backpressure design — a slow consumer loses its subscription rather
|
||||
* than silently dropping events. Consumers that cannot keep up must drain
|
||||
* {@link #next()} promptly (e.g. hand events to their own larger queue) and be
|
||||
* prepared to resubscribe with a resume cursor.
|
||||
*
|
||||
* <p><strong>Threading:</strong> the iterator methods ({@link #hasNext()} and
|
||||
* {@link #next()}) are <em>not</em> thread-safe and must be driven by a single
|
||||
* consumer thread. {@link #close()} may be called from any thread. Terminal
|
||||
* state transitions (queue overflow, server completion, and {@code close()})
|
||||
* are serialised so that the first terminal condition wins deterministically:
|
||||
* once an overflow exception has been observed it is never silently replaced
|
||||
* by an end-of-stream marker.
|
||||
*/
|
||||
public final class MxEventStream implements Iterator<MxEvent>, AutoCloseable {
|
||||
private static final Object END = new Object();
|
||||
|
||||
private final BlockingQueue<Object> queue;
|
||||
private final Object terminalLock = new Object();
|
||||
private volatile ClientCallStreamObserver<StreamEventsRequest> requestStream;
|
||||
private volatile boolean closed;
|
||||
private boolean terminated;
|
||||
private Object next;
|
||||
|
||||
MxEventStream(int capacity) {
|
||||
@@ -38,7 +63,16 @@ public final class MxEventStream implements Iterator<MxEvent>, AutoCloseable {
|
||||
return new ClientResponseObserver<>() {
|
||||
@Override
|
||||
public void beforeStart(ClientCallStreamObserver<StreamEventsRequest> requestStream) {
|
||||
// Resolve the close()/beforeStart() race the same way DeployEventStream does:
|
||||
// store the request stream first, then check the close flag and cancel the
|
||||
// call if a prior close() already fired. Without this, a close() that ran
|
||||
// before the gRPC call attached its ClientCallStreamObserver would skip
|
||||
// stream.cancel() (because requestStream is still null) and beforeStart()
|
||||
// arriving afterwards would leak the underlying call open.
|
||||
MxEventStream.this.requestStream = requestStream;
|
||||
if (closed) {
|
||||
requestStream.cancel("client cancelled event stream", null);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -98,7 +132,7 @@ public final class MxEventStream implements Iterator<MxEvent>, AutoCloseable {
|
||||
if (stream != null) {
|
||||
stream.cancel("client cancelled event stream", null);
|
||||
}
|
||||
offer(END);
|
||||
terminate(null);
|
||||
}
|
||||
|
||||
private Object take() {
|
||||
@@ -115,10 +149,7 @@ public final class MxEventStream implements Iterator<MxEvent>, AutoCloseable {
|
||||
private void offer(Object value) {
|
||||
Objects.requireNonNull(value, "value");
|
||||
if (value == END) {
|
||||
if (!queue.offer(value)) {
|
||||
queue.clear();
|
||||
queue.offer(value);
|
||||
}
|
||||
terminate(null);
|
||||
return;
|
||||
}
|
||||
if (!queue.offer(value)) {
|
||||
@@ -126,9 +157,38 @@ public final class MxEventStream implements Iterator<MxEvent>, AutoCloseable {
|
||||
if (stream != null) {
|
||||
stream.cancel("client event stream queue overflowed", null);
|
||||
}
|
||||
queue.clear();
|
||||
queue.offer(new MxGatewayException("gateway stream events queue overflowed"));
|
||||
queue.offer(END);
|
||||
terminate(new MxGatewayException("gateway stream events queue overflowed"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the single terminal transition. The first caller wins: a later
|
||||
* end-of-stream or {@code close()} cannot overwrite or discard an overflow
|
||||
* exception that has already been published to the consumer.
|
||||
*
|
||||
* @param fault the fault to surface to the consumer, or {@code null} for a
|
||||
* clean end-of-stream
|
||||
*/
|
||||
private void terminate(MxGatewayException fault) {
|
||||
synchronized (terminalLock) {
|
||||
if (terminated) {
|
||||
return;
|
||||
}
|
||||
terminated = true;
|
||||
if (fault != null) {
|
||||
// Make room for the fault marker; the consumer only needs the
|
||||
// terminal signal, queued data events are no longer relevant.
|
||||
queue.clear();
|
||||
queue.offer(fault);
|
||||
queue.offer(END);
|
||||
return;
|
||||
}
|
||||
// Clean end-of-stream: ensure the END marker is delivered even when
|
||||
// the queue is currently full of undrained data events.
|
||||
if (!queue.offer(END)) {
|
||||
queue.clear();
|
||||
queue.offer(END);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package com.dohertylan.mxgateway.client;
|
||||
|
||||
import io.grpc.stub.ClientCallStreamObserver;
|
||||
import io.grpc.stub.ClientResponseObserver;
|
||||
import io.grpc.stub.StreamObserver;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest;
|
||||
|
||||
/**
|
||||
* Cancellable handle returned by {@code streamAlarms}.
|
||||
*
|
||||
* <p>Wraps a caller-supplied {@link StreamObserver} and exposes a
|
||||
* {@link #cancel()} entry point that aborts the underlying gRPC call. The
|
||||
* subscription also implements {@link AutoCloseable} so it can participate in
|
||||
* try-with-resources blocks.
|
||||
*/
|
||||
public final class MxGatewayAlarmFeedSubscription implements AutoCloseable {
|
||||
private final AtomicReference<ClientCallStreamObserver<StreamAlarmsRequest>> requestStream = new AtomicReference<>();
|
||||
private final AtomicBoolean cancelled = new AtomicBoolean();
|
||||
|
||||
ClientResponseObserver<StreamAlarmsRequest, AlarmFeedMessage> wrap(StreamObserver<AlarmFeedMessage> observer) {
|
||||
return new ClientResponseObserver<>() {
|
||||
@Override
|
||||
public void beforeStart(ClientCallStreamObserver<StreamAlarmsRequest> stream) {
|
||||
requestStream.set(stream);
|
||||
if (cancelled.get()) {
|
||||
stream.cancel("client cancelled alarm feed", null);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNext(AlarmFeedMessage value) {
|
||||
observer.onNext(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable error) {
|
||||
observer.onError(error);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCompleted() {
|
||||
observer.onCompleted();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the underlying gRPC call. Safe to invoke before the call has
|
||||
* started; cancellation is recorded and applied as soon as the stream
|
||||
* attaches.
|
||||
*/
|
||||
public void cancel() {
|
||||
cancelled.set(true);
|
||||
ClientCallStreamObserver<StreamAlarmsRequest> stream = requestStream.get();
|
||||
if (stream != null) {
|
||||
stream.cancel("client cancelled alarm feed", null);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
cancel();
|
||||
}
|
||||
}
|
||||
+321
@@ -0,0 +1,321 @@
|
||||
package com.dohertylan.mxgateway.client;
|
||||
|
||||
import com.google.common.util.concurrent.FutureCallback;
|
||||
import com.google.common.util.concurrent.Futures;
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
import com.google.common.util.concurrent.MoreExecutors;
|
||||
import io.grpc.ManagedChannel;
|
||||
import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts;
|
||||
import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder;
|
||||
import io.grpc.stub.AbstractStub;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Function;
|
||||
import javax.net.ssl.SSLException;
|
||||
|
||||
/**
|
||||
* Shared channel-builder and future-adaptor helpers used by both
|
||||
* {@link MxGatewayClient} and {@link GalaxyRepositoryClient}.
|
||||
*
|
||||
* <p>Extracted so transport construction, per-call deadlines, and the
|
||||
* {@link ListenableFuture}-to-{@link CompletableFuture} bridge live in one
|
||||
* place instead of being duplicated verbatim across the two clients.
|
||||
*/
|
||||
final class MxGatewayChannels {
|
||||
private MxGatewayChannels() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a Netty managed channel from the supplied options, applying the
|
||||
* connect timeout, message-size limit, and the configured transport
|
||||
* security mode (plaintext, custom CA trust, or system trust).
|
||||
*
|
||||
* @param options the client options carrying endpoint and transport config
|
||||
* @param tlsErrorPrefix a human-readable prefix for the {@link MxGatewayException}
|
||||
* thrown when a custom CA certificate cannot be loaded
|
||||
* @return a new managed channel; the caller owns its lifecycle
|
||||
*/
|
||||
static ManagedChannel createChannel(MxGatewayClientOptions options, String tlsErrorPrefix) {
|
||||
NettyChannelBuilder builder = NettyChannelBuilder.forTarget(options.endpoint())
|
||||
.maxInboundMessageSize(options.maxGrpcMessageBytes());
|
||||
if (!options.connectTimeout().isNegative()) {
|
||||
builder.withOption(
|
||||
io.grpc.netty.shaded.io.netty.channel.ChannelOption.CONNECT_TIMEOUT_MILLIS,
|
||||
Math.toIntExact(options.connectTimeout().toMillis()));
|
||||
}
|
||||
if (options.plaintext()) {
|
||||
builder.usePlaintext();
|
||||
} else if (options.caCertificatePath() != null) {
|
||||
try {
|
||||
builder.sslContext(GrpcSslContexts.forClient()
|
||||
.trustManager(options.caCertificatePath().toFile())
|
||||
.build());
|
||||
} catch (SSLException | RuntimeException error) {
|
||||
// SSLException covers handshake-context failures; RuntimeException
|
||||
// (IllegalArgumentException wrapping CertificateException) covers a
|
||||
// missing or unreadable CA file. Either way callers see one typed
|
||||
// failure instead of a raw, unwrapped exception leaking out.
|
||||
throw new MxGatewayException(tlsErrorPrefix, error);
|
||||
}
|
||||
} else {
|
||||
builder.useTransportSecurity();
|
||||
}
|
||||
if (!options.serverNameOverride().isBlank()) {
|
||||
builder.overrideAuthority(options.serverNameOverride());
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the configured per-call deadline to a unary stub.
|
||||
*
|
||||
* @param stub the stub to decorate
|
||||
* @param options the client options carrying the call timeout
|
||||
* @param <T> the concrete stub type
|
||||
* @return the stub with the call deadline applied, or the stub unchanged
|
||||
* when the call timeout is negative (disabled)
|
||||
*/
|
||||
static <T extends AbstractStub<T>> T withDeadline(T stub, MxGatewayClientOptions options) {
|
||||
if (options.callTimeout().isNegative()) {
|
||||
return stub;
|
||||
}
|
||||
return stub.withDeadlineAfter(options.callTimeout().toNanos(), TimeUnit.NANOSECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the configured streaming deadline to a streaming stub.
|
||||
*
|
||||
* @param stub the stub to decorate
|
||||
* @param options the client options carrying the stream timeout
|
||||
* @param <T> the concrete stub type
|
||||
* @return the stub with the stream deadline applied, or the stub unchanged
|
||||
* when the stream timeout is unset or negative (disabled)
|
||||
*/
|
||||
static <T extends AbstractStub<T>> T withStreamDeadline(T stub, MxGatewayClientOptions options) {
|
||||
if (options.streamTimeout() == null || options.streamTimeout().isNegative()) {
|
||||
return stub;
|
||||
}
|
||||
return stub.withDeadlineAfter(options.streamTimeout().toNanos(), TimeUnit.NANOSECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuts a client-owned channel down and waits up to the configured
|
||||
* {@link MxGatewayClientOptions#shutdownTimeout()} for graceful
|
||||
* termination, forcing {@code shutdownNow()} on timeout. If the calling
|
||||
* thread is interrupted while waiting, the channel is forcibly shut down
|
||||
* and the thread's interrupt flag is restored — this matches the
|
||||
* try-with-resources {@code close()} contract that cannot throw a checked
|
||||
* exception.
|
||||
*
|
||||
* <p>No-op when {@code ownedChannel} is {@code null} (i.e. the caller owns
|
||||
* the channel lifecycle on a borrowed channel).
|
||||
*
|
||||
* @param ownedChannel the channel to shut down, may be {@code null}
|
||||
* @param options the client options carrying the shutdown timeout
|
||||
*/
|
||||
static void shutdown(ManagedChannel ownedChannel, MxGatewayClientOptions options) {
|
||||
if (ownedChannel == null) {
|
||||
return;
|
||||
}
|
||||
ownedChannel.shutdown();
|
||||
try {
|
||||
if (!ownedChannel.awaitTermination(options.shutdownTimeout().toMillis(), TimeUnit.MILLISECONDS)) {
|
||||
ownedChannel.shutdownNow();
|
||||
}
|
||||
} catch (InterruptedException error) {
|
||||
ownedChannel.shutdownNow();
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuts a client-owned channel down and waits up to the configured
|
||||
* {@link MxGatewayClientOptions#shutdownTimeout()} for termination,
|
||||
* forcing {@code shutdownNow()} on timeout. Throws
|
||||
* {@link InterruptedException} when the calling thread is interrupted —
|
||||
* for callers that want a checked, blocking-aware shutdown.
|
||||
*
|
||||
* <p>No-op when {@code ownedChannel} is {@code null}.
|
||||
*
|
||||
* @param ownedChannel the channel to shut down, may be {@code null}
|
||||
* @param options the client options carrying the shutdown timeout
|
||||
* @throws InterruptedException if the calling thread is interrupted while waiting
|
||||
*/
|
||||
static void shutdownAndAwaitTermination(ManagedChannel ownedChannel, MxGatewayClientOptions options)
|
||||
throws InterruptedException {
|
||||
if (ownedChannel == null) {
|
||||
return;
|
||||
}
|
||||
ownedChannel.shutdown();
|
||||
if (!ownedChannel.awaitTermination(options.shutdownTimeout().toMillis(), TimeUnit.MILLISECONDS)) {
|
||||
ownedChannel.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridges a Guava {@link ListenableFuture} to a {@link CompletableFuture},
|
||||
* normalising any failure through {@link MxGatewayErrors#fromGrpc} so the
|
||||
* async error surface matches the synchronous methods. Cancelling the
|
||||
* returned future cancels the source RPC.
|
||||
*
|
||||
* <p><strong>Cancellation contract:</strong> the returned future is a
|
||||
* {@link CancellingCompletableFuture} that overrides
|
||||
* {@link CompletableFuture#cancel(boolean)} so cancelling the
|
||||
* <em>direct return value</em> forwards to the source
|
||||
* {@link ListenableFuture}, aborting the underlying gRPC call. This is the
|
||||
* fix for Client.Java-015.
|
||||
*
|
||||
* <p><strong>Important — derived stages do <em>not</em> propagate
|
||||
* cancellation upstream.</strong> Calling
|
||||
* {@code cancel(...)} on a future obtained via
|
||||
* {@code thenApply}/{@code thenCompose}/{@code thenAccept}/{@code whenComplete}
|
||||
* of the value returned by this method only marks <em>that</em> derived stage
|
||||
* as cancelled; it does <strong>not</strong> propagate back to this
|
||||
* {@code CancellingCompletableFuture}, so the source RPC continues until its
|
||||
* deadline expires. {@link CompletableFuture#thenApply} (and the other
|
||||
* chaining methods) deliberately do not forward cancellation to the upstream
|
||||
* stage they were derived from.
|
||||
*
|
||||
* <p>If a caller needs cancellation through a chained pipeline, either:
|
||||
* <ul>
|
||||
* <li>use the {@link #toCompletable(ListenableFuture, String, Function)}
|
||||
* overload below, which inlines a validator into the
|
||||
* {@code FutureCallback} so the user-visible future is the same
|
||||
* future cancellation is bound to (this is what the {@code *Async}
|
||||
* methods on {@link MxGatewayClient} and the unary methods on
|
||||
* {@link GalaxyRepositoryClient} do); or</li>
|
||||
* <li>follow {@link GalaxyRepositoryClient#discoverHierarchyAsync}'s
|
||||
* pattern of returning a custom {@link CompletableFuture} subclass
|
||||
* that tracks the current in-flight stage via an
|
||||
* {@link java.util.concurrent.atomic.AtomicReference} and forwards
|
||||
* {@code cancel(...)} to it (necessary when chaining
|
||||
* {@code thenCompose} stages across paged calls).</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param source the gRPC future-stub result
|
||||
* @param operation the operation name used in normalised error messages
|
||||
* @param <T> the reply type
|
||||
* @return a completable future mirroring the source
|
||||
*/
|
||||
static <T> CompletableFuture<T> toCompletable(ListenableFuture<T> source, String operation) {
|
||||
CancellingCompletableFuture<T> target = new CancellingCompletableFuture<>(source);
|
||||
Futures.addCallback(
|
||||
source,
|
||||
new FutureCallback<>() {
|
||||
@Override
|
||||
public void onSuccess(T result) {
|
||||
target.complete(result);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(Throwable error) {
|
||||
if (error instanceof RuntimeException runtimeException) {
|
||||
target.completeExceptionally(MxGatewayErrors.fromGrpc(operation, runtimeException));
|
||||
return;
|
||||
}
|
||||
target.completeExceptionally(error);
|
||||
}
|
||||
},
|
||||
MoreExecutors.directExecutor());
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridges a Guava {@link ListenableFuture} to a {@link CompletableFuture}
|
||||
* and applies {@code validator} to the reply inline (i.e. without a
|
||||
* downstream {@code thenApply}), so the user-visible future is the same
|
||||
* future cancellation is bound to. Any non-{@link MxGatewayException}
|
||||
* {@link RuntimeException} thrown by {@code validator} is routed through
|
||||
* {@link MxGatewayErrors#fromGrpc} to match the synchronous error surface.
|
||||
*
|
||||
* <p>This overload exists because the prior {@code toCompletable(...)
|
||||
* .thenApply(validator)} pattern broke cancellation propagation: the
|
||||
* future returned by {@code thenApply} is a new stage whose cancellation
|
||||
* does not propagate to the underlying gRPC call. Using this overload, the
|
||||
* single returned future is the one users hold, so calling {@code cancel}
|
||||
* on it forwards to the source RPC.
|
||||
*
|
||||
* @param source the gRPC future-stub result
|
||||
* @param operation the operation name used in normalised error messages
|
||||
* @param validator the validating/transforming function applied to the reply
|
||||
* @param <T> the reply type
|
||||
* @param <R> the validated/transformed result type
|
||||
* @return a completable future mirroring the validated source
|
||||
*/
|
||||
static <T, R> CompletableFuture<R> toCompletable(
|
||||
ListenableFuture<T> source, String operation, Function<T, R> validator) {
|
||||
CancellingCompletableFuture<R> target = new CancellingCompletableFuture<>(source);
|
||||
Futures.addCallback(
|
||||
source,
|
||||
new FutureCallback<>() {
|
||||
@Override
|
||||
public void onSuccess(T result) {
|
||||
try {
|
||||
target.complete(validator.apply(result));
|
||||
} catch (MxGatewayException error) {
|
||||
target.completeExceptionally(error);
|
||||
} catch (RuntimeException error) {
|
||||
target.completeExceptionally(MxGatewayErrors.fromGrpc(operation, error));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(Throwable error) {
|
||||
if (error instanceof RuntimeException runtimeException) {
|
||||
target.completeExceptionally(MxGatewayErrors.fromGrpc(operation, runtimeException));
|
||||
return;
|
||||
}
|
||||
target.completeExceptionally(error);
|
||||
}
|
||||
},
|
||||
MoreExecutors.directExecutor());
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link CompletableFuture} subclass that forwards {@link #cancel(boolean)}
|
||||
* to a backing {@link ListenableFuture}. Used by {@link #toCompletable} so
|
||||
* cancelling the user-visible future cancels the underlying gRPC call.
|
||||
*/
|
||||
static final class CancellingCompletableFuture<T> extends CompletableFuture<T> {
|
||||
private final ListenableFuture<?> source;
|
||||
|
||||
CancellingCompletableFuture(ListenableFuture<?> source) {
|
||||
this.source = source;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean cancel(boolean mayInterruptIfRunning) {
|
||||
boolean cancelled = super.cancel(mayInterruptIfRunning);
|
||||
// Always forward; the source future is idempotent on cancel and the
|
||||
// user contract is that cancelling the future cancels the RPC.
|
||||
source.cancel(mayInterruptIfRunning);
|
||||
return cancelled;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapts a reply-validating function for use inside {@code thenApply} so
|
||||
* any non-{@link MxGatewayException} {@link RuntimeException} it raises is
|
||||
* routed through {@link MxGatewayErrors#fromGrpc}. This keeps the async
|
||||
* error surface consistent with the synchronous methods, which normalise
|
||||
* failures with a {@code try/catch}.
|
||||
*
|
||||
* @param operation the operation name used in normalised error messages
|
||||
* @param validator the validating/transforming function applied to the reply
|
||||
* @param <T> the reply type
|
||||
* @param <R> the result type
|
||||
* @return a function suitable for {@link CompletableFuture#thenApply}
|
||||
*/
|
||||
static <T, R> Function<T, R> normalisingValidator(String operation, Function<T, R> validator) {
|
||||
return reply -> {
|
||||
try {
|
||||
return validator.apply(reply);
|
||||
} catch (MxGatewayException error) {
|
||||
throw error;
|
||||
} catch (RuntimeException error) {
|
||||
throw MxGatewayErrors.fromGrpc(operation, error);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+131
-102
@@ -1,20 +1,16 @@
|
||||
package com.dohertylan.mxgateway.client;
|
||||
|
||||
import com.google.common.util.concurrent.FutureCallback;
|
||||
import com.google.common.util.concurrent.Futures;
|
||||
import com.google.common.util.concurrent.MoreExecutors;
|
||||
import com.google.protobuf.Duration;
|
||||
import io.grpc.Channel;
|
||||
import io.grpc.ClientInterceptors;
|
||||
import io.grpc.ManagedChannel;
|
||||
import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts;
|
||||
import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder;
|
||||
import io.grpc.stub.StreamObserver;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import javax.net.ssl.SSLException;
|
||||
import mxaccess_gateway.v1.MxAccessGatewayGrpc;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
|
||||
@@ -23,6 +19,7 @@ import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
|
||||
|
||||
/**
|
||||
@@ -75,7 +72,8 @@ public final class MxGatewayClient implements AutoCloseable {
|
||||
* @return a connected client
|
||||
*/
|
||||
public static MxGatewayClient connect(MxGatewayClientOptions options) {
|
||||
return new MxGatewayClient(createChannel(options), options);
|
||||
return new MxGatewayClient(
|
||||
MxGatewayChannels.createChannel(options, "failed to configure gateway TLS"), options);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,7 +82,7 @@ public final class MxGatewayClient implements AutoCloseable {
|
||||
* @return the blocking stub
|
||||
*/
|
||||
public MxAccessGatewayGrpc.MxAccessGatewayBlockingStub rawBlockingStub() {
|
||||
return withDeadline(blockingStub);
|
||||
return MxGatewayChannels.withDeadline(blockingStub, options);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,7 +91,7 @@ public final class MxGatewayClient implements AutoCloseable {
|
||||
* @return the future stub
|
||||
*/
|
||||
public MxAccessGatewayGrpc.MxAccessGatewayFutureStub rawFutureStub() {
|
||||
return withDeadline(futureStub);
|
||||
return MxGatewayChannels.withDeadline(futureStub, options);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,6 +144,7 @@ public final class MxGatewayClient implements AutoCloseable {
|
||||
try {
|
||||
OpenSessionReply reply = rawBlockingStub().openSession(request);
|
||||
MxGatewayErrors.ensureProtocolSuccess("open session", reply.getProtocolStatus(), null);
|
||||
ensureGatewayProtocolCompatible(reply);
|
||||
return reply;
|
||||
} catch (RuntimeException error) {
|
||||
if (error instanceof MxGatewayException) {
|
||||
@@ -155,6 +154,24 @@ public final class MxGatewayClient implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that the gateway speaks the protocol version this client was
|
||||
* generated against. A gateway that leaves {@code gateway_protocol_version}
|
||||
* unset (value {@code 0}, e.g. an older gateway) is accepted unchanged.
|
||||
*
|
||||
* @param reply the {@code OpenSessionReply} returned by the gateway
|
||||
* @throws MxGatewayException if the gateway reports an incompatible protocol version
|
||||
*/
|
||||
private static void ensureGatewayProtocolCompatible(OpenSessionReply reply) {
|
||||
int gatewayVersion = reply.getGatewayProtocolVersion();
|
||||
int clientVersion = MxGatewayClientVersion.gatewayProtocolVersion();
|
||||
if (gatewayVersion != 0 && gatewayVersion != clientVersion) {
|
||||
throw new MxGatewayException("gateway protocol version mismatch: gateway reports "
|
||||
+ gatewayVersion + " but this client was built for " + clientVersion
|
||||
+ "; upgrade the client or gateway so the protocol versions match");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes {@code OpenSession} asynchronously.
|
||||
*
|
||||
@@ -163,11 +180,16 @@ public final class MxGatewayClient implements AutoCloseable {
|
||||
* with {@link MxGatewayException} on failure
|
||||
*/
|
||||
public CompletableFuture<OpenSessionReply> openSessionAsync(OpenSessionRequest request) {
|
||||
CompletableFuture<OpenSessionReply> future = toCompletable(rawFutureStub().openSession(request));
|
||||
return future.thenApply(reply -> {
|
||||
MxGatewayErrors.ensureProtocolSuccess("open session", reply.getProtocolStatus(), null);
|
||||
return reply;
|
||||
});
|
||||
// Apply the validator inside toCompletable rather than via .thenApply so
|
||||
// cancellation on the returned future forwards to the source RPC (a
|
||||
// .thenApply stage returns a fresh CompletableFuture whose cancel()
|
||||
// does not propagate back to the upstream stage).
|
||||
return MxGatewayChannels.toCompletable(
|
||||
rawFutureStub().openSession(request), "open session", reply -> {
|
||||
MxGatewayErrors.ensureProtocolSuccess("open session", reply.getProtocolStatus(), null);
|
||||
ensureGatewayProtocolCompatible(reply);
|
||||
return reply;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -202,8 +224,7 @@ public final class MxGatewayClient implements AutoCloseable {
|
||||
* on failure
|
||||
*/
|
||||
public CompletableFuture<MxCommandReply> invokeAsync(MxCommandRequest request) {
|
||||
CompletableFuture<MxCommandReply> future = toCompletable(rawFutureStub().invoke(request));
|
||||
return future.thenApply(reply -> {
|
||||
return MxGatewayChannels.toCompletable(rawFutureStub().invoke(request), "invoke", reply -> {
|
||||
MxGatewayErrors.ensureProtocolSuccess("invoke", reply.getProtocolStatus(), reply);
|
||||
MxGatewayErrors.ensureMxAccessSuccess("invoke", reply);
|
||||
return reply;
|
||||
@@ -239,8 +260,13 @@ public final class MxGatewayClient implements AutoCloseable {
|
||||
* @return an iterator-style stream of events
|
||||
*/
|
||||
public MxEventStream streamEvents(StreamEventsRequest request) {
|
||||
MxEventStream stream = new MxEventStream(16);
|
||||
withStreamDeadline(rawAsyncStub()).streamEvents(request, stream.observer());
|
||||
// The buffer must absorb the gateway's session-backlog replay burst,
|
||||
// which arrives far faster than the iterator drains it. A small queue
|
||||
// overflows on any moderately active session; 1024 covers a realistic
|
||||
// backlog while still bounding memory and preserving overflow
|
||||
// detection for a genuinely unbounded stream.
|
||||
MxEventStream stream = new MxEventStream(1024);
|
||||
MxGatewayChannels.withStreamDeadline(rawAsyncStub(), options).streamEvents(request, stream.observer());
|
||||
return stream;
|
||||
}
|
||||
|
||||
@@ -255,100 +281,103 @@ public final class MxGatewayClient implements AutoCloseable {
|
||||
public MxGatewayEventSubscription streamEventsAsync(
|
||||
StreamEventsRequest request, StreamObserver<MxEvent> observer) {
|
||||
MxGatewayEventSubscription subscription = new MxGatewayEventSubscription();
|
||||
withStreamDeadline(rawAsyncStub()).streamEvents(request, subscription.wrap(observer));
|
||||
MxGatewayChannels.withStreamDeadline(rawAsyncStub(), options)
|
||||
.streamEvents(request, subscription.wrap(observer));
|
||||
return subscription;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (ownedChannel != null) {
|
||||
ownedChannel.shutdown();
|
||||
/**
|
||||
* Acknowledges an active MXAccess alarm condition through the gateway.
|
||||
*
|
||||
* <p>The gateway authorizes this request against the API key's
|
||||
* {@code admin} scope (the gateway scope resolver maps alarm RPCs to the
|
||||
* default {@code admin} scope) and forwards the acknowledge to the
|
||||
* worker's MXAccess session; the resulting native MxStatus is returned
|
||||
* in the reply. Acks are idempotent at the MxAccess layer.
|
||||
*
|
||||
* @param request the {@code AcknowledgeAlarmRequest}
|
||||
* @return the raw acknowledge reply
|
||||
* @throws MxGatewayException on transport or protocol failure
|
||||
*/
|
||||
public AcknowledgeAlarmReply acknowledgeAlarm(AcknowledgeAlarmRequest request) {
|
||||
try {
|
||||
AcknowledgeAlarmReply reply = rawBlockingStub().acknowledgeAlarm(request);
|
||||
MxGatewayErrors.ensureProtocolSuccess("acknowledge alarm", reply.getProtocolStatus(), null);
|
||||
return reply;
|
||||
} catch (RuntimeException error) {
|
||||
if (error instanceof MxGatewayException) {
|
||||
throw error;
|
||||
}
|
||||
throw MxGatewayErrors.fromGrpc("acknowledge alarm", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuts the owned channel down and waits up to the configured connect
|
||||
* timeout for termination, forcibly shutting it down on timeout. No-op
|
||||
* for clients that do not own their channel.
|
||||
* Acknowledges an active MXAccess alarm condition asynchronously.
|
||||
*
|
||||
* @param request the {@code AcknowledgeAlarmRequest}
|
||||
* @return a future completed with the raw reply, or completed exceptionally
|
||||
* with {@link MxGatewayException} on failure
|
||||
*/
|
||||
public CompletableFuture<AcknowledgeAlarmReply> acknowledgeAlarmAsync(AcknowledgeAlarmRequest request) {
|
||||
return MxGatewayChannels.toCompletable(
|
||||
rawFutureStub().acknowledgeAlarm(request), "acknowledge alarm", reply -> {
|
||||
MxGatewayErrors.ensureProtocolSuccess("acknowledge alarm", reply.getProtocolStatus(), null);
|
||||
return reply;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches to the gateway's central alarm feed. The stream opens with one
|
||||
* {@code AlarmFeedMessage} per currently-active alarm (the ConditionRefresh
|
||||
* snapshot), then a single {@code snapshot_complete}, then a
|
||||
* {@code transition} for every subsequent raise / acknowledge / clear.
|
||||
*
|
||||
* <p>Served by the gateway's always-on alarm monitor — no worker session is
|
||||
* opened — so any number of clients may attach.
|
||||
*
|
||||
* @param request the {@code StreamAlarmsRequest}, optionally scoped by
|
||||
* alarm-reference prefix
|
||||
* @param observer caller-supplied observer that receives feed messages and completion
|
||||
* @return a cancellable subscription handle
|
||||
*/
|
||||
public MxGatewayAlarmFeedSubscription streamAlarms(
|
||||
StreamAlarmsRequest request, StreamObserver<AlarmFeedMessage> observer) {
|
||||
MxGatewayAlarmFeedSubscription subscription = new MxGatewayAlarmFeedSubscription();
|
||||
MxGatewayChannels.withStreamDeadline(rawAsyncStub(), options)
|
||||
.streamAlarms(request, subscription.wrap(observer));
|
||||
return subscription;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuts the owned channel down and awaits termination so try-with-resources
|
||||
* callers do not leave in-flight calls or Netty event-loop threads running
|
||||
* after the block exits.
|
||||
*
|
||||
* <p>Waits up to {@link MxGatewayClientOptions#shutdownTimeout()} for
|
||||
* graceful termination and forcibly shuts the channel down on timeout. If
|
||||
* the calling thread is interrupted while waiting, the channel is forcibly
|
||||
* shut down and the thread's interrupt flag is restored. No-op for clients
|
||||
* that do not own their channel. For an explicitly checked, blocking-aware
|
||||
* shutdown call {@link #closeAndAwaitTermination()}. Delegates to the
|
||||
* shared {@link MxGatewayChannels#shutdown} so behavior stays in lockstep
|
||||
* with {@link GalaxyRepositoryClient}.
|
||||
*/
|
||||
@Override
|
||||
public void close() {
|
||||
MxGatewayChannels.shutdown(ownedChannel, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuts the owned channel down and waits up to
|
||||
* {@link MxGatewayClientOptions#shutdownTimeout()} for termination,
|
||||
* forcibly shutting it down on timeout. No-op for clients that do not own
|
||||
* their channel.
|
||||
*
|
||||
* @throws InterruptedException if the calling thread is interrupted while waiting
|
||||
*/
|
||||
public void closeAndAwaitTermination() throws InterruptedException {
|
||||
if (ownedChannel != null) {
|
||||
ownedChannel.shutdown();
|
||||
if (!ownedChannel.awaitTermination(options.connectTimeout().toMillis(), TimeUnit.MILLISECONDS)) {
|
||||
ownedChannel.shutdownNow();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static ManagedChannel createChannel(MxGatewayClientOptions options) {
|
||||
NettyChannelBuilder builder = NettyChannelBuilder.forTarget(options.endpoint())
|
||||
.maxInboundMessageSize(options.maxGrpcMessageBytes());
|
||||
if (!options.connectTimeout().isNegative()) {
|
||||
builder.withOption(
|
||||
io.grpc.netty.shaded.io.netty.channel.ChannelOption.CONNECT_TIMEOUT_MILLIS,
|
||||
Math.toIntExact(options.connectTimeout().toMillis()));
|
||||
}
|
||||
if (options.plaintext()) {
|
||||
builder.usePlaintext();
|
||||
} else if (options.caCertificatePath() != null) {
|
||||
try {
|
||||
builder.sslContext(GrpcSslContexts.forClient()
|
||||
.trustManager(options.caCertificatePath().toFile())
|
||||
.build());
|
||||
} catch (SSLException error) {
|
||||
throw new MxGatewayException("failed to configure gateway TLS", error);
|
||||
}
|
||||
} else {
|
||||
builder.useTransportSecurity();
|
||||
}
|
||||
if (!options.serverNameOverride().isBlank()) {
|
||||
builder.overrideAuthority(options.serverNameOverride());
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
private <T extends io.grpc.stub.AbstractStub<T>> T withDeadline(T stub) {
|
||||
if (options.callTimeout().isNegative()) {
|
||||
return stub;
|
||||
}
|
||||
return stub.withDeadlineAfter(options.callTimeout().toNanos(), TimeUnit.NANOSECONDS);
|
||||
}
|
||||
|
||||
private <T extends io.grpc.stub.AbstractStub<T>> T withStreamDeadline(T stub) {
|
||||
if (options.streamTimeout() == null || options.streamTimeout().isNegative()) {
|
||||
return stub;
|
||||
}
|
||||
return stub.withDeadlineAfter(options.streamTimeout().toNanos(), TimeUnit.NANOSECONDS);
|
||||
}
|
||||
|
||||
private static <T> CompletableFuture<T> toCompletable(com.google.common.util.concurrent.ListenableFuture<T> source) {
|
||||
CompletableFuture<T> target = new CompletableFuture<>();
|
||||
Futures.addCallback(
|
||||
source,
|
||||
new FutureCallback<>() {
|
||||
@Override
|
||||
public void onSuccess(T result) {
|
||||
target.complete(result);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(Throwable error) {
|
||||
if (error instanceof RuntimeException runtimeException) {
|
||||
target.completeExceptionally(MxGatewayErrors.fromGrpc("async call", runtimeException));
|
||||
return;
|
||||
}
|
||||
target.completeExceptionally(error);
|
||||
}
|
||||
},
|
||||
MoreExecutors.directExecutor());
|
||||
target.whenComplete((ignoredResult, ignoredError) -> {
|
||||
if (target.isCancelled()) {
|
||||
source.cancel(true);
|
||||
}
|
||||
});
|
||||
return target;
|
||||
MxGatewayChannels.shutdownAndAwaitTermination(ownedChannel, options);
|
||||
}
|
||||
|
||||
static ProtocolStatusCode okStatusCode() {
|
||||
|
||||
+32
@@ -14,6 +14,7 @@ import java.util.Objects;
|
||||
public final class MxGatewayClientOptions {
|
||||
private static final Duration DEFAULT_CONNECT_TIMEOUT = Duration.ofSeconds(10);
|
||||
private static final Duration DEFAULT_CALL_TIMEOUT = Duration.ofSeconds(30);
|
||||
private static final Duration DEFAULT_SHUTDOWN_TIMEOUT = Duration.ofSeconds(10);
|
||||
private static final int DEFAULT_MAX_GRPC_MESSAGE_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
private final String endpoint;
|
||||
@@ -24,6 +25,7 @@ public final class MxGatewayClientOptions {
|
||||
private final Duration connectTimeout;
|
||||
private final Duration callTimeout;
|
||||
private final Duration streamTimeout;
|
||||
private final Duration shutdownTimeout;
|
||||
private final int maxGrpcMessageBytes;
|
||||
|
||||
private MxGatewayClientOptions(Builder builder) {
|
||||
@@ -35,6 +37,7 @@ public final class MxGatewayClientOptions {
|
||||
connectTimeout = builder.connectTimeout == null ? DEFAULT_CONNECT_TIMEOUT : builder.connectTimeout;
|
||||
callTimeout = builder.callTimeout == null ? DEFAULT_CALL_TIMEOUT : builder.callTimeout;
|
||||
streamTimeout = builder.streamTimeout;
|
||||
shutdownTimeout = builder.shutdownTimeout == null ? DEFAULT_SHUTDOWN_TIMEOUT : builder.shutdownTimeout;
|
||||
maxGrpcMessageBytes = builder.maxGrpcMessageBytes <= 0
|
||||
? DEFAULT_MAX_GRPC_MESSAGE_BYTES
|
||||
: builder.maxGrpcMessageBytes;
|
||||
@@ -131,6 +134,18 @@ public final class MxGatewayClientOptions {
|
||||
return streamTimeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the upper bound on graceful shutdown waiting, applied by
|
||||
* {@code close()} and {@code closeAndAwaitTermination()}. Independent of
|
||||
* {@link #connectTimeout()}; a small connect timeout no longer forces an
|
||||
* aggressive {@code shutdownNow()} on in-flight calls.
|
||||
*
|
||||
* @return the shutdown timeout duration
|
||||
*/
|
||||
public Duration shutdownTimeout() {
|
||||
return shutdownTimeout;
|
||||
}
|
||||
|
||||
public int maxGrpcMessageBytes() {
|
||||
return maxGrpcMessageBytes;
|
||||
}
|
||||
@@ -157,6 +172,8 @@ public final class MxGatewayClientOptions {
|
||||
+ callTimeout
|
||||
+ ", streamTimeout="
|
||||
+ streamTimeout
|
||||
+ ", shutdownTimeout="
|
||||
+ shutdownTimeout
|
||||
+ ", maxGrpcMessageBytes="
|
||||
+ maxGrpcMessageBytes
|
||||
+ '}';
|
||||
@@ -181,6 +198,7 @@ public final class MxGatewayClientOptions {
|
||||
private Duration connectTimeout;
|
||||
private Duration callTimeout;
|
||||
private Duration streamTimeout;
|
||||
private Duration shutdownTimeout;
|
||||
private int maxGrpcMessageBytes;
|
||||
|
||||
private Builder() {
|
||||
@@ -277,6 +295,20 @@ public final class MxGatewayClientOptions {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the upper bound on graceful shutdown waiting (applied by
|
||||
* {@code close()} and {@code closeAndAwaitTermination()}). Defaults to
|
||||
* 10 s and is independent of the connect timeout.
|
||||
*
|
||||
* @param value the shutdown timeout, must be non-{@code null}
|
||||
* @return this builder
|
||||
* @throws NullPointerException if {@code value} is {@code null}
|
||||
*/
|
||||
public Builder shutdownTimeout(Duration value) {
|
||||
shutdownTimeout = Objects.requireNonNull(value, "shutdownTimeout");
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder maxGrpcMessageBytes(int value) {
|
||||
maxGrpcMessageBytes = value;
|
||||
return this;
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ package com.dohertylan.mxgateway.client;
|
||||
* worker speak the same protocol version as the client.
|
||||
*/
|
||||
public final class MxGatewayClientVersion {
|
||||
private static final int GATEWAY_PROTOCOL_VERSION = 2;
|
||||
private static final int GATEWAY_PROTOCOL_VERSION = 3;
|
||||
private static final int WORKER_PROTOCOL_VERSION = 1;
|
||||
private static final String CLIENT_VERSION = "0.1.0";
|
||||
|
||||
|
||||
+43
-19
@@ -1,5 +1,7 @@
|
||||
package com.dohertylan.mxgateway.client;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Helpers for redacting secrets such as gateway API keys from log output.
|
||||
*
|
||||
@@ -7,35 +9,61 @@ package com.dohertylan.mxgateway.client;
|
||||
* produce shortened, masked forms safe for diagnostic messages.
|
||||
*/
|
||||
public final class MxGatewaySecrets {
|
||||
// Match any gateway-shaped credential anywhere in the string, regardless of
|
||||
// surrounding punctuation: quoted, colon/comma-delimited, embedded in URLs
|
||||
// or parens. The underscore-separated character class also covers a
|
||||
// trailing hyphen in case a future key format introduces one.
|
||||
private static final Pattern MXGW_TOKEN = Pattern.compile("mxgw_[A-Za-z0-9_-]+");
|
||||
// Mask the token after a Bearer marker as a unit so callers cannot
|
||||
// accidentally leak the secret when the surrounding text is a header-style
|
||||
// string (e.g. "Bearer mxgw_id_secret").
|
||||
private static final Pattern BEARER_TOKEN = Pattern.compile("(?i)bearer\\s+\\S+");
|
||||
|
||||
private MxGatewaySecrets() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Redacts the body of an API key, leaving only short prefix and suffix
|
||||
* windows so it remains comparable in logs.
|
||||
* Redacts the secret portion of an API key, leaving only the non-secret
|
||||
* key identifier visible so the value remains comparable in logs.
|
||||
*
|
||||
* <p>A gateway API key has the form {@code mxgw_<key-id>_<secret>}. Only the
|
||||
* {@code mxgw_<key-id>_} prefix is non-secret; everything after the second
|
||||
* underscore is the secret and is masked entirely — no leading or
|
||||
* trailing characters of the secret are echoed. Tokens that do not match
|
||||
* the gateway shape are masked completely as {@code "<redacted>"}.
|
||||
*
|
||||
* @param apiKey the API key to redact, may be {@code null} or empty
|
||||
* @return an empty string for {@code null}/empty input, {@code "<redacted>"}
|
||||
* for keys eight characters or shorter, or a masked form preserving
|
||||
* the leading and trailing four characters
|
||||
* for non-gateway-shaped tokens, or {@code mxgw_<key-id>_***} with the
|
||||
* secret masked for gateway-shaped keys
|
||||
*/
|
||||
public static String redactApiKey(String apiKey) {
|
||||
if (apiKey == null || apiKey.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
if (apiKey.length() <= 8) {
|
||||
return "<redacted>";
|
||||
|
||||
// Gateway keys are mxgw_<key-id>_<secret>; keep only the non-secret prefix.
|
||||
if (apiKey.startsWith("mxgw_")) {
|
||||
int secretSeparator = apiKey.indexOf('_', "mxgw_".length());
|
||||
if (secretSeparator >= 0 && secretSeparator < apiKey.length() - 1) {
|
||||
return apiKey.substring(0, secretSeparator + 1) + "***";
|
||||
}
|
||||
}
|
||||
|
||||
return apiKey.substring(0, 4)
|
||||
+ "*".repeat(apiKey.length() - 8)
|
||||
+ apiKey.substring(apiKey.length() - 4);
|
||||
// Anything else is treated as wholly secret — reveal nothing.
|
||||
return "<redacted>";
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces gateway-style credential tokens (the {@code mxgw_} prefix and
|
||||
* any {@code Bearer} marker) inside a free-form string with a redaction
|
||||
* placeholder.
|
||||
* Replaces gateway-style credential tokens inside a free-form string with a
|
||||
* redaction placeholder.
|
||||
*
|
||||
* <p>Matches any {@code mxgw_<...>} token anywhere in the string,
|
||||
* irrespective of surrounding punctuation (whitespace, colons, commas,
|
||||
* single/double quotes, parentheses, embedded URL paths). Also masks the
|
||||
* argument of an authorization-header style {@code Bearer <token>} marker
|
||||
* as a unit so the token cannot leak through when the surrounding string
|
||||
* is a raw header value.
|
||||
*
|
||||
* @param value the string to scrub, may be {@code null}
|
||||
* @return an empty string for {@code null}, the original value when blank,
|
||||
@@ -46,12 +74,8 @@ public final class MxGatewaySecrets {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
|
||||
String[] parts = value.split("\\s+");
|
||||
for (int index = 0; index < parts.length; index++) {
|
||||
if (parts[index].startsWith("mxgw_") || parts[index].equalsIgnoreCase("bearer")) {
|
||||
parts[index] = "<redacted>";
|
||||
}
|
||||
}
|
||||
return String.join(" ", parts);
|
||||
String scrubbed = MXGW_TOKEN.matcher(value).replaceAll("<redacted>");
|
||||
scrubbed = BEARER_TOKEN.matcher(scrubbed).replaceAll("Bearer <redacted>");
|
||||
return scrubbed;
|
||||
}
|
||||
}
|
||||
|
||||
+143
-4
@@ -9,6 +9,8 @@ import mxaccess_gateway.v1.MxaccessGateway.AddItemBulkCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AddItemCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AdviseItemBulkCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AdviseCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.BulkReadResult;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.BulkWriteResult;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxCommand;
|
||||
@@ -17,6 +19,7 @@ import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxValue;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ReadBulkCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.RegisterCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.RemoveItemBulkCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.RemoveItemCommand;
|
||||
@@ -27,8 +30,16 @@ import mxaccess_gateway.v1.MxaccessGateway.UnAdviseCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.UnAdviseItemBulkCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.UnsubscribeBulkCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.UnregisterCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.Write2BulkCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.Write2BulkEntry;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.Write2Command;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.WriteBulkCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.WriteBulkEntry;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.WriteCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.WriteSecured2BulkCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.WriteSecured2BulkEntry;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.WriteSecuredBulkCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.WriteSecuredBulkEntry;
|
||||
|
||||
/**
|
||||
* Typed handle for a single MXAccess gateway session.
|
||||
@@ -40,6 +51,7 @@ import mxaccess_gateway.v1.MxaccessGateway.WriteCommand;
|
||||
*/
|
||||
public final class MxGatewaySession implements AutoCloseable {
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
private static final System.Logger LOGGER = System.getLogger(MxGatewaySession.class.getName());
|
||||
|
||||
private final MxGatewayClient client;
|
||||
private final OpenSessionReply openReply;
|
||||
@@ -99,9 +111,26 @@ public final class MxGatewaySession implements AutoCloseable {
|
||||
return closeReply;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the session as part of try-with-resources.
|
||||
*
|
||||
* <p>This performs a {@code CloseSession} network RPC. Unlike
|
||||
* {@link #closeRaw()}, any failure of that RPC is swallowed (and recorded
|
||||
* as a suppressed exception when the JVM permits) rather than thrown: a
|
||||
* close-time transport or protocol failure must not replace the exception
|
||||
* that a try-with-resources body is already propagating. Callers that need
|
||||
* to observe the close result should call {@link #closeRaw()} explicitly.
|
||||
*/
|
||||
@Override
|
||||
public void close() {
|
||||
closeRaw();
|
||||
try {
|
||||
closeRaw();
|
||||
} catch (MxGatewayException error) {
|
||||
LOGGER.log(
|
||||
System.Logger.Level.WARNING,
|
||||
() -> "ignoring close-time failure for session " + sessionId(),
|
||||
error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,7 +145,11 @@ public final class MxGatewaySession implements AutoCloseable {
|
||||
if (reply.hasRegister()) {
|
||||
return reply.getRegister().getServerHandle();
|
||||
}
|
||||
return reply.getReturnValue().getInt32Value();
|
||||
if (reply.hasReturnValue()) {
|
||||
return reply.getReturnValue().getInt32Value();
|
||||
}
|
||||
throw new MxGatewayException(
|
||||
"gateway register reply carried neither a register payload nor a return value");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -159,7 +192,11 @@ public final class MxGatewaySession implements AutoCloseable {
|
||||
if (reply.hasAddItem()) {
|
||||
return reply.getAddItem().getItemHandle();
|
||||
}
|
||||
return reply.getReturnValue().getInt32Value();
|
||||
if (reply.hasReturnValue()) {
|
||||
return reply.getReturnValue().getInt32Value();
|
||||
}
|
||||
throw new MxGatewayException(
|
||||
"gateway addItem reply carried neither an add-item payload nor a return value");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -193,7 +230,11 @@ public final class MxGatewaySession implements AutoCloseable {
|
||||
if (reply.hasAddItem2()) {
|
||||
return reply.getAddItem2().getItemHandle();
|
||||
}
|
||||
return reply.getReturnValue().getInt32Value();
|
||||
if (reply.hasReturnValue()) {
|
||||
return reply.getReturnValue().getInt32Value();
|
||||
}
|
||||
throw new MxGatewayException(
|
||||
"gateway addItem2 reply carried neither an add-item payload nor a return value");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -421,6 +462,104 @@ public final class MxGatewaySession implements AutoCloseable {
|
||||
return reply.getUnsubscribeBulk().getResultsList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk {@code Write} — sequential MXAccess Write per entry on the worker's STA.
|
||||
* Per-entry failures appear as {@link BulkWriteResult} entries with
|
||||
* {@code wasSuccessful == false}; this method does not throw for per-entry
|
||||
* MXAccess failures (it still throws {@link MxGatewayException} on transport
|
||||
* or protocol-level failures).
|
||||
*
|
||||
* @param serverHandle the {@code ServerHandle} owning the items
|
||||
* @param entries the per-item (handle, value, user id) tuples
|
||||
* @return a per-entry {@link BulkWriteResult} list
|
||||
*/
|
||||
public List<BulkWriteResult> writeBulk(int serverHandle, List<WriteBulkEntry> entries) {
|
||||
Objects.requireNonNull(entries, "entries");
|
||||
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
|
||||
.setKind(MxCommandKind.MX_COMMAND_KIND_WRITE_BULK)
|
||||
.setWriteBulk(WriteBulkCommand.newBuilder()
|
||||
.setServerHandle(serverHandle)
|
||||
.addAllEntries(entries))
|
||||
.build());
|
||||
return reply.getWriteBulk().getResultsList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk {@code Write2} — sequential MXAccess Write2 (timestamped) per entry.
|
||||
*/
|
||||
public List<BulkWriteResult> write2Bulk(int serverHandle, List<Write2BulkEntry> entries) {
|
||||
Objects.requireNonNull(entries, "entries");
|
||||
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
|
||||
.setKind(MxCommandKind.MX_COMMAND_KIND_WRITE2_BULK)
|
||||
.setWrite2Bulk(Write2BulkCommand.newBuilder()
|
||||
.setServerHandle(serverHandle)
|
||||
.addAllEntries(entries))
|
||||
.build());
|
||||
return reply.getWrite2Bulk().getResultsList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk {@code WriteSecured} — credential-sensitive values must not be logged
|
||||
* by callers; mirrors the single-item write-secured redaction contract.
|
||||
*/
|
||||
public List<BulkWriteResult> writeSecuredBulk(int serverHandle, List<WriteSecuredBulkEntry> entries) {
|
||||
Objects.requireNonNull(entries, "entries");
|
||||
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
|
||||
.setKind(MxCommandKind.MX_COMMAND_KIND_WRITE_SECURED_BULK)
|
||||
.setWriteSecuredBulk(WriteSecuredBulkCommand.newBuilder()
|
||||
.setServerHandle(serverHandle)
|
||||
.addAllEntries(entries))
|
||||
.build());
|
||||
return reply.getWriteSecuredBulk().getResultsList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk {@code WriteSecured2} — sequential timestamped + verified write per entry.
|
||||
*/
|
||||
public List<BulkWriteResult> writeSecured2Bulk(int serverHandle, List<WriteSecured2BulkEntry> entries) {
|
||||
Objects.requireNonNull(entries, "entries");
|
||||
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
|
||||
.setKind(MxCommandKind.MX_COMMAND_KIND_WRITE_SECURED2_BULK)
|
||||
.setWriteSecured2Bulk(WriteSecured2BulkCommand.newBuilder()
|
||||
.setServerHandle(serverHandle)
|
||||
.addAllEntries(entries))
|
||||
.build());
|
||||
return reply.getWriteSecured2Bulk().getResultsList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk {@code Read} — snapshot the current value of each requested tag.
|
||||
*
|
||||
* <p>MXAccess COM has no synchronous read; the worker returns the cached
|
||||
* {@code OnDataChange} value for any tag that is already advised
|
||||
* ({@code wasCached == true}) without modifying the existing subscription,
|
||||
* and falls back to a full AddItem + Advise + wait + UnAdvise + RemoveItem
|
||||
* snapshot lifecycle otherwise. {@code timeoutMs} bounds the per-tag wait
|
||||
* in the snapshot case; pass {@code 0} to use the worker default (1000 ms).
|
||||
* Per-tag failures appear as {@link BulkReadResult} entries with
|
||||
* {@code wasSuccessful == false}; this method does not throw for per-tag
|
||||
* MXAccess failures.
|
||||
*
|
||||
* @param serverHandle the {@code ServerHandle} owning the items
|
||||
* @param tagAddresses the tag addresses to read
|
||||
* @param timeoutMs per-tag snapshot timeout in milliseconds (0 = worker default)
|
||||
* @return a per-tag {@link BulkReadResult} list
|
||||
*/
|
||||
public List<BulkReadResult> readBulk(int serverHandle, List<String> tagAddresses, int timeoutMs) {
|
||||
Objects.requireNonNull(tagAddresses, "tagAddresses");
|
||||
if (timeoutMs < 0) {
|
||||
throw new IllegalArgumentException("timeoutMs must be non-negative");
|
||||
}
|
||||
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
|
||||
.setKind(MxCommandKind.MX_COMMAND_KIND_READ_BULK)
|
||||
.setReadBulk(ReadBulkCommand.newBuilder()
|
||||
.setServerHandle(serverHandle)
|
||||
.addAllTagAddresses(tagAddresses)
|
||||
.setTimeoutMs(timeoutMs))
|
||||
.build());
|
||||
return reply.getReadBulk().getResultsList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes MXAccess {@code Write}.
|
||||
*
|
||||
|
||||
+58
@@ -175,6 +175,64 @@ final class GalaxyRepositoryClientTests {
|
||||
assertFalse(stream.hasNext());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deployEventStreamOverflowExceptionSurvivesASubsequentClose() {
|
||||
// Client.Java-021 regression: mirror Client.Java-002's terminal-state
|
||||
// serialisation in DeployEventStream — an overflow enqueues the overflow
|
||||
// exception, and a later close() must NOT discard it. The first terminal
|
||||
// condition (overflow) must win and stay observable by next().
|
||||
DeployEventStream stream = new DeployEventStream(2);
|
||||
ClientResponseObserver<WatchDeployEventsRequest, DeployEvent> observer = stream.observer();
|
||||
observer.beforeStart(new RecordingClientCallStreamObserver());
|
||||
|
||||
// Force a queue overflow on a capacity-2 stream.
|
||||
for (int i = 0; i < 8; i++) {
|
||||
observer.onNext(DeployEvent.newBuilder().setSequence(i).build());
|
||||
}
|
||||
|
||||
// A close() arriving after the overflow must not erase the overflow signal.
|
||||
stream.close();
|
||||
|
||||
MxGatewayException error = assertThrows(MxGatewayException.class, () -> {
|
||||
while (stream.hasNext()) {
|
||||
stream.next();
|
||||
}
|
||||
});
|
||||
assertTrue(error.getMessage().contains("overflow"), error::getMessage);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deployEventStreamConcurrentOverflowAndCloseAlwaysTerminate() throws Exception {
|
||||
// Client.Java-021 regression: the terminal-state transition must be
|
||||
// serialised so whatever the interleaving of overflow and close,
|
||||
// hasNext() always reaches a terminal state (no stuck consumer).
|
||||
for (int iteration = 0; iteration < 300; iteration++) {
|
||||
DeployEventStream stream = new DeployEventStream(2);
|
||||
ClientResponseObserver<WatchDeployEventsRequest, DeployEvent> observer = stream.observer();
|
||||
observer.beforeStart(new RecordingClientCallStreamObserver());
|
||||
|
||||
Thread filler = new Thread(() -> {
|
||||
for (int i = 0; i < 8; i++) {
|
||||
observer.onNext(DeployEvent.newBuilder().setSequence(i).build());
|
||||
}
|
||||
});
|
||||
Thread closer = new Thread(stream::close);
|
||||
filler.start();
|
||||
closer.start();
|
||||
filler.join();
|
||||
closer.join();
|
||||
|
||||
try {
|
||||
while (stream.hasNext()) {
|
||||
stream.next();
|
||||
}
|
||||
} catch (MxGatewayException expected) {
|
||||
assertTrue(expected.getMessage().contains("overflow"), expected::getMessage);
|
||||
}
|
||||
assertFalse(stream.hasNext());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void discoverHierarchyRejectsRepeatedPageToken() throws Exception {
|
||||
TestService service = new TestService() {
|
||||
|
||||
+88
@@ -24,7 +24,14 @@ import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import mxaccess_gateway.v1.MxAccessGatewayGrpc;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AddItemReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.BulkReadReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.BulkReadResult;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.BulkSubscribeReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.BulkWriteReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.BulkWriteResult;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxDataType;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxValue;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.WriteBulkEntry;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxCommandKind;
|
||||
@@ -151,6 +158,87 @@ final class MxGatewayClientSessionTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeBulkBuildsOneBulkCommandAndReturnsPerEntryResults() throws Exception {
|
||||
AtomicReference<MxCommandRequest> commandRequest = new AtomicReference<>();
|
||||
TestGatewayService service = new TestGatewayService() {
|
||||
@Override
|
||||
public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
|
||||
commandRequest.set(request);
|
||||
responseObserver.onNext(MxCommandReply.newBuilder()
|
||||
.setSessionId(request.getSessionId())
|
||||
.setKind(request.getCommand().getKind())
|
||||
.setProtocolStatus(ok())
|
||||
.setWriteBulk(BulkWriteReply.newBuilder()
|
||||
.addResults(BulkWriteResult.newBuilder()
|
||||
.setServerHandle(12).setItemHandle(901).setWasSuccessful(true))
|
||||
.addResults(BulkWriteResult.newBuilder()
|
||||
.setServerHandle(12).setItemHandle(902).setWasSuccessful(false)
|
||||
.setErrorMessage("invalid handle")))
|
||||
.build());
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
};
|
||||
|
||||
try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
|
||||
MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
|
||||
MxGatewaySession session = MxGatewaySession.forSessionId(client, "existing-session");
|
||||
|
||||
List<BulkWriteResult> results = session.writeBulk(12, List.of(
|
||||
WriteBulkEntry.newBuilder().setItemHandle(901).setUserId(5)
|
||||
.setValue(MxValue.newBuilder().setDataType(MxDataType.MX_DATA_TYPE_INTEGER).setInt32Value(11)).build(),
|
||||
WriteBulkEntry.newBuilder().setItemHandle(902).setUserId(5)
|
||||
.setValue(MxValue.newBuilder().setDataType(MxDataType.MX_DATA_TYPE_INTEGER).setInt32Value(22)).build()));
|
||||
|
||||
assertEquals(2, results.size());
|
||||
assertTrue(results.get(0).getWasSuccessful());
|
||||
assertEquals(MxCommandKind.MX_COMMAND_KIND_WRITE_BULK, commandRequest.get().getCommand().getKind());
|
||||
assertEquals(2, commandRequest.get().getCommand().getWriteBulk().getEntriesCount());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void readBulkForwardsTimeoutAndUnpacksCachedFlag() throws Exception {
|
||||
AtomicReference<MxCommandRequest> commandRequest = new AtomicReference<>();
|
||||
TestGatewayService service = new TestGatewayService() {
|
||||
@Override
|
||||
public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
|
||||
commandRequest.set(request);
|
||||
responseObserver.onNext(MxCommandReply.newBuilder()
|
||||
.setSessionId(request.getSessionId())
|
||||
.setKind(request.getCommand().getKind())
|
||||
.setProtocolStatus(ok())
|
||||
.setReadBulk(BulkReadReply.newBuilder()
|
||||
.addResults(BulkReadResult.newBuilder()
|
||||
.setServerHandle(12)
|
||||
.setTagAddress("Area001.Pump001.Speed")
|
||||
.setItemHandle(34)
|
||||
.setWasSuccessful(true)
|
||||
.setWasCached(true)
|
||||
.setValue(MxValue.newBuilder()
|
||||
.setDataType(MxDataType.MX_DATA_TYPE_INTEGER)
|
||||
.setInt32Value(99))))
|
||||
.build());
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
};
|
||||
|
||||
try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
|
||||
MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
|
||||
MxGatewaySession session = MxGatewaySession.forSessionId(client, "existing-session");
|
||||
|
||||
List<BulkReadResult> results = session.readBulk(12, List.of("Area001.Pump001.Speed"), 750);
|
||||
|
||||
assertEquals(1, results.size());
|
||||
assertTrue(results.get(0).getWasCached());
|
||||
assertEquals(99, results.get(0).getValue().getInt32Value());
|
||||
assertEquals(MxCommandKind.MX_COMMAND_KIND_READ_BULK, commandRequest.get().getCommand().getKind());
|
||||
assertEquals(750, commandRequest.get().getCommand().getReadBulk().getTimeoutMs());
|
||||
assertEquals(List.of("Area001.Pump001.Speed"),
|
||||
commandRequest.get().getCommand().getReadBulk().getTagAddressesList());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void streamCancellationCancelsServerCall() throws Exception {
|
||||
CountDownLatch cancelled = new CountDownLatch(1);
|
||||
|
||||
+31
@@ -106,6 +106,37 @@ final class MxGatewayFixtureTests {
|
||||
assertFalse(authError.getMessage().contains("visible_secret"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void redactCredentialsHandlesNonWhitespaceDelimitedTokens() {
|
||||
// Client.Java-018 regression: the previous whitespace-split scrub left
|
||||
// mxgw_ credentials attached to quotes, commas, colons, parens, and
|
||||
// URL paths intact. The strengthened pattern matches mxgw_<...>
|
||||
// anywhere in the string regardless of surrounding punctuation.
|
||||
String singleQuoted = MxGatewaySecrets.redactCredentials("authentication failed: 'mxgw_keyid_secret'");
|
||||
String doubleQuoted = MxGatewaySecrets.redactCredentials("Bearer:\"mxgw_keyid_secret\"");
|
||||
String commaDelimited = MxGatewaySecrets.redactCredentials("token=mxgw_keyid_secret,scope=admin");
|
||||
String colonDelimited = MxGatewaySecrets.redactCredentials("Bearer:mxgw_keyid_secret");
|
||||
String parenthesised = MxGatewaySecrets.redactCredentials("auth(mxgw_keyid_secret)");
|
||||
String urlEmbedded = MxGatewaySecrets.redactCredentials("https://gw/api?key=mxgw_keyid_secret&x=1");
|
||||
String bearerHeader = MxGatewaySecrets.redactCredentials("Bearer mxgw_keyid_secret");
|
||||
|
||||
for (String redacted : new String[] {
|
||||
singleQuoted, doubleQuoted, commaDelimited, colonDelimited, parenthesised, urlEmbedded, bearerHeader
|
||||
}) {
|
||||
assertFalse(redacted.contains("mxgw_keyid_secret"), "expected redaction, got: " + redacted);
|
||||
assertFalse(redacted.contains("keyid_secret"), "tail leaked: " + redacted);
|
||||
assertTrue(redacted.contains("<redacted>"), "expected <redacted>, got: " + redacted);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void redactCredentialsLeavesBenignContentAlone() {
|
||||
assertEquals(
|
||||
"no credentials here",
|
||||
MxGatewaySecrets.redactCredentials("no credentials here"));
|
||||
assertEquals("", MxGatewaySecrets.redactCredentials(null));
|
||||
}
|
||||
|
||||
private static JsonObject readFixture(String relativePath) throws Exception {
|
||||
return JsonParser.parseString(Files.readString(fixtureRoot().resolve(relativePath))).getAsJsonObject();
|
||||
}
|
||||
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
package com.dohertylan.mxgateway.client;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import io.grpc.CallOptions;
|
||||
import io.grpc.ClientCall;
|
||||
import io.grpc.ConnectivityState;
|
||||
import io.grpc.ManagedChannel;
|
||||
import io.grpc.MethodDescriptor;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Regression tests for the second-pass Low-severity Client.Java findings
|
||||
* Client.Java-016, Client.Java-019, and the shared shutdown helpers extracted
|
||||
* to {@link MxGatewayChannels}.
|
||||
*/
|
||||
final class MxGatewayLowFindingsIITests {
|
||||
|
||||
// --- Client.Java-019: shutdown timeout is independent of connect timeout ---
|
||||
|
||||
@Test
|
||||
void shutdownAndAwaitTerminationHonoursShutdownTimeoutNotConnectTimeout() throws Exception {
|
||||
// The historical bug: close() used connectTimeout as the awaitTermination
|
||||
// deadline, so a small connectTimeout forced a premature shutdownNow()
|
||||
// on in-flight calls. The fix uses a dedicated shutdownTimeout. This
|
||||
// test verifies the helper waits up to shutdownTimeout (1s) even when
|
||||
// connectTimeout is set to a tiny value (50ms).
|
||||
RecordingChannel channel = new RecordingChannel(/* terminatesAfterMillis = */ 200);
|
||||
MxGatewayClientOptions options = MxGatewayClientOptions.builder()
|
||||
.endpoint("in-process")
|
||||
.plaintext(true)
|
||||
.connectTimeout(Duration.ofMillis(50))
|
||||
.shutdownTimeout(Duration.ofSeconds(1))
|
||||
.build();
|
||||
|
||||
long start = System.nanoTime();
|
||||
MxGatewayChannels.shutdownAndAwaitTermination(channel, options);
|
||||
long elapsedMillis = (System.nanoTime() - start) / 1_000_000L;
|
||||
|
||||
// The channel finished orderly termination within the shutdown timeout
|
||||
// window, so shutdownNow() must NOT have been called. With the old
|
||||
// implementation a 50ms connect-timeout-as-shutdown-deadline would
|
||||
// have escalated to shutdownNow() before the channel's 200ms graceful
|
||||
// termination completed.
|
||||
assertTrue(channel.shutdownCalled, "shutdown() must be called");
|
||||
assertFalse(
|
||||
channel.shutdownNowCalled,
|
||||
"graceful termination finished within shutdownTimeout; shutdownNow() must not have been called");
|
||||
// Allow ample slack for build-machine variance but assert we waited at
|
||||
// least the channel's graceful-termination window.
|
||||
assertTrue(elapsedMillis >= 150, "should have waited for graceful termination, elapsed=" + elapsedMillis);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shutdownEscalatesToShutdownNowWhenTimeoutExceeded() {
|
||||
// The other half of the contract: a channel that does not terminate
|
||||
// within the shutdownTimeout window must be forcibly shut down.
|
||||
RecordingChannel channel = new RecordingChannel(/* terminatesAfterMillis = */ 5_000);
|
||||
MxGatewayClientOptions options = MxGatewayClientOptions.builder()
|
||||
.endpoint("in-process")
|
||||
.plaintext(true)
|
||||
.shutdownTimeout(Duration.ofMillis(100))
|
||||
.build();
|
||||
|
||||
MxGatewayChannels.shutdown(channel, options);
|
||||
|
||||
assertTrue(channel.shutdownCalled);
|
||||
assertTrue(channel.shutdownNowCalled, "stuck channel must be forcibly shut down");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shutdownTimeoutDefaultIsTenSecondsIndependentOfConnectTimeout() {
|
||||
MxGatewayClientOptions defaults = MxGatewayClientOptions.builder()
|
||||
.endpoint("in-process")
|
||||
.build();
|
||||
// Default is 10s; an unset connectTimeout-of-10s default coincides but
|
||||
// the two are now independent options.
|
||||
assertEquals(Duration.ofSeconds(10), defaults.shutdownTimeout());
|
||||
|
||||
MxGatewayClientOptions tinyConnect = MxGatewayClientOptions.builder()
|
||||
.endpoint("in-process")
|
||||
.connectTimeout(Duration.ofMillis(500))
|
||||
.build();
|
||||
assertEquals(Duration.ofSeconds(10), tinyConnect.shutdownTimeout(),
|
||||
"shutdownTimeout default is independent of connectTimeout");
|
||||
}
|
||||
|
||||
// --- Client.Java-016: shared shutdown helpers behave identically for both clients ---
|
||||
|
||||
@Test
|
||||
void sharedShutdownHelperIsNoOpForNullChannel() throws Exception {
|
||||
MxGatewayClientOptions options = MxGatewayClientOptions.builder()
|
||||
.endpoint("in-process")
|
||||
.plaintext(true)
|
||||
.shutdownTimeout(Duration.ofMillis(50))
|
||||
.build();
|
||||
// Both helpers must tolerate a null owned-channel (caller-managed channel case).
|
||||
MxGatewayChannels.shutdown(null, options);
|
||||
MxGatewayChannels.shutdownAndAwaitTermination(null, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test double for {@link ManagedChannel} that records {@code shutdown}/
|
||||
* {@code shutdownNow} invocations and simulates an orderly termination
|
||||
* after a configurable delay. Avoids the heavy in-process gRPC machinery —
|
||||
* the shutdown helpers only touch the three lifecycle methods.
|
||||
*/
|
||||
private static final class RecordingChannel extends ManagedChannel {
|
||||
private final long terminatesAfterMillis;
|
||||
private final long createdAtNanos;
|
||||
private volatile boolean shutdownCalled;
|
||||
private volatile boolean shutdownNowCalled;
|
||||
|
||||
RecordingChannel(long terminatesAfterMillis) {
|
||||
this.terminatesAfterMillis = terminatesAfterMillis;
|
||||
this.createdAtNanos = System.nanoTime();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ManagedChannel shutdown() {
|
||||
shutdownCalled = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isShutdown() {
|
||||
return shutdownCalled || shutdownNowCalled;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isTerminated() {
|
||||
if (shutdownNowCalled) {
|
||||
return true;
|
||||
}
|
||||
if (!shutdownCalled) {
|
||||
return false;
|
||||
}
|
||||
long elapsed = (System.nanoTime() - createdAtNanos) / 1_000_000L;
|
||||
return elapsed >= terminatesAfterMillis;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ManagedChannel shutdownNow() {
|
||||
shutdownNowCalled = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException {
|
||||
long deadlineNanos = System.nanoTime() + unit.toNanos(timeout);
|
||||
while (System.nanoTime() < deadlineNanos) {
|
||||
if (isTerminated()) {
|
||||
return true;
|
||||
}
|
||||
long remaining = Math.max(1, (deadlineNanos - System.nanoTime()) / 1_000_000L);
|
||||
Thread.sleep(Math.min(remaining, 10));
|
||||
}
|
||||
return isTerminated();
|
||||
}
|
||||
|
||||
@Override
|
||||
public <RequestT, ResponseT> ClientCall<RequestT, ResponseT> newCall(
|
||||
MethodDescriptor<RequestT, ResponseT> methodDescriptor, CallOptions callOptions) {
|
||||
throw new UnsupportedOperationException("no RPCs are issued in shutdown tests");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String authority() {
|
||||
return "in-process";
|
||||
}
|
||||
|
||||
@Override
|
||||
public ConnectivityState getState(boolean requestConnection) {
|
||||
return ConnectivityState.IDLE;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+509
@@ -0,0 +1,509 @@
|
||||
package com.dohertylan.mxgateway.client;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import io.grpc.ManagedChannel;
|
||||
import io.grpc.Server;
|
||||
import io.grpc.Status;
|
||||
import io.grpc.inprocess.InProcessChannelBuilder;
|
||||
import io.grpc.inprocess.InProcessServerBuilder;
|
||||
import io.grpc.stub.ClientCallStreamObserver;
|
||||
import io.grpc.stub.ClientResponseObserver;
|
||||
import io.grpc.stub.StreamObserver;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import mxaccess_gateway.v1.MxAccessGatewayGrpc;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AlarmConditionState;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Regression tests for the Low-severity Client.Java code-review findings
|
||||
* (Client.Java-006 through Client.Java-012). Covers the alarm RPC surface,
|
||||
* async streaming/subscription cancellation, queue overflow, and TLS-config
|
||||
* construction that Client.Java-007 reports as untested.
|
||||
*/
|
||||
final class MxGatewayLowFindingsTests {
|
||||
|
||||
// --- Client.Java-007: AcknowledgeAlarm RPC coverage ---
|
||||
|
||||
@Test
|
||||
void acknowledgeAlarmReturnsReplyAndSendsAuthMetadata() throws Exception {
|
||||
AtomicReference<String> authorization = new AtomicReference<>();
|
||||
AtomicReference<AcknowledgeAlarmRequest> seen = new AtomicReference<>();
|
||||
TestService service = new TestService() {
|
||||
@Override
|
||||
public void acknowledgeAlarm(
|
||||
AcknowledgeAlarmRequest request, StreamObserver<AcknowledgeAlarmReply> responseObserver) {
|
||||
seen.set(request);
|
||||
responseObserver.onNext(AcknowledgeAlarmReply.newBuilder()
|
||||
.setProtocolStatus(ok())
|
||||
.setDiagnosticMessage("acked")
|
||||
.build());
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
};
|
||||
|
||||
try (Harness harness = Harness.start(service, "mxgw_keyid_secret", authorization)) {
|
||||
AcknowledgeAlarmReply reply = harness.client().acknowledgeAlarm(AcknowledgeAlarmRequest.newBuilder()
|
||||
.setAlarmFullReference("Area1.Pump.PV.HiHi")
|
||||
.setComment("operator note")
|
||||
.build());
|
||||
assertEquals("acked", reply.getDiagnosticMessage());
|
||||
assertEquals("Area1.Pump.PV.HiHi", seen.get().getAlarmFullReference());
|
||||
assertEquals("Bearer mxgw_keyid_secret", authorization.get());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void acknowledgeAlarmThrowsTypedExceptionOnProtocolFailure() throws Exception {
|
||||
TestService service = new TestService() {
|
||||
@Override
|
||||
public void acknowledgeAlarm(
|
||||
AcknowledgeAlarmRequest request, StreamObserver<AcknowledgeAlarmReply> responseObserver) {
|
||||
responseObserver.onNext(AcknowledgeAlarmReply.newBuilder()
|
||||
.setProtocolStatus(ProtocolStatus.newBuilder()
|
||||
.setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_SESSION_NOT_FOUND))
|
||||
.build());
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
};
|
||||
|
||||
try (Harness harness = Harness.start(service)) {
|
||||
assertThrows(
|
||||
MxGatewayException.class,
|
||||
() -> harness.client().acknowledgeAlarm(AcknowledgeAlarmRequest.newBuilder()
|
||||
.setAlarmFullReference("Area1.Pump.PV.HiHi")
|
||||
.build()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void acknowledgeAlarmAsyncCompletesWithReply() throws Exception {
|
||||
TestService service = new TestService() {
|
||||
@Override
|
||||
public void acknowledgeAlarm(
|
||||
AcknowledgeAlarmRequest request, StreamObserver<AcknowledgeAlarmReply> responseObserver) {
|
||||
responseObserver.onNext(AcknowledgeAlarmReply.newBuilder()
|
||||
.setProtocolStatus(ok())
|
||||
.setDiagnosticMessage("async-acked")
|
||||
.build());
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
};
|
||||
|
||||
try (Harness harness = Harness.start(service)) {
|
||||
CompletableFuture<AcknowledgeAlarmReply> future = harness.client()
|
||||
.acknowledgeAlarmAsync(AcknowledgeAlarmRequest.newBuilder()
|
||||
.setAlarmFullReference("Area1.Pump.PV.HiHi")
|
||||
.build());
|
||||
assertEquals("async-acked", future.get(5, TimeUnit.SECONDS).getDiagnosticMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void acknowledgeAlarmAsyncFailsExceptionallyWithTypedException() throws Exception {
|
||||
TestService service = new TestService() {
|
||||
@Override
|
||||
public void acknowledgeAlarm(
|
||||
AcknowledgeAlarmRequest request, StreamObserver<AcknowledgeAlarmReply> responseObserver) {
|
||||
responseObserver.onError(Status.UNAVAILABLE.withDescription("worker down").asRuntimeException());
|
||||
}
|
||||
};
|
||||
|
||||
try (Harness harness = Harness.start(service)) {
|
||||
CompletableFuture<AcknowledgeAlarmReply> future = harness.client()
|
||||
.acknowledgeAlarmAsync(AcknowledgeAlarmRequest.newBuilder()
|
||||
.setAlarmFullReference("Area1.Pump.PV.HiHi")
|
||||
.build());
|
||||
ExecutionException error = assertThrows(
|
||||
ExecutionException.class, () -> future.get(5, TimeUnit.SECONDS));
|
||||
assertTrue(error.getCause() instanceof MxGatewayException, () -> String.valueOf(error.getCause()));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Client.Java-007: StreamAlarms RPC + subscription coverage ---
|
||||
|
||||
@Test
|
||||
void streamAlarmsDeliversFeedMessagesToObserver() throws Exception {
|
||||
AlarmFeedMessage active = AlarmFeedMessage.newBuilder()
|
||||
.setActiveAlarm(ActiveAlarmSnapshot.newBuilder()
|
||||
.setAlarmFullReference("Area1.Tank.Level.Hi")
|
||||
.setSeverity(800)
|
||||
.setCurrentState(AlarmConditionState.ALARM_CONDITION_STATE_ACTIVE))
|
||||
.build();
|
||||
AlarmFeedMessage snapshotComplete =
|
||||
AlarmFeedMessage.newBuilder().setSnapshotComplete(true).build();
|
||||
TestService service = new TestService() {
|
||||
@Override
|
||||
public void streamAlarms(
|
||||
StreamAlarmsRequest request, StreamObserver<AlarmFeedMessage> responseObserver) {
|
||||
responseObserver.onNext(active);
|
||||
responseObserver.onNext(snapshotComplete);
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
};
|
||||
|
||||
try (Harness harness = Harness.start(service)) {
|
||||
List<AlarmFeedMessage> received = new ArrayList<>();
|
||||
CountDownLatch done = new CountDownLatch(1);
|
||||
harness.client().streamAlarms(
|
||||
StreamAlarmsRequest.newBuilder().build(),
|
||||
new StreamObserver<>() {
|
||||
@Override
|
||||
public void onNext(AlarmFeedMessage value) {
|
||||
received.add(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable t) {
|
||||
done.countDown();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCompleted() {
|
||||
done.countDown();
|
||||
}
|
||||
});
|
||||
assertTrue(done.await(5, TimeUnit.SECONDS), "stream should complete");
|
||||
assertEquals(2, received.size());
|
||||
assertEquals("Area1.Tank.Level.Hi", received.get(0).getActiveAlarm().getAlarmFullReference());
|
||||
assertTrue(received.get(1).getSnapshotComplete());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void alarmFeedSubscriptionCancelBeforeBeforeStartCancelsStream() {
|
||||
MxGatewayAlarmFeedSubscription subscription = new MxGatewayAlarmFeedSubscription();
|
||||
ClientResponseObserver<StreamAlarmsRequest, AlarmFeedMessage> observer =
|
||||
subscription.wrap(new StreamObserver<>() {
|
||||
@Override
|
||||
public void onNext(AlarmFeedMessage value) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable t) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCompleted() {
|
||||
}
|
||||
});
|
||||
RecordingAlarmFeedRequestStream requestStream = new RecordingAlarmFeedRequestStream();
|
||||
|
||||
subscription.cancel();
|
||||
observer.beforeStart(requestStream);
|
||||
|
||||
assertTrue(requestStream.cancelled);
|
||||
assertEquals("client cancelled alarm feed", requestStream.cancelMessage);
|
||||
}
|
||||
|
||||
// --- Client.Java-007: async streamEvents + subscription cancellation ---
|
||||
|
||||
@Test
|
||||
void streamEventsAsyncDeliversEventsToObserver() throws Exception {
|
||||
MxEvent event = MxEvent.newBuilder().setWorkerSequence(7).build();
|
||||
TestService service = new TestService() {
|
||||
@Override
|
||||
public void streamEvents(StreamEventsRequest request, StreamObserver<MxEvent> responseObserver) {
|
||||
responseObserver.onNext(event);
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
};
|
||||
|
||||
try (Harness harness = Harness.start(service)) {
|
||||
List<MxEvent> received = new ArrayList<>();
|
||||
CountDownLatch done = new CountDownLatch(1);
|
||||
harness.client().streamEventsAsync(
|
||||
StreamEventsRequest.newBuilder().setSessionId("s-5").build(),
|
||||
new StreamObserver<>() {
|
||||
@Override
|
||||
public void onNext(MxEvent value) {
|
||||
received.add(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable t) {
|
||||
done.countDown();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCompleted() {
|
||||
done.countDown();
|
||||
}
|
||||
});
|
||||
assertTrue(done.await(5, TimeUnit.SECONDS), "stream should complete");
|
||||
assertEquals(1, received.size());
|
||||
assertEquals(7, received.get(0).getWorkerSequence());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void eventSubscriptionCancelBeforeBeforeStartCancelsStream() {
|
||||
MxGatewayEventSubscription subscription = new MxGatewayEventSubscription();
|
||||
ClientResponseObserver<StreamEventsRequest, MxEvent> observer =
|
||||
subscription.wrap(new StreamObserver<>() {
|
||||
@Override
|
||||
public void onNext(MxEvent value) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable t) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCompleted() {
|
||||
}
|
||||
});
|
||||
RecordingEventsRequestStream requestStream = new RecordingEventsRequestStream();
|
||||
|
||||
subscription.cancel();
|
||||
observer.beforeStart(requestStream);
|
||||
|
||||
assertTrue(requestStream.cancelled);
|
||||
assertEquals("client cancelled event stream", requestStream.cancelMessage);
|
||||
}
|
||||
|
||||
// --- Client.Java-007 / Client.Java-011: MxEventStream queue overflow ---
|
||||
|
||||
@Test
|
||||
void eventStreamQueueOverflowSurfacesExceptionFromNext() {
|
||||
MxEventStream stream = new MxEventStream(2);
|
||||
ClientResponseObserver<StreamEventsRequest, MxEvent> observer = stream.observer();
|
||||
RecordingEventsRequestStream requestStream = new RecordingEventsRequestStream();
|
||||
observer.beforeStart(requestStream);
|
||||
|
||||
// Push far more events than the capacity-2 buffer can hold without draining.
|
||||
for (int i = 0; i < 16; i++) {
|
||||
observer.onNext(MxEvent.newBuilder().setWorkerSequence(i).build());
|
||||
}
|
||||
|
||||
// Overflow must cancel the gRPC call and surface as MxGatewayException.
|
||||
assertTrue(requestStream.cancelled, "overflow should cancel the underlying call");
|
||||
MxGatewayException error = assertThrows(MxGatewayException.class, () -> {
|
||||
while (stream.hasNext()) {
|
||||
stream.next();
|
||||
}
|
||||
});
|
||||
assertTrue(error.getMessage().contains("overflow"), error::getMessage);
|
||||
}
|
||||
|
||||
// --- Client.Java-007: TLS channel construction ---
|
||||
|
||||
@Test
|
||||
void connectWithMissingCaCertificateThrowsTypedTlsException() {
|
||||
MxGatewayClientOptions options = MxGatewayClientOptions.builder()
|
||||
.endpoint("localhost:5001")
|
||||
.apiKey("mxgw_id_secret")
|
||||
.plaintext(false)
|
||||
.caCertificatePath(Path.of("does-not-exist-" + UUID.randomUUID() + ".pem"))
|
||||
.build();
|
||||
|
||||
MxGatewayException error = assertThrows(MxGatewayException.class, () -> MxGatewayClient.connect(options));
|
||||
assertTrue(error.getMessage().contains("TLS"), error::getMessage);
|
||||
|
||||
MxGatewayException galaxyError =
|
||||
assertThrows(MxGatewayException.class, () -> GalaxyRepositoryClient.connect(options));
|
||||
assertTrue(galaxyError.getMessage().contains("TLS"), galaxyError::getMessage);
|
||||
}
|
||||
|
||||
@Test
|
||||
void connectWithSystemTrustBuildsTlsChannelWithoutError() {
|
||||
// No CA path and plaintext=false exercises the useTransportSecurity() branch.
|
||||
MxGatewayClientOptions options = MxGatewayClientOptions.builder()
|
||||
.endpoint("localhost:5001")
|
||||
.apiKey("mxgw_id_secret")
|
||||
.plaintext(false)
|
||||
.build();
|
||||
|
||||
try (MxGatewayClient client = MxGatewayClient.connect(options)) {
|
||||
assertNotNull(client);
|
||||
}
|
||||
try (GalaxyRepositoryClient galaxy = GalaxyRepositoryClient.connect(options)) {
|
||||
assertNotNull(galaxy);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Client.Java-008: async error surface is normalised ---
|
||||
|
||||
@Test
|
||||
void openSessionAsyncNormalisesNonGatewayRuntimeExceptionFromValidator() {
|
||||
// ensureGatewayProtocolCompatible already throws MxGatewayException; this verifies
|
||||
// the normalisingValidator wrapper routes a stray RuntimeException through fromGrpc.
|
||||
CompletableFuture<String> source = new CompletableFuture<>();
|
||||
CompletableFuture<String> wrapped =
|
||||
source.thenApply(MxGatewayChannels.normalisingValidator("open session", reply -> {
|
||||
throw new IllegalStateException("malformed reply");
|
||||
}));
|
||||
source.complete("payload");
|
||||
|
||||
CompletionException error = assertThrows(CompletionException.class, wrapped::join);
|
||||
assertTrue(error.getCause() instanceof MxGatewayException, () -> String.valueOf(error.getCause()));
|
||||
}
|
||||
|
||||
private static ProtocolStatus ok() {
|
||||
return ProtocolStatus.newBuilder()
|
||||
.setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK)
|
||||
.build();
|
||||
}
|
||||
|
||||
private static class TestService extends MxAccessGatewayGrpc.MxAccessGatewayImplBase {
|
||||
}
|
||||
|
||||
private record Harness(Server server, ManagedChannel channel, MxGatewayClient client) implements AutoCloseable {
|
||||
static Harness start(MxAccessGatewayGrpc.MxAccessGatewayImplBase service) throws Exception {
|
||||
return start(service, "", new AtomicReference<>());
|
||||
}
|
||||
|
||||
static Harness start(
|
||||
MxAccessGatewayGrpc.MxAccessGatewayImplBase service,
|
||||
String apiKey,
|
||||
AtomicReference<String> authorization)
|
||||
throws Exception {
|
||||
String name = "mxgw-low-" + UUID.randomUUID();
|
||||
io.grpc.ServerInterceptor interceptor = new io.grpc.ServerInterceptor() {
|
||||
@Override
|
||||
public <ReqT, RespT> io.grpc.ServerCall.Listener<ReqT> interceptCall(
|
||||
io.grpc.ServerCall<ReqT, RespT> call,
|
||||
io.grpc.Metadata headers,
|
||||
io.grpc.ServerCallHandler<ReqT, RespT> next) {
|
||||
authorization.set(headers.get(MxGatewayAuthInterceptor.AUTHORIZATION_HEADER));
|
||||
return next.startCall(call, headers);
|
||||
}
|
||||
};
|
||||
Server server = InProcessServerBuilder.forName(name)
|
||||
.directExecutor()
|
||||
.addService(io.grpc.ServerInterceptors.intercept(service, interceptor))
|
||||
.build()
|
||||
.start();
|
||||
ManagedChannel channel = InProcessChannelBuilder.forName(name).directExecutor().build();
|
||||
MxGatewayClient client = new MxGatewayClient(
|
||||
channel,
|
||||
MxGatewayClientOptions.builder()
|
||||
.endpoint("in-process")
|
||||
.apiKey(apiKey)
|
||||
.plaintext(true)
|
||||
.callTimeout(Duration.ofSeconds(5))
|
||||
.streamTimeout(Duration.ofSeconds(5))
|
||||
.build());
|
||||
return new Harness(server, channel, client);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
channel.shutdownNow();
|
||||
server.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingEventsRequestStream
|
||||
extends ClientCallStreamObserver<StreamEventsRequest> {
|
||||
private boolean cancelled;
|
||||
private String cancelMessage;
|
||||
|
||||
@Override
|
||||
public void cancel(String message, Throwable cause) {
|
||||
cancelled = true;
|
||||
cancelMessage = message;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isReady() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setOnReadyHandler(Runnable onReadyHandler) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void request(int count) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setMessageCompression(boolean enable) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disableAutoInboundFlowControl() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNext(StreamEventsRequest value) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable t) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCompleted() {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingAlarmFeedRequestStream
|
||||
extends ClientCallStreamObserver<StreamAlarmsRequest> {
|
||||
private boolean cancelled;
|
||||
private String cancelMessage;
|
||||
|
||||
@Override
|
||||
public void cancel(String message, Throwable cause) {
|
||||
cancelled = true;
|
||||
cancelMessage = message;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isReady() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setOnReadyHandler(Runnable onReadyHandler) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void request(int count) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setMessageCompression(boolean enable) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disableAutoInboundFlowControl() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNext(StreamAlarmsRequest value) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable t) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCompleted() {
|
||||
}
|
||||
}
|
||||
}
|
||||
+527
@@ -0,0 +1,527 @@
|
||||
package com.dohertylan.mxgateway.client;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import io.grpc.ManagedChannel;
|
||||
import io.grpc.Server;
|
||||
import io.grpc.inprocess.InProcessChannelBuilder;
|
||||
import io.grpc.inprocess.InProcessServerBuilder;
|
||||
import io.grpc.stub.StreamObserver;
|
||||
import java.time.Duration;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import mxaccess_gateway.v1.MxAccessGatewayGrpc;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxCommandKind;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Regression tests for the Medium-severity Client.Java code-review findings
|
||||
* (Client.Java-001 through Client.Java-005, and Client.Java-014/015).
|
||||
*/
|
||||
final class MxGatewayMediumFindingsTests {
|
||||
|
||||
// --- Client.Java-001: redactApiKey must not leak trailing secret chars ---
|
||||
|
||||
@Test
|
||||
void redactApiKeyDoesNotLeakAnyCharacterOfTheSecret() {
|
||||
// mxgw_<key-id>_<secret> — the secret is the segment after the second underscore.
|
||||
String apiKey = "mxgw_keyid01_supersecretvalue";
|
||||
String redacted = MxGatewaySecrets.redactApiKey(apiKey);
|
||||
|
||||
// None of the secret characters may appear in the redacted output.
|
||||
assertFalse(redacted.contains("value"), () -> "redacted form leaked secret tail: " + redacted);
|
||||
assertFalse(redacted.endsWith("alue"), () -> "redacted form leaked trailing secret chars: " + redacted);
|
||||
assertFalse(redacted.contains("supersecret"), () -> "redacted form leaked secret: " + redacted);
|
||||
// The non-secret key-id prefix may stay so the value is still comparable in logs.
|
||||
assertTrue(redacted.startsWith("mxgw_keyid01_"), () -> "redacted form lost key-id prefix: " + redacted);
|
||||
}
|
||||
|
||||
@Test
|
||||
void redactApiKeyForNonGatewayShapedKeyRevealsNothing() {
|
||||
String redacted = MxGatewaySecrets.redactApiKey("plain-opaque-token-1234");
|
||||
assertFalse(redacted.contains("1234"), () -> "redacted form leaked trailing chars: " + redacted);
|
||||
assertFalse(redacted.contains("plain-opaque-token"), () -> "redacted form leaked body: " + redacted);
|
||||
}
|
||||
|
||||
@Test
|
||||
void redactApiKeyStillHandlesNullAndShortInput() {
|
||||
assertEquals("", MxGatewaySecrets.redactApiKey(null));
|
||||
assertEquals("", MxGatewaySecrets.redactApiKey(""));
|
||||
assertEquals("<redacted>", MxGatewaySecrets.redactApiKey("short"));
|
||||
}
|
||||
|
||||
// --- Client.Java-002: terminal-state transition must be deterministic ---
|
||||
|
||||
@Test
|
||||
void eventStreamOverflowExceptionSurvivesASubsequentClose() {
|
||||
// Deterministic reproduction of Client.Java-002: an overflow enqueues the
|
||||
// overflow exception, then a later close() must NOT discard it. The first
|
||||
// terminal condition (overflow) must win and stay observable by next().
|
||||
MxEventStream stream = new MxEventStream(2);
|
||||
io.grpc.stub.ClientResponseObserver<
|
||||
mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest,
|
||||
mxaccess_gateway.v1.MxaccessGateway.MxEvent>
|
||||
observer = stream.observer();
|
||||
observer.beforeStart(new NoopRequestStream());
|
||||
|
||||
// Force a queue overflow on a capacity-2 stream.
|
||||
for (int i = 0; i < 8; i++) {
|
||||
observer.onNext(testEvent(i));
|
||||
}
|
||||
|
||||
// A close() arriving after the overflow must not erase the overflow signal.
|
||||
stream.close();
|
||||
|
||||
MxGatewayException error = assertThrows(MxGatewayException.class, () -> {
|
||||
while (stream.hasNext()) {
|
||||
stream.next();
|
||||
}
|
||||
});
|
||||
assertTrue(error.getMessage().contains("overflow"), error::getMessage);
|
||||
}
|
||||
|
||||
@Test
|
||||
void eventStreamConcurrentOverflowAndCloseAlwaysTerminate() throws Exception {
|
||||
// The terminal-state transition must be serialised: whatever the interleaving
|
||||
// of overflow and close, hasNext() always reaches a terminal state.
|
||||
for (int iteration = 0; iteration < 300; iteration++) {
|
||||
MxEventStream stream = new MxEventStream(2);
|
||||
io.grpc.stub.ClientResponseObserver<
|
||||
mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest,
|
||||
mxaccess_gateway.v1.MxaccessGateway.MxEvent>
|
||||
observer = stream.observer();
|
||||
observer.beforeStart(new NoopRequestStream());
|
||||
|
||||
Thread filler = new Thread(() -> {
|
||||
for (int i = 0; i < 8; i++) {
|
||||
observer.onNext(testEvent(i));
|
||||
}
|
||||
});
|
||||
Thread closer = new Thread(stream::close);
|
||||
filler.start();
|
||||
closer.start();
|
||||
filler.join();
|
||||
closer.join();
|
||||
|
||||
try {
|
||||
while (stream.hasNext()) {
|
||||
stream.next();
|
||||
}
|
||||
} catch (MxGatewayException expected) {
|
||||
assertTrue(expected.getMessage().contains("overflow"), expected::getMessage);
|
||||
}
|
||||
assertFalse(stream.hasNext());
|
||||
}
|
||||
}
|
||||
|
||||
private static final class NoopRequestStream
|
||||
extends io.grpc.stub.ClientCallStreamObserver<mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest> {
|
||||
@Override
|
||||
public void cancel(String message, Throwable cause) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isReady() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setOnReadyHandler(Runnable onReadyHandler) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void request(int count) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setMessageCompression(boolean enable) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disableAutoInboundFlowControl() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNext(mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest value) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable t) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCompleted() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Client.Java-003: gateway protocol version mismatch must be rejected ---
|
||||
|
||||
@Test
|
||||
void openSessionRejectsIncompatibleGatewayProtocolVersion() throws Exception {
|
||||
TestService service = new TestService() {
|
||||
@Override
|
||||
public void openSession(OpenSessionRequest request, StreamObserver<OpenSessionReply> responseObserver) {
|
||||
responseObserver.onNext(OpenSessionReply.newBuilder()
|
||||
.setSessionId("session-mismatch")
|
||||
.setGatewayProtocolVersion(MxGatewayClientVersion.gatewayProtocolVersion() + 1)
|
||||
.setProtocolStatus(ok())
|
||||
.build());
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
};
|
||||
|
||||
try (Harness harness = Harness.start(service)) {
|
||||
MxGatewayException error = assertThrows(
|
||||
MxGatewayException.class,
|
||||
() -> harness.client().openSession("junit-session"));
|
||||
assertTrue(error.getMessage().contains("protocol version"), error::getMessage);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void openSessionAcceptsMatchingOrUnsetGatewayProtocolVersion() throws Exception {
|
||||
TestService matching = new TestService() {
|
||||
@Override
|
||||
public void openSession(OpenSessionRequest request, StreamObserver<OpenSessionReply> responseObserver) {
|
||||
responseObserver.onNext(OpenSessionReply.newBuilder()
|
||||
.setSessionId("session-ok")
|
||||
.setGatewayProtocolVersion(MxGatewayClientVersion.gatewayProtocolVersion())
|
||||
.setProtocolStatus(ok())
|
||||
.build());
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
};
|
||||
try (Harness harness = Harness.start(matching)) {
|
||||
assertEquals("session-ok", harness.client().openSession("junit-session").sessionId());
|
||||
}
|
||||
|
||||
// A gateway that leaves the field unset (0) must not be rejected — older gateways
|
||||
// simply do not populate it.
|
||||
TestService unset = new TestService();
|
||||
try (Harness harness = Harness.start(unset)) {
|
||||
assertEquals("session-java", harness.client().openSession("junit-session").sessionId());
|
||||
}
|
||||
}
|
||||
|
||||
// --- Client.Java-004: missing typed payload AND missing return_value must throw ---
|
||||
|
||||
@Test
|
||||
void registerThrowsWhenReplyHasNeitherTypedPayloadNorReturnValue() throws Exception {
|
||||
TestService service = new TestService() {
|
||||
@Override
|
||||
public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
|
||||
// Reply with neither register payload nor return_value set.
|
||||
responseObserver.onNext(MxCommandReply.newBuilder()
|
||||
.setSessionId(request.getSessionId())
|
||||
.setKind(request.getCommand().getKind())
|
||||
.setProtocolStatus(ok())
|
||||
.build());
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
};
|
||||
|
||||
try (Harness harness = Harness.start(service)) {
|
||||
MxGatewaySession session = MxGatewaySession.forSessionId(harness.client(), "s");
|
||||
MxGatewayException error = assertThrows(
|
||||
MxGatewayException.class, () -> session.register("c"));
|
||||
assertTrue(error.getMessage().contains("register"), error::getMessage);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void addItemThrowsWhenReplyHasNeitherTypedPayloadNorReturnValue() throws Exception {
|
||||
TestService service = new TestService() {
|
||||
@Override
|
||||
public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
|
||||
responseObserver.onNext(MxCommandReply.newBuilder()
|
||||
.setSessionId(request.getSessionId())
|
||||
.setKind(request.getCommand().getKind())
|
||||
.setProtocolStatus(ok())
|
||||
.build());
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
};
|
||||
|
||||
try (Harness harness = Harness.start(service)) {
|
||||
MxGatewaySession session = MxGatewaySession.forSessionId(harness.client(), "s");
|
||||
assertThrows(MxGatewayException.class, () -> session.addItem(1, "Tag"));
|
||||
assertThrows(MxGatewayException.class, () -> session.addItem2(1, "Tag", "ctx"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void addItemStillHonoursReturnValueFallback() throws Exception {
|
||||
TestService service = new TestService() {
|
||||
@Override
|
||||
public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
|
||||
responseObserver.onNext(MxCommandReply.newBuilder()
|
||||
.setSessionId(request.getSessionId())
|
||||
.setKind(request.getCommand().getKind())
|
||||
.setProtocolStatus(ok())
|
||||
.setReturnValue(mxaccess_gateway.v1.MxaccessGateway.MxValue.newBuilder()
|
||||
.setInt32Value(99))
|
||||
.build());
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
};
|
||||
|
||||
try (Harness harness = Harness.start(service)) {
|
||||
MxGatewaySession session = MxGatewaySession.forSessionId(harness.client(), "s");
|
||||
assertEquals(99, session.addItem(1, "Tag"));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Client.Java-005: close() must not mask the primary try-with-resources error ---
|
||||
|
||||
@Test
|
||||
void closeSuppressesCloseTimeFailureInsteadOfMaskingBodyException() throws Exception {
|
||||
TestService service = new TestService() {
|
||||
@Override
|
||||
public void closeSession(CloseSessionRequest request, StreamObserver<CloseSessionReply> responseObserver) {
|
||||
responseObserver.onError(io.grpc.Status.UNAVAILABLE
|
||||
.withDescription("WORKER_UNAVAILABLE")
|
||||
.asRuntimeException());
|
||||
}
|
||||
};
|
||||
|
||||
try (Harness harness = Harness.start(service)) {
|
||||
IllegalStateException bodyError = assertThrows(IllegalStateException.class, () -> {
|
||||
try (MxGatewaySession session = MxGatewaySession.forSessionId(harness.client(), "s")) {
|
||||
throw new IllegalStateException("body failure");
|
||||
}
|
||||
});
|
||||
// The body exception must propagate; the close-time RPC failure must not replace it.
|
||||
assertEquals("body failure", bodyError.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void closeRawStillSurfacesCloseTimeFailureForCallersWhoWantIt() throws Exception {
|
||||
TestService service = new TestService() {
|
||||
@Override
|
||||
public void closeSession(CloseSessionRequest request, StreamObserver<CloseSessionReply> responseObserver) {
|
||||
responseObserver.onError(io.grpc.Status.UNAVAILABLE
|
||||
.withDescription("WORKER_UNAVAILABLE")
|
||||
.asRuntimeException());
|
||||
}
|
||||
};
|
||||
|
||||
try (Harness harness = Harness.start(service)) {
|
||||
MxGatewaySession session = MxGatewaySession.forSessionId(harness.client(), "s");
|
||||
assertThrows(MxGatewayException.class, session::closeRaw);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Client.Java-014: MxEventStream.close() before beforeStart must cancel the call ---
|
||||
|
||||
@Test
|
||||
void mxEventStreamCloseBeforeBeforeStartCancelsStream() {
|
||||
// Mirrors GalaxyRepositoryClientTests.deployEventStreamCloseBeforeBeforeStartCancelsStream:
|
||||
// if close() runs before the gRPC call has attached its ClientCallStreamObserver,
|
||||
// beforeStart() must observe the prior close and cancel the underlying call so the
|
||||
// gRPC subscription does not leak open after the consumer has stopped iterating.
|
||||
MxEventStream stream = new MxEventStream(4);
|
||||
io.grpc.stub.ClientResponseObserver<
|
||||
mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest,
|
||||
mxaccess_gateway.v1.MxaccessGateway.MxEvent>
|
||||
observer = stream.observer();
|
||||
RecordingEventsRequestStream requestStream = new RecordingEventsRequestStream();
|
||||
|
||||
stream.close();
|
||||
observer.beforeStart(requestStream);
|
||||
|
||||
assertTrue(requestStream.cancelled, "beforeStart must cancel the underlying call after a prior close()");
|
||||
assertEquals("client cancelled event stream", requestStream.cancelMessage);
|
||||
assertFalse(stream.hasNext());
|
||||
}
|
||||
|
||||
// --- Client.Java-015: cancelling the user-visible *Async future cancels the gRPC call ---
|
||||
|
||||
@Test
|
||||
void invokeAsyncCancellationCancelsUnderlyingGrpcCall() throws Exception {
|
||||
// Set up a gateway service that never completes the invoke call so cancellation is
|
||||
// the only way the call terminates. Hook ServerCallStreamObserver.setOnCancelHandler
|
||||
// to latch when the server observes cancellation.
|
||||
java.util.concurrent.CountDownLatch serverCancelled = new java.util.concurrent.CountDownLatch(1);
|
||||
TestService service = new TestService() {
|
||||
@Override
|
||||
public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
|
||||
io.grpc.stub.ServerCallStreamObserver<MxCommandReply> serverObserver =
|
||||
(io.grpc.stub.ServerCallStreamObserver<MxCommandReply>) responseObserver;
|
||||
serverObserver.setOnCancelHandler(serverCancelled::countDown);
|
||||
// Intentionally never complete — the call must be terminated by the client
|
||||
// cancelling its future, which must propagate to the gRPC cancellation.
|
||||
}
|
||||
};
|
||||
|
||||
try (Harness harness = Harness.start(service)) {
|
||||
CompletableFuture<MxCommandReply> future = harness.client().invokeAsync(MxCommandRequest.newBuilder()
|
||||
.setSessionId("s-cancel")
|
||||
.setCommand(mxaccess_gateway.v1.MxaccessGateway.MxCommand.newBuilder()
|
||||
.setKind(MxCommandKind.MX_COMMAND_KIND_REGISTER))
|
||||
.build());
|
||||
|
||||
// Cancellation of the user-visible future must propagate to the gRPC call.
|
||||
assertTrue(future.cancel(true), "cancel(true) should return true on a pending future");
|
||||
assertTrue(
|
||||
serverCancelled.await(5, java.util.concurrent.TimeUnit.SECONDS),
|
||||
"server must observe RPC cancellation after future.cancel(true)");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void toCompletableValidatorOverloadForwardsCancellationToSource() {
|
||||
// Unit-level proof: cancel() on the future returned by the validator-aware
|
||||
// toCompletable overload must call cancel(true) on the source ListenableFuture.
|
||||
// This is the core fix for Client.Java-015 — the validator runs inside
|
||||
// toCompletable instead of via .thenApply, so the user holds the future
|
||||
// that is bound to the source.
|
||||
com.google.common.util.concurrent.SettableFuture<String> source =
|
||||
com.google.common.util.concurrent.SettableFuture.create();
|
||||
java.util.concurrent.CompletableFuture<Integer> target =
|
||||
MxGatewayChannels.toCompletable(source, "noop", String::length);
|
||||
|
||||
assertFalse(source.isCancelled());
|
||||
assertTrue(target.cancel(true));
|
||||
assertTrue(source.isCancelled(), "source ListenableFuture must be cancelled");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toCompletableNoValidatorOverloadForwardsCancellationToSource() {
|
||||
// Regression for the no-validator overload (the historic toCompletable shape).
|
||||
com.google.common.util.concurrent.SettableFuture<String> source =
|
||||
com.google.common.util.concurrent.SettableFuture.create();
|
||||
java.util.concurrent.CompletableFuture<String> target = MxGatewayChannels.toCompletable(source, "noop");
|
||||
|
||||
assertFalse(source.isCancelled());
|
||||
assertTrue(target.cancel(true));
|
||||
assertTrue(source.isCancelled(), "source ListenableFuture must be cancelled");
|
||||
}
|
||||
|
||||
private static final class RecordingEventsRequestStream
|
||||
extends io.grpc.stub.ClientCallStreamObserver<
|
||||
mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest> {
|
||||
private boolean cancelled;
|
||||
private String cancelMessage;
|
||||
|
||||
@Override
|
||||
public void cancel(String message, Throwable cause) {
|
||||
cancelled = true;
|
||||
cancelMessage = message;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isReady() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setOnReadyHandler(Runnable onReadyHandler) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void request(int count) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setMessageCompression(boolean enable) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disableAutoInboundFlowControl() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNext(mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest value) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable t) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCompleted() {
|
||||
}
|
||||
}
|
||||
|
||||
private static mxaccess_gateway.v1.MxaccessGateway.MxEvent testEvent(int sequence) {
|
||||
return mxaccess_gateway.v1.MxaccessGateway.MxEvent.newBuilder()
|
||||
.setWorkerSequence(sequence)
|
||||
.build();
|
||||
}
|
||||
|
||||
private static ProtocolStatus ok() {
|
||||
return ProtocolStatus.newBuilder()
|
||||
.setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK)
|
||||
.build();
|
||||
}
|
||||
|
||||
private static class TestService extends MxAccessGatewayGrpc.MxAccessGatewayImplBase {
|
||||
@Override
|
||||
public void openSession(OpenSessionRequest request, StreamObserver<OpenSessionReply> responseObserver) {
|
||||
responseObserver.onNext(OpenSessionReply.newBuilder()
|
||||
.setSessionId("session-java")
|
||||
.setProtocolStatus(ok())
|
||||
.build());
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void closeSession(CloseSessionRequest request, StreamObserver<CloseSessionReply> responseObserver) {
|
||||
responseObserver.onNext(CloseSessionReply.newBuilder()
|
||||
.setSessionId(request.getSessionId())
|
||||
.setProtocolStatus(ok())
|
||||
.build());
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
|
||||
responseObserver.onNext(MxCommandReply.newBuilder()
|
||||
.setSessionId(request.getSessionId())
|
||||
.setKind(MxCommandKind.MX_COMMAND_KIND_UNSPECIFIED)
|
||||
.setProtocolStatus(ok())
|
||||
.build());
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
}
|
||||
|
||||
private record Harness(Server server, ManagedChannel channel, MxGatewayClient client) implements AutoCloseable {
|
||||
static Harness start(MxAccessGatewayGrpc.MxAccessGatewayImplBase service) throws Exception {
|
||||
String name = "mxgw-medium-" + UUID.randomUUID();
|
||||
Server server = InProcessServerBuilder.forName(name)
|
||||
.directExecutor()
|
||||
.addService(service)
|
||||
.build()
|
||||
.start();
|
||||
ManagedChannel channel = InProcessChannelBuilder.forName(name).directExecutor().build();
|
||||
MxGatewayClient client = new MxGatewayClient(
|
||||
channel,
|
||||
MxGatewayClientOptions.builder()
|
||||
.endpoint("in-process")
|
||||
.apiKey("")
|
||||
.plaintext(true)
|
||||
.callTimeout(Duration.ofSeconds(5))
|
||||
.build());
|
||||
return new Harness(server, channel, client);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
channel.shutdownNow();
|
||||
server.shutdownNow();
|
||||
}
|
||||
}
|
||||
}
|
||||
+185
@@ -139,6 +139,68 @@ public final class MxAccessGatewayGrpc {
|
||||
return getStreamEventsMethod;
|
||||
}
|
||||
|
||||
private static volatile io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest,
|
||||
mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply> getAcknowledgeAlarmMethod;
|
||||
|
||||
@io.grpc.stub.annotations.RpcMethod(
|
||||
fullMethodName = SERVICE_NAME + '/' + "AcknowledgeAlarm",
|
||||
requestType = mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest.class,
|
||||
responseType = mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply.class,
|
||||
methodType = io.grpc.MethodDescriptor.MethodType.UNARY)
|
||||
public static io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest,
|
||||
mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply> getAcknowledgeAlarmMethod() {
|
||||
io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest, mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply> getAcknowledgeAlarmMethod;
|
||||
if ((getAcknowledgeAlarmMethod = MxAccessGatewayGrpc.getAcknowledgeAlarmMethod) == null) {
|
||||
synchronized (MxAccessGatewayGrpc.class) {
|
||||
if ((getAcknowledgeAlarmMethod = MxAccessGatewayGrpc.getAcknowledgeAlarmMethod) == null) {
|
||||
MxAccessGatewayGrpc.getAcknowledgeAlarmMethod = getAcknowledgeAlarmMethod =
|
||||
io.grpc.MethodDescriptor.<mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest, mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply>newBuilder()
|
||||
.setType(io.grpc.MethodDescriptor.MethodType.UNARY)
|
||||
.setFullMethodName(generateFullMethodName(SERVICE_NAME, "AcknowledgeAlarm"))
|
||||
.setSampledToLocalTracing(true)
|
||||
.setRequestMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
|
||||
mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest.getDefaultInstance()))
|
||||
.setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
|
||||
mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply.getDefaultInstance()))
|
||||
.setSchemaDescriptor(new MxAccessGatewayMethodDescriptorSupplier("AcknowledgeAlarm"))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
}
|
||||
return getAcknowledgeAlarmMethod;
|
||||
}
|
||||
|
||||
private static volatile io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest,
|
||||
mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage> getStreamAlarmsMethod;
|
||||
|
||||
@io.grpc.stub.annotations.RpcMethod(
|
||||
fullMethodName = SERVICE_NAME + '/' + "StreamAlarms",
|
||||
requestType = mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest.class,
|
||||
responseType = mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage.class,
|
||||
methodType = io.grpc.MethodDescriptor.MethodType.SERVER_STREAMING)
|
||||
public static io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest,
|
||||
mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage> getStreamAlarmsMethod() {
|
||||
io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest, mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage> getStreamAlarmsMethod;
|
||||
if ((getStreamAlarmsMethod = MxAccessGatewayGrpc.getStreamAlarmsMethod) == null) {
|
||||
synchronized (MxAccessGatewayGrpc.class) {
|
||||
if ((getStreamAlarmsMethod = MxAccessGatewayGrpc.getStreamAlarmsMethod) == null) {
|
||||
MxAccessGatewayGrpc.getStreamAlarmsMethod = getStreamAlarmsMethod =
|
||||
io.grpc.MethodDescriptor.<mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest, mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage>newBuilder()
|
||||
.setType(io.grpc.MethodDescriptor.MethodType.SERVER_STREAMING)
|
||||
.setFullMethodName(generateFullMethodName(SERVICE_NAME, "StreamAlarms"))
|
||||
.setSampledToLocalTracing(true)
|
||||
.setRequestMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
|
||||
mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest.getDefaultInstance()))
|
||||
.setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
|
||||
mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage.getDefaultInstance()))
|
||||
.setSchemaDescriptor(new MxAccessGatewayMethodDescriptorSupplier("StreamAlarms"))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
}
|
||||
return getStreamAlarmsMethod;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new async stub that supports all call types for the service
|
||||
*/
|
||||
@@ -232,6 +294,27 @@ public final class MxAccessGatewayGrpc {
|
||||
io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.MxEvent> responseObserver) {
|
||||
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getStreamEventsMethod(), responseObserver);
|
||||
}
|
||||
|
||||
/**
|
||||
*/
|
||||
default void acknowledgeAlarm(mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest request,
|
||||
io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply> responseObserver) {
|
||||
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getAcknowledgeAlarmMethod(), responseObserver);
|
||||
}
|
||||
|
||||
/**
|
||||
* <pre>
|
||||
* Session-less central alarm feed. The stream opens with the current
|
||||
* active-alarm snapshot (one `active_alarm` per alarm), then a single
|
||||
* `snapshot_complete`, then a `transition` for every subsequent change.
|
||||
* Served by the gateway's always-on alarm monitor; any number of clients
|
||||
* fan out from the single monitor without opening a worker session.
|
||||
* </pre>
|
||||
*/
|
||||
default void streamAlarms(mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest request,
|
||||
io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage> responseObserver) {
|
||||
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getStreamAlarmsMethod(), responseObserver);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -298,6 +381,29 @@ public final class MxAccessGatewayGrpc {
|
||||
io.grpc.stub.ClientCalls.asyncServerStreamingCall(
|
||||
getChannel().newCall(getStreamEventsMethod(), getCallOptions()), request, responseObserver);
|
||||
}
|
||||
|
||||
/**
|
||||
*/
|
||||
public void acknowledgeAlarm(mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest request,
|
||||
io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply> responseObserver) {
|
||||
io.grpc.stub.ClientCalls.asyncUnaryCall(
|
||||
getChannel().newCall(getAcknowledgeAlarmMethod(), getCallOptions()), request, responseObserver);
|
||||
}
|
||||
|
||||
/**
|
||||
* <pre>
|
||||
* Session-less central alarm feed. The stream opens with the current
|
||||
* active-alarm snapshot (one `active_alarm` per alarm), then a single
|
||||
* `snapshot_complete`, then a `transition` for every subsequent change.
|
||||
* Served by the gateway's always-on alarm monitor; any number of clients
|
||||
* fan out from the single monitor without opening a worker session.
|
||||
* </pre>
|
||||
*/
|
||||
public void streamAlarms(mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest request,
|
||||
io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage> responseObserver) {
|
||||
io.grpc.stub.ClientCalls.asyncServerStreamingCall(
|
||||
getChannel().newCall(getStreamAlarmsMethod(), getCallOptions()), request, responseObserver);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -348,6 +454,29 @@ public final class MxAccessGatewayGrpc {
|
||||
return io.grpc.stub.ClientCalls.blockingV2ServerStreamingCall(
|
||||
getChannel(), getStreamEventsMethod(), getCallOptions(), request);
|
||||
}
|
||||
|
||||
/**
|
||||
*/
|
||||
public mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply acknowledgeAlarm(mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest request) throws io.grpc.StatusException {
|
||||
return io.grpc.stub.ClientCalls.blockingV2UnaryCall(
|
||||
getChannel(), getAcknowledgeAlarmMethod(), getCallOptions(), request);
|
||||
}
|
||||
|
||||
/**
|
||||
* <pre>
|
||||
* Session-less central alarm feed. The stream opens with the current
|
||||
* active-alarm snapshot (one `active_alarm` per alarm), then a single
|
||||
* `snapshot_complete`, then a `transition` for every subsequent change.
|
||||
* Served by the gateway's always-on alarm monitor; any number of clients
|
||||
* fan out from the single monitor without opening a worker session.
|
||||
* </pre>
|
||||
*/
|
||||
@io.grpc.ExperimentalApi("https://github.com/grpc/grpc-java/issues/10918")
|
||||
public io.grpc.stub.BlockingClientCall<?, mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage>
|
||||
streamAlarms(mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest request) {
|
||||
return io.grpc.stub.ClientCalls.blockingV2ServerStreamingCall(
|
||||
getChannel(), getStreamAlarmsMethod(), getCallOptions(), request);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -397,6 +526,28 @@ public final class MxAccessGatewayGrpc {
|
||||
return io.grpc.stub.ClientCalls.blockingServerStreamingCall(
|
||||
getChannel(), getStreamEventsMethod(), getCallOptions(), request);
|
||||
}
|
||||
|
||||
/**
|
||||
*/
|
||||
public mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply acknowledgeAlarm(mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest request) {
|
||||
return io.grpc.stub.ClientCalls.blockingUnaryCall(
|
||||
getChannel(), getAcknowledgeAlarmMethod(), getCallOptions(), request);
|
||||
}
|
||||
|
||||
/**
|
||||
* <pre>
|
||||
* Session-less central alarm feed. The stream opens with the current
|
||||
* active-alarm snapshot (one `active_alarm` per alarm), then a single
|
||||
* `snapshot_complete`, then a `transition` for every subsequent change.
|
||||
* Served by the gateway's always-on alarm monitor; any number of clients
|
||||
* fan out from the single monitor without opening a worker session.
|
||||
* </pre>
|
||||
*/
|
||||
public java.util.Iterator<mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage> streamAlarms(
|
||||
mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest request) {
|
||||
return io.grpc.stub.ClientCalls.blockingServerStreamingCall(
|
||||
getChannel(), getStreamAlarmsMethod(), getCallOptions(), request);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -441,12 +592,22 @@ public final class MxAccessGatewayGrpc {
|
||||
return io.grpc.stub.ClientCalls.futureUnaryCall(
|
||||
getChannel().newCall(getInvokeMethod(), getCallOptions()), request);
|
||||
}
|
||||
|
||||
/**
|
||||
*/
|
||||
public com.google.common.util.concurrent.ListenableFuture<mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply> acknowledgeAlarm(
|
||||
mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest request) {
|
||||
return io.grpc.stub.ClientCalls.futureUnaryCall(
|
||||
getChannel().newCall(getAcknowledgeAlarmMethod(), getCallOptions()), request);
|
||||
}
|
||||
}
|
||||
|
||||
private static final int METHODID_OPEN_SESSION = 0;
|
||||
private static final int METHODID_CLOSE_SESSION = 1;
|
||||
private static final int METHODID_INVOKE = 2;
|
||||
private static final int METHODID_STREAM_EVENTS = 3;
|
||||
private static final int METHODID_ACKNOWLEDGE_ALARM = 4;
|
||||
private static final int METHODID_STREAM_ALARMS = 5;
|
||||
|
||||
private static final class MethodHandlers<Req, Resp> implements
|
||||
io.grpc.stub.ServerCalls.UnaryMethod<Req, Resp>,
|
||||
@@ -481,6 +642,14 @@ public final class MxAccessGatewayGrpc {
|
||||
serviceImpl.streamEvents((mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest) request,
|
||||
(io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.MxEvent>) responseObserver);
|
||||
break;
|
||||
case METHODID_ACKNOWLEDGE_ALARM:
|
||||
serviceImpl.acknowledgeAlarm((mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest) request,
|
||||
(io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply>) responseObserver);
|
||||
break;
|
||||
case METHODID_STREAM_ALARMS:
|
||||
serviceImpl.streamAlarms((mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest) request,
|
||||
(io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage>) responseObserver);
|
||||
break;
|
||||
default:
|
||||
throw new AssertionError();
|
||||
}
|
||||
@@ -527,6 +696,20 @@ public final class MxAccessGatewayGrpc {
|
||||
mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest,
|
||||
mxaccess_gateway.v1.MxaccessGateway.MxEvent>(
|
||||
service, METHODID_STREAM_EVENTS)))
|
||||
.addMethod(
|
||||
getAcknowledgeAlarmMethod(),
|
||||
io.grpc.stub.ServerCalls.asyncUnaryCall(
|
||||
new MethodHandlers<
|
||||
mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest,
|
||||
mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply>(
|
||||
service, METHODID_ACKNOWLEDGE_ALARM)))
|
||||
.addMethod(
|
||||
getStreamAlarmsMethod(),
|
||||
io.grpc.stub.ServerCalls.asyncServerStreamingCall(
|
||||
new MethodHandlers<
|
||||
mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest,
|
||||
mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage>(
|
||||
service, METHODID_STREAM_ALARMS)))
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -579,6 +762,8 @@ public final class MxAccessGatewayGrpc {
|
||||
.addMethod(getCloseSessionMethod())
|
||||
.addMethod(getInvokeMethod())
|
||||
.addMethod(getStreamEventsMethod())
|
||||
.addMethod(getAcknowledgeAlarmMethod())
|
||||
.addMethod(getStreamAlarmsMethod())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
+212
-62
@@ -1750,7 +1750,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
* <code>.google.protobuf.Timestamp time_of_last_deploy = 2;</code>
|
||||
*/
|
||||
private com.google.protobuf.SingleFieldBuilder<
|
||||
com.google.protobuf.Timestamp, com.google.protobuf.Timestamp.Builder, com.google.protobuf.TimestampOrBuilder>
|
||||
com.google.protobuf.Timestamp, com.google.protobuf.Timestamp.Builder, com.google.protobuf.TimestampOrBuilder>
|
||||
internalGetTimeOfLastDeployFieldBuilder() {
|
||||
if (timeOfLastDeployBuilder_ == null) {
|
||||
timeOfLastDeployBuilder_ = new com.google.protobuf.SingleFieldBuilder<
|
||||
@@ -2175,7 +2175,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
if (ref instanceof java.lang.String) {
|
||||
return (java.lang.String) ref;
|
||||
} else {
|
||||
com.google.protobuf.ByteString bs =
|
||||
com.google.protobuf.ByteString bs =
|
||||
(com.google.protobuf.ByteString) ref;
|
||||
java.lang.String s = bs.toStringUtf8();
|
||||
pageToken_ = s;
|
||||
@@ -2195,7 +2195,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
getPageTokenBytes() {
|
||||
java.lang.Object ref = pageToken_;
|
||||
if (ref instanceof java.lang.String) {
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString.copyFromUtf8(
|
||||
(java.lang.String) ref);
|
||||
pageToken_ = b;
|
||||
@@ -2246,7 +2246,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
if (ref instanceof java.lang.String) {
|
||||
return (java.lang.String) ref;
|
||||
} else {
|
||||
com.google.protobuf.ByteString bs =
|
||||
com.google.protobuf.ByteString bs =
|
||||
(com.google.protobuf.ByteString) ref;
|
||||
java.lang.String s = bs.toStringUtf8();
|
||||
if (rootCase_ == 4) {
|
||||
@@ -2266,7 +2266,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
ref = root_;
|
||||
}
|
||||
if (ref instanceof java.lang.String) {
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString.copyFromUtf8(
|
||||
(java.lang.String) ref);
|
||||
if (rootCase_ == 4) {
|
||||
@@ -2298,7 +2298,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
if (ref instanceof java.lang.String) {
|
||||
return (java.lang.String) ref;
|
||||
} else {
|
||||
com.google.protobuf.ByteString bs =
|
||||
com.google.protobuf.ByteString bs =
|
||||
(com.google.protobuf.ByteString) ref;
|
||||
java.lang.String s = bs.toStringUtf8();
|
||||
if (rootCase_ == 5) {
|
||||
@@ -2318,7 +2318,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
ref = root_;
|
||||
}
|
||||
if (ref instanceof java.lang.String) {
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString.copyFromUtf8(
|
||||
(java.lang.String) ref);
|
||||
if (rootCase_ == 5) {
|
||||
@@ -2483,7 +2483,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
if (ref instanceof java.lang.String) {
|
||||
return (java.lang.String) ref;
|
||||
} else {
|
||||
com.google.protobuf.ByteString bs =
|
||||
com.google.protobuf.ByteString bs =
|
||||
(com.google.protobuf.ByteString) ref;
|
||||
java.lang.String s = bs.toStringUtf8();
|
||||
tagNameGlob_ = s;
|
||||
@@ -2503,7 +2503,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
getTagNameGlobBytes() {
|
||||
java.lang.Object ref = tagNameGlob_;
|
||||
if (ref instanceof java.lang.String) {
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString.copyFromUtf8(
|
||||
(java.lang.String) ref);
|
||||
tagNameGlob_ = b;
|
||||
@@ -3328,7 +3328,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
getPageTokenBytes() {
|
||||
java.lang.Object ref = pageToken_;
|
||||
if (ref instanceof String) {
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString.copyFromUtf8(
|
||||
(java.lang.String) ref);
|
||||
pageToken_ = b;
|
||||
@@ -3471,7 +3471,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
ref = root_;
|
||||
}
|
||||
if (ref instanceof String) {
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString.copyFromUtf8(
|
||||
(java.lang.String) ref);
|
||||
if (rootCase_ == 4) {
|
||||
@@ -3564,7 +3564,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
ref = root_;
|
||||
}
|
||||
if (ref instanceof String) {
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString.copyFromUtf8(
|
||||
(java.lang.String) ref);
|
||||
if (rootCase_ == 5) {
|
||||
@@ -3768,7 +3768,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
* <code>.google.protobuf.Int32Value max_depth = 6;</code>
|
||||
*/
|
||||
private com.google.protobuf.SingleFieldBuilder<
|
||||
com.google.protobuf.Int32Value, com.google.protobuf.Int32Value.Builder, com.google.protobuf.Int32ValueOrBuilder>
|
||||
com.google.protobuf.Int32Value, com.google.protobuf.Int32Value.Builder, com.google.protobuf.Int32ValueOrBuilder>
|
||||
internalGetMaxDepthFieldBuilder() {
|
||||
if (maxDepthBuilder_ == null) {
|
||||
maxDepthBuilder_ = new com.google.protobuf.SingleFieldBuilder<
|
||||
@@ -4073,7 +4073,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
getTagNameGlobBytes() {
|
||||
java.lang.Object ref = tagNameGlob_;
|
||||
if (ref instanceof String) {
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString.copyFromUtf8(
|
||||
(java.lang.String) ref);
|
||||
tagNameGlob_ = b;
|
||||
@@ -4334,7 +4334,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
/**
|
||||
* <code>repeated .galaxy_repository.v1.GalaxyObject objects = 1;</code>
|
||||
*/
|
||||
java.util.List<galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject>
|
||||
java.util.List<galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject>
|
||||
getObjectsList();
|
||||
/**
|
||||
* <code>repeated .galaxy_repository.v1.GalaxyObject objects = 1;</code>
|
||||
@@ -4347,7 +4347,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
/**
|
||||
* <code>repeated .galaxy_repository.v1.GalaxyObject objects = 1;</code>
|
||||
*/
|
||||
java.util.List<? extends galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObjectOrBuilder>
|
||||
java.util.List<? extends galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObjectOrBuilder>
|
||||
getObjectsOrBuilderList();
|
||||
/**
|
||||
* <code>repeated .galaxy_repository.v1.GalaxyObject objects = 1;</code>
|
||||
@@ -4438,7 +4438,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
* <code>repeated .galaxy_repository.v1.GalaxyObject objects = 1;</code>
|
||||
*/
|
||||
@java.lang.Override
|
||||
public java.util.List<? extends galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObjectOrBuilder>
|
||||
public java.util.List<? extends galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObjectOrBuilder>
|
||||
getObjectsOrBuilderList() {
|
||||
return objects_;
|
||||
}
|
||||
@@ -4482,7 +4482,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
if (ref instanceof java.lang.String) {
|
||||
return (java.lang.String) ref;
|
||||
} else {
|
||||
com.google.protobuf.ByteString bs =
|
||||
com.google.protobuf.ByteString bs =
|
||||
(com.google.protobuf.ByteString) ref;
|
||||
java.lang.String s = bs.toStringUtf8();
|
||||
nextPageToken_ = s;
|
||||
@@ -4502,7 +4502,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
getNextPageTokenBytes() {
|
||||
java.lang.Object ref = nextPageToken_;
|
||||
if (ref instanceof java.lang.String) {
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString.copyFromUtf8(
|
||||
(java.lang.String) ref);
|
||||
nextPageToken_ = b;
|
||||
@@ -4834,7 +4834,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
objectsBuilder_ = null;
|
||||
objects_ = other.objects_;
|
||||
bitField0_ = (bitField0_ & ~0x00000001);
|
||||
objectsBuilder_ =
|
||||
objectsBuilder_ =
|
||||
com.google.protobuf.GeneratedMessage.alwaysUseFieldBuilders ?
|
||||
internalGetObjectsFieldBuilder() : null;
|
||||
} else {
|
||||
@@ -5111,7 +5111,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
/**
|
||||
* <code>repeated .galaxy_repository.v1.GalaxyObject objects = 1;</code>
|
||||
*/
|
||||
public java.util.List<? extends galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObjectOrBuilder>
|
||||
public java.util.List<? extends galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObjectOrBuilder>
|
||||
getObjectsOrBuilderList() {
|
||||
if (objectsBuilder_ != null) {
|
||||
return objectsBuilder_.getMessageOrBuilderList();
|
||||
@@ -5137,12 +5137,12 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
/**
|
||||
* <code>repeated .galaxy_repository.v1.GalaxyObject objects = 1;</code>
|
||||
*/
|
||||
public java.util.List<galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject.Builder>
|
||||
public java.util.List<galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject.Builder>
|
||||
getObjectsBuilderList() {
|
||||
return internalGetObjectsFieldBuilder().getBuilderList();
|
||||
}
|
||||
private com.google.protobuf.RepeatedFieldBuilder<
|
||||
galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject, galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject.Builder, galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObjectOrBuilder>
|
||||
galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject, galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject.Builder, galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObjectOrBuilder>
|
||||
internalGetObjectsFieldBuilder() {
|
||||
if (objectsBuilder_ == null) {
|
||||
objectsBuilder_ = new com.google.protobuf.RepeatedFieldBuilder<
|
||||
@@ -5189,7 +5189,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
getNextPageTokenBytes() {
|
||||
java.lang.Object ref = nextPageToken_;
|
||||
if (ref instanceof String) {
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString.copyFromUtf8(
|
||||
(java.lang.String) ref);
|
||||
nextPageToken_ = b;
|
||||
@@ -5924,7 +5924,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
* <code>.google.protobuf.Timestamp last_seen_deploy_time = 1;</code>
|
||||
*/
|
||||
private com.google.protobuf.SingleFieldBuilder<
|
||||
com.google.protobuf.Timestamp, com.google.protobuf.Timestamp.Builder, com.google.protobuf.TimestampOrBuilder>
|
||||
com.google.protobuf.Timestamp, com.google.protobuf.Timestamp.Builder, com.google.protobuf.TimestampOrBuilder>
|
||||
internalGetLastSeenDeployTimeFieldBuilder() {
|
||||
if (lastSeenDeployTimeBuilder_ == null) {
|
||||
lastSeenDeployTimeBuilder_ = new com.google.protobuf.SingleFieldBuilder<
|
||||
@@ -6871,7 +6871,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
* <code>.google.protobuf.Timestamp observed_at = 2;</code>
|
||||
*/
|
||||
private com.google.protobuf.SingleFieldBuilder<
|
||||
com.google.protobuf.Timestamp, com.google.protobuf.Timestamp.Builder, com.google.protobuf.TimestampOrBuilder>
|
||||
com.google.protobuf.Timestamp, com.google.protobuf.Timestamp.Builder, com.google.protobuf.TimestampOrBuilder>
|
||||
internalGetObservedAtFieldBuilder() {
|
||||
if (observedAtBuilder_ == null) {
|
||||
observedAtBuilder_ = new com.google.protobuf.SingleFieldBuilder<
|
||||
@@ -7028,7 +7028,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
* <code>.google.protobuf.Timestamp time_of_last_deploy = 3;</code>
|
||||
*/
|
||||
private com.google.protobuf.SingleFieldBuilder<
|
||||
com.google.protobuf.Timestamp, com.google.protobuf.Timestamp.Builder, com.google.protobuf.TimestampOrBuilder>
|
||||
com.google.protobuf.Timestamp, com.google.protobuf.Timestamp.Builder, com.google.protobuf.TimestampOrBuilder>
|
||||
internalGetTimeOfLastDeployFieldBuilder() {
|
||||
if (timeOfLastDeployBuilder_ == null) {
|
||||
timeOfLastDeployBuilder_ = new com.google.protobuf.SingleFieldBuilder<
|
||||
@@ -7286,7 +7286,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
/**
|
||||
* <code>repeated .galaxy_repository.v1.GalaxyAttribute attributes = 10;</code>
|
||||
*/
|
||||
java.util.List<galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttribute>
|
||||
java.util.List<galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttribute>
|
||||
getAttributesList();
|
||||
/**
|
||||
* <code>repeated .galaxy_repository.v1.GalaxyAttribute attributes = 10;</code>
|
||||
@@ -7299,7 +7299,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
/**
|
||||
* <code>repeated .galaxy_repository.v1.GalaxyAttribute attributes = 10;</code>
|
||||
*/
|
||||
java.util.List<? extends galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttributeOrBuilder>
|
||||
java.util.List<? extends galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttributeOrBuilder>
|
||||
getAttributesOrBuilderList();
|
||||
/**
|
||||
* <code>repeated .galaxy_repository.v1.GalaxyAttribute attributes = 10;</code>
|
||||
@@ -7374,7 +7374,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
if (ref instanceof java.lang.String) {
|
||||
return (java.lang.String) ref;
|
||||
} else {
|
||||
com.google.protobuf.ByteString bs =
|
||||
com.google.protobuf.ByteString bs =
|
||||
(com.google.protobuf.ByteString) ref;
|
||||
java.lang.String s = bs.toStringUtf8();
|
||||
tagName_ = s;
|
||||
@@ -7390,7 +7390,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
getTagNameBytes() {
|
||||
java.lang.Object ref = tagName_;
|
||||
if (ref instanceof java.lang.String) {
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString.copyFromUtf8(
|
||||
(java.lang.String) ref);
|
||||
tagName_ = b;
|
||||
@@ -7413,7 +7413,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
if (ref instanceof java.lang.String) {
|
||||
return (java.lang.String) ref;
|
||||
} else {
|
||||
com.google.protobuf.ByteString bs =
|
||||
com.google.protobuf.ByteString bs =
|
||||
(com.google.protobuf.ByteString) ref;
|
||||
java.lang.String s = bs.toStringUtf8();
|
||||
containedName_ = s;
|
||||
@@ -7429,7 +7429,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
getContainedNameBytes() {
|
||||
java.lang.Object ref = containedName_;
|
||||
if (ref instanceof java.lang.String) {
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString.copyFromUtf8(
|
||||
(java.lang.String) ref);
|
||||
containedName_ = b;
|
||||
@@ -7452,7 +7452,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
if (ref instanceof java.lang.String) {
|
||||
return (java.lang.String) ref;
|
||||
} else {
|
||||
com.google.protobuf.ByteString bs =
|
||||
com.google.protobuf.ByteString bs =
|
||||
(com.google.protobuf.ByteString) ref;
|
||||
java.lang.String s = bs.toStringUtf8();
|
||||
browseName_ = s;
|
||||
@@ -7468,7 +7468,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
getBrowseNameBytes() {
|
||||
java.lang.Object ref = browseName_;
|
||||
if (ref instanceof java.lang.String) {
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString.copyFromUtf8(
|
||||
(java.lang.String) ref);
|
||||
browseName_ = b;
|
||||
@@ -7573,7 +7573,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
* <code>repeated .galaxy_repository.v1.GalaxyAttribute attributes = 10;</code>
|
||||
*/
|
||||
@java.lang.Override
|
||||
public java.util.List<? extends galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttributeOrBuilder>
|
||||
public java.util.List<? extends galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttributeOrBuilder>
|
||||
getAttributesOrBuilderList() {
|
||||
return attributes_;
|
||||
}
|
||||
@@ -8059,7 +8059,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
attributesBuilder_ = null;
|
||||
attributes_ = other.attributes_;
|
||||
bitField0_ = (bitField0_ & ~0x00000200);
|
||||
attributesBuilder_ =
|
||||
attributesBuilder_ =
|
||||
com.google.protobuf.GeneratedMessage.alwaysUseFieldBuilders ?
|
||||
internalGetAttributesFieldBuilder() : null;
|
||||
} else {
|
||||
@@ -8226,7 +8226,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
getTagNameBytes() {
|
||||
java.lang.Object ref = tagName_;
|
||||
if (ref instanceof String) {
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString.copyFromUtf8(
|
||||
(java.lang.String) ref);
|
||||
tagName_ = b;
|
||||
@@ -8298,7 +8298,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
getContainedNameBytes() {
|
||||
java.lang.Object ref = containedName_;
|
||||
if (ref instanceof String) {
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString.copyFromUtf8(
|
||||
(java.lang.String) ref);
|
||||
containedName_ = b;
|
||||
@@ -8370,7 +8370,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
getBrowseNameBytes() {
|
||||
java.lang.Object ref = browseName_;
|
||||
if (ref instanceof String) {
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString.copyFromUtf8(
|
||||
(java.lang.String) ref);
|
||||
browseName_ = b;
|
||||
@@ -8851,7 +8851,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
/**
|
||||
* <code>repeated .galaxy_repository.v1.GalaxyAttribute attributes = 10;</code>
|
||||
*/
|
||||
public java.util.List<? extends galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttributeOrBuilder>
|
||||
public java.util.List<? extends galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttributeOrBuilder>
|
||||
getAttributesOrBuilderList() {
|
||||
if (attributesBuilder_ != null) {
|
||||
return attributesBuilder_.getMessageOrBuilderList();
|
||||
@@ -8877,12 +8877,12 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
/**
|
||||
* <code>repeated .galaxy_repository.v1.GalaxyAttribute attributes = 10;</code>
|
||||
*/
|
||||
public java.util.List<galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttribute.Builder>
|
||||
public java.util.List<galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttribute.Builder>
|
||||
getAttributesBuilderList() {
|
||||
return internalGetAttributesFieldBuilder().getBuilderList();
|
||||
}
|
||||
private com.google.protobuf.RepeatedFieldBuilder<
|
||||
galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttribute, galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttribute.Builder, galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttributeOrBuilder>
|
||||
galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttribute, galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttribute.Builder, galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttributeOrBuilder>
|
||||
internalGetAttributesFieldBuilder() {
|
||||
if (attributesBuilder_ == null) {
|
||||
attributesBuilder_ = new com.google.protobuf.RepeatedFieldBuilder<
|
||||
@@ -8976,17 +8976,36 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
getFullTagReferenceBytes();
|
||||
|
||||
/**
|
||||
* <pre>
|
||||
* Raw Galaxy SQL `dbo.data_type` identifier, passed through unchanged.
|
||||
* This is NOT a member of `mxaccess_gateway.v1.MxDataType` — Galaxy's
|
||||
* type enumeration is distinct from MXAccess's wire data-type enum and
|
||||
* the two must not be cast or compared. The GalaxyRepository service is
|
||||
* metadata-only and deliberately does not share types with
|
||||
* mxaccess_gateway.proto. See docs/GalaxyRepository.md.
|
||||
* </pre>
|
||||
*
|
||||
* <code>int32 mx_data_type = 3;</code>
|
||||
* @return The mxDataType.
|
||||
*/
|
||||
int getMxDataType();
|
||||
|
||||
/**
|
||||
* <pre>
|
||||
* Human-readable name from Galaxy's `dbo.data_type` table (e.g. "Float",
|
||||
* "Integer", "Boolean"). Free-form Galaxy text; not a stable enum.
|
||||
* </pre>
|
||||
*
|
||||
* <code>string data_type_name = 4;</code>
|
||||
* @return The dataTypeName.
|
||||
*/
|
||||
java.lang.String getDataTypeName();
|
||||
/**
|
||||
* <pre>
|
||||
* Human-readable name from Galaxy's `dbo.data_type` table (e.g. "Float",
|
||||
* "Integer", "Boolean"). Free-form Galaxy text; not a stable enum.
|
||||
* </pre>
|
||||
*
|
||||
* <code>string data_type_name = 4;</code>
|
||||
* @return The bytes for dataTypeName.
|
||||
*/
|
||||
@@ -9012,12 +9031,24 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
boolean getArrayDimensionPresent();
|
||||
|
||||
/**
|
||||
* <pre>
|
||||
* Raw Galaxy SQL attribute-category identifier, passed through unchanged.
|
||||
* Galaxy-specific; not mapped to any gateway enum. See
|
||||
* docs/GalaxyRepository.md.
|
||||
* </pre>
|
||||
*
|
||||
* <code>int32 mx_attribute_category = 8;</code>
|
||||
* @return The mxAttributeCategory.
|
||||
*/
|
||||
int getMxAttributeCategory();
|
||||
|
||||
/**
|
||||
* <pre>
|
||||
* Raw Galaxy SQL security-classification identifier, passed through
|
||||
* unchanged. Galaxy-specific; not mapped to any gateway enum. See
|
||||
* docs/GalaxyRepository.md.
|
||||
* </pre>
|
||||
*
|
||||
* <code>int32 security_classification = 9;</code>
|
||||
* @return The securityClassification.
|
||||
*/
|
||||
@@ -9088,7 +9119,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
if (ref instanceof java.lang.String) {
|
||||
return (java.lang.String) ref;
|
||||
} else {
|
||||
com.google.protobuf.ByteString bs =
|
||||
com.google.protobuf.ByteString bs =
|
||||
(com.google.protobuf.ByteString) ref;
|
||||
java.lang.String s = bs.toStringUtf8();
|
||||
attributeName_ = s;
|
||||
@@ -9104,7 +9135,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
getAttributeNameBytes() {
|
||||
java.lang.Object ref = attributeName_;
|
||||
if (ref instanceof java.lang.String) {
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString.copyFromUtf8(
|
||||
(java.lang.String) ref);
|
||||
attributeName_ = b;
|
||||
@@ -9127,7 +9158,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
if (ref instanceof java.lang.String) {
|
||||
return (java.lang.String) ref;
|
||||
} else {
|
||||
com.google.protobuf.ByteString bs =
|
||||
com.google.protobuf.ByteString bs =
|
||||
(com.google.protobuf.ByteString) ref;
|
||||
java.lang.String s = bs.toStringUtf8();
|
||||
fullTagReference_ = s;
|
||||
@@ -9143,7 +9174,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
getFullTagReferenceBytes() {
|
||||
java.lang.Object ref = fullTagReference_;
|
||||
if (ref instanceof java.lang.String) {
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString.copyFromUtf8(
|
||||
(java.lang.String) ref);
|
||||
fullTagReference_ = b;
|
||||
@@ -9156,6 +9187,15 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
public static final int MX_DATA_TYPE_FIELD_NUMBER = 3;
|
||||
private int mxDataType_ = 0;
|
||||
/**
|
||||
* <pre>
|
||||
* Raw Galaxy SQL `dbo.data_type` identifier, passed through unchanged.
|
||||
* This is NOT a member of `mxaccess_gateway.v1.MxDataType` — Galaxy's
|
||||
* type enumeration is distinct from MXAccess's wire data-type enum and
|
||||
* the two must not be cast or compared. The GalaxyRepository service is
|
||||
* metadata-only and deliberately does not share types with
|
||||
* mxaccess_gateway.proto. See docs/GalaxyRepository.md.
|
||||
* </pre>
|
||||
*
|
||||
* <code>int32 mx_data_type = 3;</code>
|
||||
* @return The mxDataType.
|
||||
*/
|
||||
@@ -9168,6 +9208,11 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
@SuppressWarnings("serial")
|
||||
private volatile java.lang.Object dataTypeName_ = "";
|
||||
/**
|
||||
* <pre>
|
||||
* Human-readable name from Galaxy's `dbo.data_type` table (e.g. "Float",
|
||||
* "Integer", "Boolean"). Free-form Galaxy text; not a stable enum.
|
||||
* </pre>
|
||||
*
|
||||
* <code>string data_type_name = 4;</code>
|
||||
* @return The dataTypeName.
|
||||
*/
|
||||
@@ -9177,7 +9222,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
if (ref instanceof java.lang.String) {
|
||||
return (java.lang.String) ref;
|
||||
} else {
|
||||
com.google.protobuf.ByteString bs =
|
||||
com.google.protobuf.ByteString bs =
|
||||
(com.google.protobuf.ByteString) ref;
|
||||
java.lang.String s = bs.toStringUtf8();
|
||||
dataTypeName_ = s;
|
||||
@@ -9185,6 +9230,11 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
}
|
||||
}
|
||||
/**
|
||||
* <pre>
|
||||
* Human-readable name from Galaxy's `dbo.data_type` table (e.g. "Float",
|
||||
* "Integer", "Boolean"). Free-form Galaxy text; not a stable enum.
|
||||
* </pre>
|
||||
*
|
||||
* <code>string data_type_name = 4;</code>
|
||||
* @return The bytes for dataTypeName.
|
||||
*/
|
||||
@@ -9193,7 +9243,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
getDataTypeNameBytes() {
|
||||
java.lang.Object ref = dataTypeName_;
|
||||
if (ref instanceof java.lang.String) {
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString.copyFromUtf8(
|
||||
(java.lang.String) ref);
|
||||
dataTypeName_ = b;
|
||||
@@ -9239,6 +9289,12 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
public static final int MX_ATTRIBUTE_CATEGORY_FIELD_NUMBER = 8;
|
||||
private int mxAttributeCategory_ = 0;
|
||||
/**
|
||||
* <pre>
|
||||
* Raw Galaxy SQL attribute-category identifier, passed through unchanged.
|
||||
* Galaxy-specific; not mapped to any gateway enum. See
|
||||
* docs/GalaxyRepository.md.
|
||||
* </pre>
|
||||
*
|
||||
* <code>int32 mx_attribute_category = 8;</code>
|
||||
* @return The mxAttributeCategory.
|
||||
*/
|
||||
@@ -9250,6 +9306,12 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
public static final int SECURITY_CLASSIFICATION_FIELD_NUMBER = 9;
|
||||
private int securityClassification_ = 0;
|
||||
/**
|
||||
* <pre>
|
||||
* Raw Galaxy SQL security-classification identifier, passed through
|
||||
* unchanged. Galaxy-specific; not mapped to any gateway enum. See
|
||||
* docs/GalaxyRepository.md.
|
||||
* </pre>
|
||||
*
|
||||
* <code>int32 security_classification = 9;</code>
|
||||
* @return The securityClassification.
|
||||
*/
|
||||
@@ -9835,7 +9897,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
getAttributeNameBytes() {
|
||||
java.lang.Object ref = attributeName_;
|
||||
if (ref instanceof String) {
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString.copyFromUtf8(
|
||||
(java.lang.String) ref);
|
||||
attributeName_ = b;
|
||||
@@ -9907,7 +9969,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
getFullTagReferenceBytes() {
|
||||
java.lang.Object ref = fullTagReference_;
|
||||
if (ref instanceof String) {
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString.copyFromUtf8(
|
||||
(java.lang.String) ref);
|
||||
fullTagReference_ = b;
|
||||
@@ -9956,6 +10018,15 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
|
||||
private int mxDataType_ ;
|
||||
/**
|
||||
* <pre>
|
||||
* Raw Galaxy SQL `dbo.data_type` identifier, passed through unchanged.
|
||||
* This is NOT a member of `mxaccess_gateway.v1.MxDataType` — Galaxy's
|
||||
* type enumeration is distinct from MXAccess's wire data-type enum and
|
||||
* the two must not be cast or compared. The GalaxyRepository service is
|
||||
* metadata-only and deliberately does not share types with
|
||||
* mxaccess_gateway.proto. See docs/GalaxyRepository.md.
|
||||
* </pre>
|
||||
*
|
||||
* <code>int32 mx_data_type = 3;</code>
|
||||
* @return The mxDataType.
|
||||
*/
|
||||
@@ -9964,6 +10035,15 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
return mxDataType_;
|
||||
}
|
||||
/**
|
||||
* <pre>
|
||||
* Raw Galaxy SQL `dbo.data_type` identifier, passed through unchanged.
|
||||
* This is NOT a member of `mxaccess_gateway.v1.MxDataType` — Galaxy's
|
||||
* type enumeration is distinct from MXAccess's wire data-type enum and
|
||||
* the two must not be cast or compared. The GalaxyRepository service is
|
||||
* metadata-only and deliberately does not share types with
|
||||
* mxaccess_gateway.proto. See docs/GalaxyRepository.md.
|
||||
* </pre>
|
||||
*
|
||||
* <code>int32 mx_data_type = 3;</code>
|
||||
* @param value The mxDataType to set.
|
||||
* @return This builder for chaining.
|
||||
@@ -9976,6 +10056,15 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* <pre>
|
||||
* Raw Galaxy SQL `dbo.data_type` identifier, passed through unchanged.
|
||||
* This is NOT a member of `mxaccess_gateway.v1.MxDataType` — Galaxy's
|
||||
* type enumeration is distinct from MXAccess's wire data-type enum and
|
||||
* the two must not be cast or compared. The GalaxyRepository service is
|
||||
* metadata-only and deliberately does not share types with
|
||||
* mxaccess_gateway.proto. See docs/GalaxyRepository.md.
|
||||
* </pre>
|
||||
*
|
||||
* <code>int32 mx_data_type = 3;</code>
|
||||
* @return This builder for chaining.
|
||||
*/
|
||||
@@ -9988,6 +10077,11 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
|
||||
private java.lang.Object dataTypeName_ = "";
|
||||
/**
|
||||
* <pre>
|
||||
* Human-readable name from Galaxy's `dbo.data_type` table (e.g. "Float",
|
||||
* "Integer", "Boolean"). Free-form Galaxy text; not a stable enum.
|
||||
* </pre>
|
||||
*
|
||||
* <code>string data_type_name = 4;</code>
|
||||
* @return The dataTypeName.
|
||||
*/
|
||||
@@ -10004,6 +10098,11 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
}
|
||||
}
|
||||
/**
|
||||
* <pre>
|
||||
* Human-readable name from Galaxy's `dbo.data_type` table (e.g. "Float",
|
||||
* "Integer", "Boolean"). Free-form Galaxy text; not a stable enum.
|
||||
* </pre>
|
||||
*
|
||||
* <code>string data_type_name = 4;</code>
|
||||
* @return The bytes for dataTypeName.
|
||||
*/
|
||||
@@ -10011,7 +10110,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
getDataTypeNameBytes() {
|
||||
java.lang.Object ref = dataTypeName_;
|
||||
if (ref instanceof String) {
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString.copyFromUtf8(
|
||||
(java.lang.String) ref);
|
||||
dataTypeName_ = b;
|
||||
@@ -10021,6 +10120,11 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
}
|
||||
}
|
||||
/**
|
||||
* <pre>
|
||||
* Human-readable name from Galaxy's `dbo.data_type` table (e.g. "Float",
|
||||
* "Integer", "Boolean"). Free-form Galaxy text; not a stable enum.
|
||||
* </pre>
|
||||
*
|
||||
* <code>string data_type_name = 4;</code>
|
||||
* @param value The dataTypeName to set.
|
||||
* @return This builder for chaining.
|
||||
@@ -10034,6 +10138,11 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* <pre>
|
||||
* Human-readable name from Galaxy's `dbo.data_type` table (e.g. "Float",
|
||||
* "Integer", "Boolean"). Free-form Galaxy text; not a stable enum.
|
||||
* </pre>
|
||||
*
|
||||
* <code>string data_type_name = 4;</code>
|
||||
* @return This builder for chaining.
|
||||
*/
|
||||
@@ -10044,6 +10153,11 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* <pre>
|
||||
* Human-readable name from Galaxy's `dbo.data_type` table (e.g. "Float",
|
||||
* "Integer", "Boolean"). Free-form Galaxy text; not a stable enum.
|
||||
* </pre>
|
||||
*
|
||||
* <code>string data_type_name = 4;</code>
|
||||
* @param value The bytes for dataTypeName to set.
|
||||
* @return This builder for chaining.
|
||||
@@ -10156,6 +10270,12 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
|
||||
private int mxAttributeCategory_ ;
|
||||
/**
|
||||
* <pre>
|
||||
* Raw Galaxy SQL attribute-category identifier, passed through unchanged.
|
||||
* Galaxy-specific; not mapped to any gateway enum. See
|
||||
* docs/GalaxyRepository.md.
|
||||
* </pre>
|
||||
*
|
||||
* <code>int32 mx_attribute_category = 8;</code>
|
||||
* @return The mxAttributeCategory.
|
||||
*/
|
||||
@@ -10164,6 +10284,12 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
return mxAttributeCategory_;
|
||||
}
|
||||
/**
|
||||
* <pre>
|
||||
* Raw Galaxy SQL attribute-category identifier, passed through unchanged.
|
||||
* Galaxy-specific; not mapped to any gateway enum. See
|
||||
* docs/GalaxyRepository.md.
|
||||
* </pre>
|
||||
*
|
||||
* <code>int32 mx_attribute_category = 8;</code>
|
||||
* @param value The mxAttributeCategory to set.
|
||||
* @return This builder for chaining.
|
||||
@@ -10176,6 +10302,12 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* <pre>
|
||||
* Raw Galaxy SQL attribute-category identifier, passed through unchanged.
|
||||
* Galaxy-specific; not mapped to any gateway enum. See
|
||||
* docs/GalaxyRepository.md.
|
||||
* </pre>
|
||||
*
|
||||
* <code>int32 mx_attribute_category = 8;</code>
|
||||
* @return This builder for chaining.
|
||||
*/
|
||||
@@ -10188,6 +10320,12 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
|
||||
private int securityClassification_ ;
|
||||
/**
|
||||
* <pre>
|
||||
* Raw Galaxy SQL security-classification identifier, passed through
|
||||
* unchanged. Galaxy-specific; not mapped to any gateway enum. See
|
||||
* docs/GalaxyRepository.md.
|
||||
* </pre>
|
||||
*
|
||||
* <code>int32 security_classification = 9;</code>
|
||||
* @return The securityClassification.
|
||||
*/
|
||||
@@ -10196,6 +10334,12 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
return securityClassification_;
|
||||
}
|
||||
/**
|
||||
* <pre>
|
||||
* Raw Galaxy SQL security-classification identifier, passed through
|
||||
* unchanged. Galaxy-specific; not mapped to any gateway enum. See
|
||||
* docs/GalaxyRepository.md.
|
||||
* </pre>
|
||||
*
|
||||
* <code>int32 security_classification = 9;</code>
|
||||
* @param value The securityClassification to set.
|
||||
* @return This builder for chaining.
|
||||
@@ -10208,6 +10352,12 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* <pre>
|
||||
* Raw Galaxy SQL security-classification identifier, passed through
|
||||
* unchanged. Galaxy-specific; not mapped to any gateway enum. See
|
||||
* docs/GalaxyRepository.md.
|
||||
* </pre>
|
||||
*
|
||||
* <code>int32 security_classification = 9;</code>
|
||||
* @return This builder for chaining.
|
||||
*/
|
||||
@@ -10335,52 +10485,52 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
|
||||
private static final com.google.protobuf.Descriptors.Descriptor
|
||||
internal_static_galaxy_repository_v1_TestConnectionRequest_descriptor;
|
||||
private static final
|
||||
private static final
|
||||
com.google.protobuf.GeneratedMessage.FieldAccessorTable
|
||||
internal_static_galaxy_repository_v1_TestConnectionRequest_fieldAccessorTable;
|
||||
private static final com.google.protobuf.Descriptors.Descriptor
|
||||
internal_static_galaxy_repository_v1_TestConnectionReply_descriptor;
|
||||
private static final
|
||||
private static final
|
||||
com.google.protobuf.GeneratedMessage.FieldAccessorTable
|
||||
internal_static_galaxy_repository_v1_TestConnectionReply_fieldAccessorTable;
|
||||
private static final com.google.protobuf.Descriptors.Descriptor
|
||||
internal_static_galaxy_repository_v1_GetLastDeployTimeRequest_descriptor;
|
||||
private static final
|
||||
private static final
|
||||
com.google.protobuf.GeneratedMessage.FieldAccessorTable
|
||||
internal_static_galaxy_repository_v1_GetLastDeployTimeRequest_fieldAccessorTable;
|
||||
private static final com.google.protobuf.Descriptors.Descriptor
|
||||
internal_static_galaxy_repository_v1_GetLastDeployTimeReply_descriptor;
|
||||
private static final
|
||||
private static final
|
||||
com.google.protobuf.GeneratedMessage.FieldAccessorTable
|
||||
internal_static_galaxy_repository_v1_GetLastDeployTimeReply_fieldAccessorTable;
|
||||
private static final com.google.protobuf.Descriptors.Descriptor
|
||||
internal_static_galaxy_repository_v1_DiscoverHierarchyRequest_descriptor;
|
||||
private static final
|
||||
private static final
|
||||
com.google.protobuf.GeneratedMessage.FieldAccessorTable
|
||||
internal_static_galaxy_repository_v1_DiscoverHierarchyRequest_fieldAccessorTable;
|
||||
private static final com.google.protobuf.Descriptors.Descriptor
|
||||
internal_static_galaxy_repository_v1_DiscoverHierarchyReply_descriptor;
|
||||
private static final
|
||||
private static final
|
||||
com.google.protobuf.GeneratedMessage.FieldAccessorTable
|
||||
internal_static_galaxy_repository_v1_DiscoverHierarchyReply_fieldAccessorTable;
|
||||
private static final com.google.protobuf.Descriptors.Descriptor
|
||||
internal_static_galaxy_repository_v1_WatchDeployEventsRequest_descriptor;
|
||||
private static final
|
||||
private static final
|
||||
com.google.protobuf.GeneratedMessage.FieldAccessorTable
|
||||
internal_static_galaxy_repository_v1_WatchDeployEventsRequest_fieldAccessorTable;
|
||||
private static final com.google.protobuf.Descriptors.Descriptor
|
||||
internal_static_galaxy_repository_v1_DeployEvent_descriptor;
|
||||
private static final
|
||||
private static final
|
||||
com.google.protobuf.GeneratedMessage.FieldAccessorTable
|
||||
internal_static_galaxy_repository_v1_DeployEvent_fieldAccessorTable;
|
||||
private static final com.google.protobuf.Descriptors.Descriptor
|
||||
internal_static_galaxy_repository_v1_GalaxyObject_descriptor;
|
||||
private static final
|
||||
private static final
|
||||
com.google.protobuf.GeneratedMessage.FieldAccessorTable
|
||||
internal_static_galaxy_repository_v1_GalaxyObject_fieldAccessorTable;
|
||||
private static final com.google.protobuf.Descriptors.Descriptor
|
||||
internal_static_galaxy_repository_v1_GalaxyAttribute_descriptor;
|
||||
private static final
|
||||
private static final
|
||||
com.google.protobuf.GeneratedMessage.FieldAccessorTable
|
||||
internal_static_galaxy_repository_v1_GalaxyAttribute_fieldAccessorTable;
|
||||
|
||||
|
||||
+33126
-333
File diff suppressed because it is too large
Load Diff
@@ -95,6 +95,33 @@ async with await GatewayClient.connect(
|
||||
events available for parity tests. `Session` helpers call the method-specific
|
||||
MXAccess commands and preserve raw replies on typed command exceptions.
|
||||
|
||||
The full bulk family is available — `add_item_bulk`, `advise_item_bulk`,
|
||||
`remove_item_bulk`, `unadvise_item_bulk`, `subscribe_bulk`, `unsubscribe_bulk`,
|
||||
`write_bulk`, `write2_bulk`, `write_secured_bulk`, `write_secured2_bulk`, and
|
||||
`read_bulk`. Bulk methods carry a list of entries in one round-trip and
|
||||
return a `list[pb.SubscribeResult]` / `list[pb.BulkWriteResult]` /
|
||||
`list[pb.BulkReadResult]`; per-entry MXAccess failures appear as result
|
||||
entries with `was_successful = False` and never raise. `read_bulk` accepts
|
||||
a per-tag `timeout_ms` (`0` = worker default) and returns cached
|
||||
`OnDataChange` values when the tag is already advised
|
||||
(`was_cached = True`) without touching the existing subscription.
|
||||
|
||||
`*_raw` methods (`GatewayClient.invoke_raw`, `Session.invoke_raw`) surface
|
||||
gateway protocol failures by raising the typed `MxGateway*` exceptions, but
|
||||
they deliberately do **not** run MXAccess-failure detection: an MXAccess
|
||||
HRESULT or `MxStatusProxy` status failure is left embedded in the returned
|
||||
reply and no `MxAccessError` is raised. `Session.invoke` adds that check on
|
||||
top. Parity-test callers using `invoke_raw` must inspect the reply's
|
||||
`protocol_status`, `hresult`, and `statuses` themselves. The non-raw `Session`
|
||||
helpers (`register`, `add_item`, `write`, the bulk methods, etc.) run the
|
||||
check and raise `MxAccessError`.
|
||||
|
||||
Value conversion (`to_mx_value`, used by `Session.write`/`write2` and the
|
||||
bulk helpers) rejects non-finite floats — `nan`, `inf`, and `-inf` raise
|
||||
`ValueError` rather than being forwarded to MXAccess, which has no defined
|
||||
wire representation for them. Python `bytes` values are an opaque
|
||||
`VT_RECORD` pass-through that MXAccess does not interpret.
|
||||
|
||||
Canceling a Python task cancels the client-side gRPC call or stream wait. It
|
||||
does not abort an in-flight MXAccess COM call inside the worker process.
|
||||
|
||||
@@ -131,6 +158,25 @@ The methods return native Python types (`bool`, `datetime | None`, and a
|
||||
into the hierarchy without learning the underlying stub class. The
|
||||
service requires the `metadata:read` scope on the API key.
|
||||
|
||||
`discover_hierarchy` buffers every object (with its full attribute list)
|
||||
into a single in-memory `list`. For a large Galaxy use `iter_hierarchy`
|
||||
instead — it is an async generator that fetches one page at a time and
|
||||
yields objects as they arrive, so peak memory stays bounded by a single
|
||||
page rather than the whole hierarchy:
|
||||
|
||||
```python
|
||||
async with await GalaxyRepositoryClient.connect(
|
||||
endpoint="localhost:5000",
|
||||
api_key="<gateway-api-key>",
|
||||
plaintext=True,
|
||||
) as galaxy:
|
||||
async for obj in galaxy.iter_hierarchy():
|
||||
print(obj.tag_name, obj.contained_name)
|
||||
```
|
||||
|
||||
Pages are fetched lazily: the next page is only requested once the
|
||||
caller has consumed every object from the current page.
|
||||
|
||||
### Watching deploy events
|
||||
|
||||
`GalaxyRepositoryClient.watch_deploy_events` opens a server-streaming
|
||||
@@ -180,6 +226,12 @@ The client supports plaintext channels for local development, TLS with system
|
||||
roots, TLS with a custom `ca_file`, and an optional test server name override.
|
||||
API keys are redacted from option repr output and CLI error output.
|
||||
|
||||
The CLI defaults to TLS. Pass `--plaintext` explicitly to open an unencrypted
|
||||
channel — there is no implicit localhost downgrade. `--tls` is accepted but
|
||||
redundant with the default, and cannot be combined with `--plaintext`. Scripts
|
||||
that previously relied on a `localhost:` / `127.0.0.1:` endpoint silently
|
||||
selecting plaintext must now pass `--plaintext` explicitly.
|
||||
|
||||
## CLI
|
||||
|
||||
The CLI emits deterministic JSON for automation:
|
||||
@@ -204,6 +256,31 @@ Use TLS options for a secured gateway:
|
||||
mxgw-py smoke --endpoint mxgateway.example.local:5001 --tls --ca-file C:\certs\mxgateway-ca.pem --server-name-override mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item Object.Attribute --json
|
||||
```
|
||||
|
||||
### CLI Parity Gaps
|
||||
|
||||
The `mxgw-py` CLI does not currently ship the Galaxy Repository
|
||||
subcommands that the .NET (`mxgw`), Go (`mxgw-go`), Rust (`mxgw`), and
|
||||
Java (`mxgw-java`) CLIs expose:
|
||||
|
||||
- `galaxy-test-connection` — ping the Galaxy Repository SQL DB.
|
||||
- `galaxy-last-deploy` — fetch the last deploy timestamp.
|
||||
- `galaxy-discover` — enumerate the deployed object hierarchy with
|
||||
attributes.
|
||||
- `galaxy-watch` — stream `DeployEvent`s as the Galaxy is re-deployed.
|
||||
|
||||
The Python `GalaxyRepositoryClient` library wrapper is fully
|
||||
implemented and exercised by `tests/test_galaxy.py` and
|
||||
`tests/test_galaxy_iter_hierarchy.py` — use the library API (see
|
||||
[Galaxy Repository Browse](#galaxy-repository-browse) above) when
|
||||
calling these RPCs from Python. The four CLI subcommands above are a
|
||||
forward-looking parity item; see the matching .NET / Go / Rust / Java
|
||||
CLI implementations for the expected JSON shape when they are added.
|
||||
|
||||
The .NET CLI also ships `bench-stream-events`, which is .NET-only today
|
||||
and not yet present in Go / Rust / Java / Python. It will need
|
||||
matching coverage if the cross-language benchmark matrix grows a
|
||||
stream-events driver under `scripts/`.
|
||||
|
||||
## Integration Checks
|
||||
|
||||
Run live checks only when a gateway and MXAccess-backed worker are available:
|
||||
|
||||
@@ -5,9 +5,34 @@ build-backend = "setuptools.build_meta"
|
||||
[project]
|
||||
name = "mxaccess-gateway-client"
|
||||
version = "0.1.0"
|
||||
description = "Async Python client scaffold for MXAccess Gateway."
|
||||
description = "Async Python client for MXAccess Gateway."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
authors = [
|
||||
{ name = "MXAccess Gateway Authors" },
|
||||
]
|
||||
keywords = [
|
||||
"mxaccess",
|
||||
"archestra",
|
||||
"gateway",
|
||||
"grpc",
|
||||
"industrial",
|
||||
"scada",
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"Intended Audience :: Information Technology",
|
||||
"License :: Other/Proprietary License",
|
||||
"Operating System :: Microsoft :: Windows",
|
||||
"Operating System :: POSIX",
|
||||
"Programming Language :: Python",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
"Topic :: System :: Distributed Computing",
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"click>=8.3,<9",
|
||||
"grpcio>=1.80,<2",
|
||||
@@ -21,12 +46,21 @@ dev = [
|
||||
"pytest-asyncio>=1.3,<2",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
|
||||
Source = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
|
||||
Issues = "https://gitea.dohertylan.com/dohertj2/mxaccessgw/issues"
|
||||
|
||||
[project.scripts]
|
||||
mxgw-py = "mxgateway_cli.commands:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
mxgateway = ["py.typed"]
|
||||
mxgateway_cli = ["py.typed"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "-ra"
|
||||
pythonpath = ["src"]
|
||||
|
||||
@@ -72,14 +72,20 @@ class GatewayClient:
|
||||
await self.close()
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the owned gRPC channel."""
|
||||
"""Close the owned gRPC channel.
|
||||
|
||||
Idempotent, including under concurrent calls: ``_closed`` is set
|
||||
before the ``await`` so a second coroutine entering ``close()``
|
||||
while the first is still awaiting the channel close returns
|
||||
immediately instead of issuing a second ``channel.close()``.
|
||||
"""
|
||||
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
|
||||
if self._channel is not None:
|
||||
await self._channel.close()
|
||||
self._closed = True
|
||||
|
||||
async def open_session(
|
||||
self,
|
||||
@@ -117,7 +123,15 @@ class GatewayClient:
|
||||
return reply
|
||||
|
||||
async def invoke_raw(self, request: pb.MxCommandRequest) -> pb.MxCommandReply:
|
||||
"""Send an `Invoke` RPC and return the raw reply."""
|
||||
"""Send an `Invoke` RPC and return the raw reply.
|
||||
|
||||
Enforces gateway protocol success only. MXAccess HRESULT/status
|
||||
failures are left embedded in the reply and do not raise
|
||||
`MxAccessError` — parity-test callers must inspect the reply's
|
||||
`protocol_status`, `hresult`, and `statuses` themselves. Use
|
||||
`Session.invoke` for the variant that also raises on MXAccess
|
||||
failure.
|
||||
"""
|
||||
reply = await self._unary("invoke", self.raw_stub.Invoke, request)
|
||||
ensure_protocol_success("invoke", reply.protocol_status, reply)
|
||||
return reply
|
||||
@@ -133,8 +147,46 @@ class GatewayClient:
|
||||
kwargs: dict[str, Any] = {"metadata": merge_metadata(self.options.api_key, metadata)}
|
||||
if self.options.stream_timeout is not None:
|
||||
kwargs["timeout"] = self.options.stream_timeout
|
||||
call = self.raw_stub.StreamEvents(request, **kwargs)
|
||||
return _canceling_iterator(call)
|
||||
call = _open_stream(self.raw_stub.StreamEvents, request, kwargs)
|
||||
return _canceling_iterator(call, "stream events")
|
||||
|
||||
async def acknowledge_alarm(
|
||||
self,
|
||||
request: pb.AcknowledgeAlarmRequest,
|
||||
) -> pb.AcknowledgeAlarmReply:
|
||||
"""Acknowledge an active MXAccess alarm condition through the gateway.
|
||||
|
||||
The gateway authenticates the request against the API key's
|
||||
``invoke:alarm-ack`` scope and forwards the acknowledge to the worker's
|
||||
MXAccess session; the resulting native ``MxStatus`` is returned in the
|
||||
reply. Acks are idempotent — re-acking an already-acked condition is a
|
||||
no-op at the MxAccess layer.
|
||||
"""
|
||||
reply = await self._unary("acknowledge alarm", self.raw_stub.AcknowledgeAlarm, request)
|
||||
ensure_protocol_success("acknowledge alarm", reply.protocol_status, reply)
|
||||
return reply
|
||||
|
||||
def stream_alarms(
|
||||
self,
|
||||
request: pb.StreamAlarmsRequest,
|
||||
*,
|
||||
metadata: Sequence[tuple[str, str]] | None = None,
|
||||
) -> AsyncIterator[pb.AlarmFeedMessage]:
|
||||
"""Attach to the gateway's central alarm feed.
|
||||
|
||||
The stream opens with one ``AlarmFeedMessage`` per currently-active
|
||||
alarm (the ConditionRefresh snapshot), then a single
|
||||
``snapshot_complete``, then a ``transition`` for every subsequent
|
||||
raise / acknowledge / clear. Served by the gateway's always-on alarm
|
||||
monitor — no worker session is opened — so any number of clients may
|
||||
attach. Optionally scoped by alarm-reference prefix
|
||||
(``request.alarm_filter_prefix``).
|
||||
"""
|
||||
kwargs: dict[str, Any] = {"metadata": merge_metadata(self.options.api_key, metadata)}
|
||||
if self.options.stream_timeout is not None:
|
||||
kwargs["timeout"] = self.options.stream_timeout
|
||||
call = _open_stream(self.raw_stub.StreamAlarms, request, kwargs)
|
||||
return _canceling_iterator(call, "stream alarms")
|
||||
|
||||
async def _unary(
|
||||
self,
|
||||
@@ -165,12 +217,43 @@ class GatewayClient:
|
||||
raise map_rpc_error(operation, error) from error
|
||||
|
||||
|
||||
async def _canceling_iterator(call: Any) -> AsyncIterator[pb.MxEvent]:
|
||||
def _open_stream(method: Any, request: Any, kwargs: dict[str, Any]) -> Any:
|
||||
"""Open a server-streaming call, dropping ``timeout`` if the stub rejects it.
|
||||
|
||||
Mirrors the fallback in ``_unary`` so an older or fake stub that does not
|
||||
accept a ``timeout`` keyword argument does not crash when ``stream_timeout``
|
||||
is configured.
|
||||
"""
|
||||
|
||||
try:
|
||||
async for event in call:
|
||||
yield event
|
||||
return method(request, **kwargs)
|
||||
except TypeError as error:
|
||||
if "timeout" not in kwargs or "unexpected keyword argument 'timeout'" not in str(error):
|
||||
raise
|
||||
kwargs.pop("timeout")
|
||||
return method(request, **kwargs)
|
||||
|
||||
|
||||
async def _canceling_iterator(call: Any, operation: str) -> AsyncIterator[Any]:
|
||||
"""Yield from a server-streaming call and cancel it when iteration stops.
|
||||
|
||||
Explicitly catches :class:`asyncio.CancelledError` to cancel the
|
||||
underlying call before re-raising, then repeats the cancel in the
|
||||
``finally`` block so the call is also cancelled on a clean break or an
|
||||
``aclose()``. ``galaxy._canceling_iterator`` delegates here so the
|
||||
gateway and Galaxy stream helpers stay identical.
|
||||
"""
|
||||
|
||||
try:
|
||||
async for item in call:
|
||||
yield item
|
||||
except asyncio.CancelledError:
|
||||
cancel = getattr(call, "cancel", None)
|
||||
if cancel is not None:
|
||||
cancel()
|
||||
raise
|
||||
except grpc.RpcError as error:
|
||||
raise map_rpc_error("stream events", error) from error
|
||||
raise map_rpc_error(operation, error) from error
|
||||
finally:
|
||||
cancel = getattr(call, "cancel", None)
|
||||
if cancel is not None:
|
||||
|
||||
@@ -138,7 +138,7 @@ def ensure_mxaccess_success(operation: str, reply: pb.MxCommandReply) -> pb.MxCo
|
||||
)
|
||||
|
||||
for mx_status in reply.statuses:
|
||||
if mx_status.success == 0:
|
||||
if _is_mxaccess_status_failure(mx_status):
|
||||
raise MxAccessError(
|
||||
_mxaccess_message(operation, reply),
|
||||
protocol_status=status,
|
||||
@@ -148,6 +148,28 @@ def ensure_mxaccess_success(operation: str, reply: pb.MxCommandReply) -> pb.MxCo
|
||||
return reply
|
||||
|
||||
|
||||
def _is_mxaccess_status_failure(mx_status: pb.MxStatusProxy) -> bool:
|
||||
"""Return ``True`` only for a populated MXAccess status reporting failure.
|
||||
|
||||
MXAccess uses ``success == 0`` as the failure flag, but ``0`` is also the
|
||||
proto3 scalar default. The gateway emits placeholder ``MxStatusProxy``
|
||||
entries with ``success`` unset for null ``MXSTATUS_PROXY`` COM entries
|
||||
(see ``MxStatusProxyConverter.ConvertMany``); such an entry has
|
||||
``category`` of ``UNSPECIFIED`` or ``UNKNOWN``. Treating it as a failure
|
||||
would raise ``MxAccessError`` for a reply that carries no real failure,
|
||||
so failure is keyed on ``success == 0`` together with a populated,
|
||||
non-OK status category.
|
||||
"""
|
||||
|
||||
if mx_status.success != 0:
|
||||
return False
|
||||
return mx_status.category not in (
|
||||
pb.MX_STATUS_CATEGORY_UNSPECIFIED,
|
||||
pb.MX_STATUS_CATEGORY_UNKNOWN,
|
||||
pb.MX_STATUS_CATEGORY_OK,
|
||||
)
|
||||
|
||||
|
||||
def _mxaccess_message(operation: str, reply: pb.MxCommandReply) -> str:
|
||||
status_text = reply.protocol_status.message or "MXAccess command failed"
|
||||
hresult = reply.hresult if reply.HasField("hresult") else None
|
||||
|
||||
@@ -18,6 +18,7 @@ import grpc
|
||||
from google.protobuf.timestamp_pb2 import Timestamp
|
||||
|
||||
from .auth import merge_metadata
|
||||
from .client import _canceling_iterator
|
||||
from .errors import MxGatewayError, map_rpc_error
|
||||
from .generated import galaxy_repository_pb2 as galaxy_pb
|
||||
from .generated import galaxy_repository_pb2_grpc as galaxy_pb_grpc
|
||||
@@ -83,14 +84,20 @@ class GalaxyRepositoryClient:
|
||||
await self.close()
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the owned gRPC channel."""
|
||||
"""Close the owned gRPC channel.
|
||||
|
||||
Idempotent, including under concurrent calls: ``_closed`` is set
|
||||
before the ``await`` so a second coroutine entering ``close()``
|
||||
while the first is still awaiting the channel close returns
|
||||
immediately instead of issuing a second ``channel.close()``.
|
||||
"""
|
||||
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
|
||||
if self._channel is not None:
|
||||
await self._channel.close()
|
||||
self._closed = True
|
||||
|
||||
async def test_connection(self) -> bool:
|
||||
"""Return ``True`` when the gateway can reach the Galaxy Repository DB."""
|
||||
@@ -114,10 +121,17 @@ class GalaxyRepositoryClient:
|
||||
return None
|
||||
return reply.time_of_last_deploy.ToDatetime()
|
||||
|
||||
async def discover_hierarchy(self) -> list[galaxy_pb.GalaxyObject]:
|
||||
"""Return the deployed Galaxy object hierarchy as raw proto messages."""
|
||||
async def iter_hierarchy(self) -> AsyncIterator[galaxy_pb.GalaxyObject]:
|
||||
"""Yield the deployed Galaxy object hierarchy one object at a time.
|
||||
|
||||
Pages are fetched lazily: a page is only requested once the caller has
|
||||
consumed every object from the previous page. This keeps peak memory
|
||||
bounded by a single page (``_DISCOVER_HIERARCHY_PAGE_SIZE`` objects)
|
||||
rather than the whole Galaxy. Use this for large Galaxies; use
|
||||
:meth:`discover_hierarchy` when a fully buffered ``list`` is convenient
|
||||
and the Galaxy is known to be small.
|
||||
"""
|
||||
|
||||
objects: list[galaxy_pb.GalaxyObject] = []
|
||||
seen_page_tokens: set[str] = set()
|
||||
page_token = ""
|
||||
while True:
|
||||
@@ -129,16 +143,27 @@ class GalaxyRepositoryClient:
|
||||
page_token=page_token,
|
||||
),
|
||||
)
|
||||
objects.extend(reply.objects)
|
||||
for obj in reply.objects:
|
||||
yield obj
|
||||
page_token = reply.next_page_token
|
||||
if not page_token:
|
||||
return objects
|
||||
return
|
||||
if page_token in seen_page_tokens:
|
||||
raise MxGatewayError(
|
||||
f"galaxy discover hierarchy returned repeated page token {page_token!r}"
|
||||
)
|
||||
seen_page_tokens.add(page_token)
|
||||
|
||||
async def discover_hierarchy(self) -> list[galaxy_pb.GalaxyObject]:
|
||||
"""Return the deployed Galaxy object hierarchy as raw proto messages.
|
||||
|
||||
This buffers every object (and its full attribute list) into a single
|
||||
in-memory ``list``. For a large Galaxy prefer :meth:`iter_hierarchy`,
|
||||
which streams objects page by page without holding the whole hierarchy.
|
||||
"""
|
||||
|
||||
return [obj async for obj in self.iter_hierarchy()]
|
||||
|
||||
def watch_deploy_events(
|
||||
self,
|
||||
last_seen_deploy_time: datetime | None = None,
|
||||
@@ -171,7 +196,7 @@ class GalaxyRepositoryClient:
|
||||
kwargs.pop("timeout")
|
||||
call = self.raw_stub.WatchDeployEvents(request, **kwargs)
|
||||
|
||||
return _canceling_iterator(call)
|
||||
return _canceling_iterator(call, "watch deploy events")
|
||||
|
||||
async def _unary(
|
||||
self,
|
||||
@@ -200,20 +225,3 @@ class GalaxyRepositoryClient:
|
||||
raise
|
||||
except grpc.RpcError as error:
|
||||
raise map_rpc_error(operation, error) from error
|
||||
|
||||
|
||||
async def _canceling_iterator(call: Any) -> AsyncIterator[galaxy_pb.DeployEvent]:
|
||||
try:
|
||||
async for event in call:
|
||||
yield event
|
||||
except asyncio.CancelledError:
|
||||
cancel = getattr(call, "cancel", None)
|
||||
if cancel is not None:
|
||||
cancel()
|
||||
raise
|
||||
except grpc.RpcError as error:
|
||||
raise map_rpc_error("watch deploy events", error) from error
|
||||
finally:
|
||||
cancel = getattr(call, "cancel", None)
|
||||
if cancel is not None:
|
||||
cancel()
|
||||
|
||||
@@ -26,7 +26,14 @@ if _version_not_supported:
|
||||
|
||||
|
||||
class GalaxyRepositoryStub(object):
|
||||
"""Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
|
||||
"""Wire-compatibility policy (ProtobufStyleGuide): this contract evolves
|
||||
additively only. Never renumber or repurpose an existing field number or
|
||||
enum value. When a field or enum value is removed, add a `reserved` range
|
||||
(and `reserved` name) covering it in the same change so a future editor
|
||||
cannot accidentally reuse the retired tag. There are no `reserved`
|
||||
declarations today because no field or enum value has ever been removed.
|
||||
|
||||
Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
|
||||
database). Lets clients enumerate the deployed object hierarchy and each
|
||||
object's dynamic attributes so they know what tag references to subscribe
|
||||
to via the MxAccessGateway service.
|
||||
@@ -61,7 +68,14 @@ class GalaxyRepositoryStub(object):
|
||||
|
||||
|
||||
class GalaxyRepositoryServicer(object):
|
||||
"""Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
|
||||
"""Wire-compatibility policy (ProtobufStyleGuide): this contract evolves
|
||||
additively only. Never renumber or repurpose an existing field number or
|
||||
enum value. When a field or enum value is removed, add a `reserved` range
|
||||
(and `reserved` name) covering it in the same change so a future editor
|
||||
cannot accidentally reuse the retired tag. There are no `reserved`
|
||||
declarations today because no field or enum value has ever been removed.
|
||||
|
||||
Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
|
||||
database). Lets clients enumerate the deployed object hierarchy and each
|
||||
object's dynamic attributes so they know what tag references to subscribe
|
||||
to via the MxAccessGateway service.
|
||||
@@ -129,7 +143,14 @@ def add_GalaxyRepositoryServicer_to_server(servicer, server):
|
||||
|
||||
# This class is part of an EXPERIMENTAL API.
|
||||
class GalaxyRepository(object):
|
||||
"""Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
|
||||
"""Wire-compatibility policy (ProtobufStyleGuide): this contract evolves
|
||||
additively only. Never renumber or repurpose an existing field number or
|
||||
enum value. When a field or enum value is removed, add a `reserved` range
|
||||
(and `reserved` name) covering it in the same change so a future editor
|
||||
cannot accidentally reuse the retired tag. There are no `reserved`
|
||||
declarations today because no field or enum value has ever been removed.
|
||||
|
||||
Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
|
||||
database). Lets clients enumerate the deployed object hierarchy and each
|
||||
object's dynamic attributes so they know what tag references to subscribe
|
||||
to via the MxAccessGateway service.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -26,7 +26,13 @@ if _version_not_supported:
|
||||
|
||||
|
||||
class MxAccessGatewayStub(object):
|
||||
"""Public client API for MXAccess sessions hosted by the gateway.
|
||||
"""Wire-compatibility policy (ProtobufStyleGuide): this contract evolves
|
||||
additively only. Never renumber or repurpose an existing field number or
|
||||
enum value. When a field or enum value is removed, add a `reserved` range
|
||||
(and `reserved` name) covering it in the same change so a future editor
|
||||
cannot accidentally reuse the retired tag.
|
||||
|
||||
Public client API for MXAccess sessions hosted by the gateway.
|
||||
"""
|
||||
|
||||
def __init__(self, channel):
|
||||
@@ -60,15 +66,21 @@ class MxAccessGatewayStub(object):
|
||||
request_serializer=mxaccess__gateway__pb2.AcknowledgeAlarmRequest.SerializeToString,
|
||||
response_deserializer=mxaccess__gateway__pb2.AcknowledgeAlarmReply.FromString,
|
||||
_registered_method=True)
|
||||
self.QueryActiveAlarms = channel.unary_stream(
|
||||
'/mxaccess_gateway.v1.MxAccessGateway/QueryActiveAlarms',
|
||||
request_serializer=mxaccess__gateway__pb2.QueryActiveAlarmsRequest.SerializeToString,
|
||||
response_deserializer=mxaccess__gateway__pb2.ActiveAlarmSnapshot.FromString,
|
||||
self.StreamAlarms = channel.unary_stream(
|
||||
'/mxaccess_gateway.v1.MxAccessGateway/StreamAlarms',
|
||||
request_serializer=mxaccess__gateway__pb2.StreamAlarmsRequest.SerializeToString,
|
||||
response_deserializer=mxaccess__gateway__pb2.AlarmFeedMessage.FromString,
|
||||
_registered_method=True)
|
||||
|
||||
|
||||
class MxAccessGatewayServicer(object):
|
||||
"""Public client API for MXAccess sessions hosted by the gateway.
|
||||
"""Wire-compatibility policy (ProtobufStyleGuide): this contract evolves
|
||||
additively only. Never renumber or repurpose an existing field number or
|
||||
enum value. When a field or enum value is removed, add a `reserved` range
|
||||
(and `reserved` name) covering it in the same change so a future editor
|
||||
cannot accidentally reuse the retired tag.
|
||||
|
||||
Public client API for MXAccess sessions hosted by the gateway.
|
||||
"""
|
||||
|
||||
def OpenSession(self, request, context):
|
||||
@@ -101,8 +113,13 @@ class MxAccessGatewayServicer(object):
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def QueryActiveAlarms(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
def StreamAlarms(self, request, context):
|
||||
"""Session-less central alarm feed. The stream opens with the current
|
||||
active-alarm snapshot (one `active_alarm` per alarm), then a single
|
||||
`snapshot_complete`, then a `transition` for every subsequent change.
|
||||
Served by the gateway's always-on alarm monitor; any number of clients
|
||||
fan out from the single monitor without opening a worker session.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
@@ -135,10 +152,10 @@ def add_MxAccessGatewayServicer_to_server(servicer, server):
|
||||
request_deserializer=mxaccess__gateway__pb2.AcknowledgeAlarmRequest.FromString,
|
||||
response_serializer=mxaccess__gateway__pb2.AcknowledgeAlarmReply.SerializeToString,
|
||||
),
|
||||
'QueryActiveAlarms': grpc.unary_stream_rpc_method_handler(
|
||||
servicer.QueryActiveAlarms,
|
||||
request_deserializer=mxaccess__gateway__pb2.QueryActiveAlarmsRequest.FromString,
|
||||
response_serializer=mxaccess__gateway__pb2.ActiveAlarmSnapshot.SerializeToString,
|
||||
'StreamAlarms': grpc.unary_stream_rpc_method_handler(
|
||||
servicer.StreamAlarms,
|
||||
request_deserializer=mxaccess__gateway__pb2.StreamAlarmsRequest.FromString,
|
||||
response_serializer=mxaccess__gateway__pb2.AlarmFeedMessage.SerializeToString,
|
||||
),
|
||||
}
|
||||
generic_handler = grpc.method_handlers_generic_handler(
|
||||
@@ -149,7 +166,13 @@ def add_MxAccessGatewayServicer_to_server(servicer, server):
|
||||
|
||||
# This class is part of an EXPERIMENTAL API.
|
||||
class MxAccessGateway(object):
|
||||
"""Public client API for MXAccess sessions hosted by the gateway.
|
||||
"""Wire-compatibility policy (ProtobufStyleGuide): this contract evolves
|
||||
additively only. Never renumber or repurpose an existing field number or
|
||||
enum value. When a field or enum value is removed, add a `reserved` range
|
||||
(and `reserved` name) covering it in the same change so a future editor
|
||||
cannot accidentally reuse the retired tag.
|
||||
|
||||
Public client API for MXAccess sessions hosted by the gateway.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
@@ -288,7 +311,7 @@ class MxAccessGateway(object):
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def QueryActiveAlarms(request,
|
||||
def StreamAlarms(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
@@ -301,9 +324,9 @@ class MxAccessGateway(object):
|
||||
return grpc.experimental.unary_stream(
|
||||
request,
|
||||
target,
|
||||
'/mxaccess_gateway.v1.MxAccessGateway/QueryActiveAlarms',
|
||||
mxaccess__gateway__pb2.QueryActiveAlarmsRequest.SerializeToString,
|
||||
mxaccess__gateway__pb2.ActiveAlarmSnapshot.FromString,
|
||||
'/mxaccess_gateway.v1.MxAccessGateway/StreamAlarms',
|
||||
mxaccess__gateway__pb2.StreamAlarmsRequest.SerializeToString,
|
||||
mxaccess__gateway__pb2.AlarmFeedMessage.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
|
||||
@@ -3,11 +3,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .errors import ensure_mxaccess_success
|
||||
from .generated import mxaccess_gateway_pb2 as pb
|
||||
from .values import MxValueInput, to_mx_value
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .client import GatewayClient
|
||||
|
||||
MAX_BULK_ITEMS = 1000
|
||||
|
||||
|
||||
@@ -36,7 +40,13 @@ class Session:
|
||||
await self.close()
|
||||
|
||||
async def close(self, *, client_correlation_id: str = "") -> pb.CloseSessionReply:
|
||||
"""Close the gateway session. Repeated calls return a local closed reply."""
|
||||
"""Close the gateway session. Repeated calls return a local closed reply.
|
||||
|
||||
Idempotent, including under concurrent calls: ``_closed`` is set
|
||||
before the ``CloseSession`` RPC is awaited so a second coroutine
|
||||
entering ``close()`` while the first RPC is in flight returns the
|
||||
local closed reply instead of issuing a second ``CloseSession``.
|
||||
"""
|
||||
|
||||
if self._closed:
|
||||
return pb.CloseSessionReply(
|
||||
@@ -44,15 +54,14 @@ class Session:
|
||||
final_state=pb.SESSION_STATE_CLOSED,
|
||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||
)
|
||||
self._closed = True
|
||||
|
||||
reply = await self.client.close_session_raw(
|
||||
return await self.client.close_session_raw(
|
||||
pb.CloseSessionRequest(
|
||||
session_id=self.session_id,
|
||||
client_correlation_id=client_correlation_id,
|
||||
),
|
||||
)
|
||||
self._closed = True
|
||||
return reply
|
||||
|
||||
async def invoke(self, command: pb.MxCommand, *, correlation_id: str = "") -> pb.MxCommandReply:
|
||||
"""Invoke a raw command and enforce gateway and MXAccess success."""
|
||||
@@ -66,7 +75,15 @@ class Session:
|
||||
*,
|
||||
correlation_id: str = "",
|
||||
) -> pb.MxCommandReply:
|
||||
"""Invoke a raw command and preserve the raw reply."""
|
||||
"""Invoke a raw command and preserve the raw reply.
|
||||
|
||||
Enforces gateway protocol success only — unlike :meth:`invoke`, it
|
||||
does not run MXAccess-failure detection. An MXAccess HRESULT or
|
||||
``MxStatusProxy`` status failure is left embedded in the returned
|
||||
reply and no ``MxAccessError`` is raised. Parity-test callers must
|
||||
inspect ``protocol_status``, ``hresult``, and ``statuses`` on the
|
||||
reply themselves.
|
||||
"""
|
||||
|
||||
return await self.client.invoke_raw(
|
||||
pb.MxCommandRequest(
|
||||
@@ -334,6 +351,138 @@ class Session:
|
||||
)
|
||||
return list(reply.unsubscribe_bulk.results)
|
||||
|
||||
async def write_bulk(
|
||||
self,
|
||||
server_handle: int,
|
||||
entries: Sequence[pb.WriteBulkEntry],
|
||||
*,
|
||||
correlation_id: str = "",
|
||||
) -> list[pb.BulkWriteResult]:
|
||||
"""Invoke MXAccess `WriteBulk` and return one BulkWriteResult per entry.
|
||||
|
||||
Per-entry MXAccess failures appear as results with ``was_successful = False``
|
||||
and a populated ``error_message`` / ``hresult``; this method does not raise
|
||||
on per-entry failure, mirroring the existing add/advise bulk surface.
|
||||
"""
|
||||
if entries is None:
|
||||
raise TypeError("entries is required")
|
||||
_ensure_bulk_size("entries", len(entries))
|
||||
reply = await self.invoke(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_WRITE_BULK,
|
||||
write_bulk=pb.WriteBulkCommand(
|
||||
server_handle=server_handle,
|
||||
entries=entries,
|
||||
),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return list(reply.write_bulk.results)
|
||||
|
||||
async def write2_bulk(
|
||||
self,
|
||||
server_handle: int,
|
||||
entries: Sequence[pb.Write2BulkEntry],
|
||||
*,
|
||||
correlation_id: str = "",
|
||||
) -> list[pb.BulkWriteResult]:
|
||||
"""Invoke MXAccess `Write2Bulk` (timestamped) and return per-entry results."""
|
||||
if entries is None:
|
||||
raise TypeError("entries is required")
|
||||
_ensure_bulk_size("entries", len(entries))
|
||||
reply = await self.invoke(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_WRITE2_BULK,
|
||||
write2_bulk=pb.Write2BulkCommand(
|
||||
server_handle=server_handle,
|
||||
entries=entries,
|
||||
),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return list(reply.write2_bulk.results)
|
||||
|
||||
async def write_secured_bulk(
|
||||
self,
|
||||
server_handle: int,
|
||||
entries: Sequence[pb.WriteSecuredBulkEntry],
|
||||
*,
|
||||
correlation_id: str = "",
|
||||
) -> list[pb.BulkWriteResult]:
|
||||
"""Invoke MXAccess `WriteSecuredBulk` — credential-sensitive values must not be logged."""
|
||||
if entries is None:
|
||||
raise TypeError("entries is required")
|
||||
_ensure_bulk_size("entries", len(entries))
|
||||
reply = await self.invoke(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_WRITE_SECURED_BULK,
|
||||
write_secured_bulk=pb.WriteSecuredBulkCommand(
|
||||
server_handle=server_handle,
|
||||
entries=entries,
|
||||
),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return list(reply.write_secured_bulk.results)
|
||||
|
||||
async def write_secured2_bulk(
|
||||
self,
|
||||
server_handle: int,
|
||||
entries: Sequence[pb.WriteSecured2BulkEntry],
|
||||
*,
|
||||
correlation_id: str = "",
|
||||
) -> list[pb.BulkWriteResult]:
|
||||
"""Invoke MXAccess `WriteSecured2Bulk` (timestamped + verified)."""
|
||||
if entries is None:
|
||||
raise TypeError("entries is required")
|
||||
_ensure_bulk_size("entries", len(entries))
|
||||
reply = await self.invoke(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_WRITE_SECURED2_BULK,
|
||||
write_secured2_bulk=pb.WriteSecured2BulkCommand(
|
||||
server_handle=server_handle,
|
||||
entries=entries,
|
||||
),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return list(reply.write_secured2_bulk.results)
|
||||
|
||||
async def read_bulk(
|
||||
self,
|
||||
server_handle: int,
|
||||
tag_addresses: Sequence[str],
|
||||
*,
|
||||
timeout_ms: int = 0,
|
||||
correlation_id: str = "",
|
||||
) -> list[pb.BulkReadResult]:
|
||||
"""Invoke `ReadBulk` — snapshot the current value of each requested tag.
|
||||
|
||||
MXAccess COM has no synchronous read; the worker returns the cached
|
||||
``OnDataChange`` value for any tag that is already advised (``was_cached =
|
||||
True``) without modifying the existing subscription, and falls back to
|
||||
a full AddItem + Advise + wait + UnAdvise + RemoveItem snapshot lifecycle
|
||||
otherwise. ``timeout_ms`` bounds the per-tag wait in the snapshot case;
|
||||
pass ``0`` to use the worker default (1000 ms).
|
||||
"""
|
||||
if tag_addresses is None:
|
||||
raise TypeError("tag_addresses is required")
|
||||
_ensure_bulk_size("tag_addresses", len(tag_addresses))
|
||||
if timeout_ms < 0:
|
||||
raise ValueError("timeout_ms must be non-negative")
|
||||
reply = await self.invoke(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_READ_BULK,
|
||||
read_bulk=pb.ReadBulkCommand(
|
||||
server_handle=server_handle,
|
||||
tag_addresses=tag_addresses,
|
||||
timeout_ms=timeout_ms,
|
||||
),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return list(reply.read_bulk.results)
|
||||
|
||||
async def write(
|
||||
self,
|
||||
server_handle: int,
|
||||
@@ -399,6 +548,3 @@ class Session:
|
||||
def _ensure_bulk_size(name: str, count: int) -> None:
|
||||
if count > MAX_BULK_ITEMS:
|
||||
raise ValueError(f"{name} bulk commands are limited to {MAX_BULK_ITEMS} item(s)")
|
||||
|
||||
|
||||
from .client import GatewayClient # noqa: E402
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
"""MXAccess value conversion helpers."""
|
||||
"""MXAccess value conversion helpers.
|
||||
|
||||
Value-mapping assumptions (see ``to_mx_value``):
|
||||
|
||||
* A Python ``float`` maps to ``VT_R8`` / ``MX_DATA_TYPE_DOUBLE``. Only finite
|
||||
values are accepted — ``nan``, ``inf`` and ``-inf`` raise ``ValueError``
|
||||
rather than being forwarded to MXAccess, which has no defined wire
|
||||
representation for non-finite doubles.
|
||||
* A Python ``bytes`` value maps to ``VT_RECORD`` / ``MX_DATA_TYPE_UNKNOWN``
|
||||
and is carried in ``raw_value``. This is an opaque pass-through: MXAccess
|
||||
does not interpret the bytes. Pass ``data_type`` explicitly when a concrete
|
||||
MXAccess type is required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
@@ -60,6 +73,7 @@ def to_mx_value(value: MxValueInput, *, data_type: str | None = None) -> pb.MxVa
|
||||
)
|
||||
|
||||
if isinstance(value, float):
|
||||
_ensure_finite(value)
|
||||
return pb.MxValue(
|
||||
data_type=_data_type(data_type, pb.MX_DATA_TYPE_DOUBLE),
|
||||
variant_type="VT_R8",
|
||||
@@ -177,6 +191,8 @@ def _sequence_to_mx_value(
|
||||
return pb.MxValue(data_type=pb.MX_DATA_TYPE_INTEGER, array_value=array)
|
||||
|
||||
if all(isinstance(item, float) for item in sequence):
|
||||
for item in sequence:
|
||||
_ensure_finite(item)
|
||||
array = pb.MxArray(
|
||||
element_data_type=pb.MX_DATA_TYPE_DOUBLE,
|
||||
variant_type="VT_ARRAY|VT_R8",
|
||||
@@ -232,3 +248,12 @@ def _data_type(name: str | None, default: int) -> int:
|
||||
if name is None:
|
||||
return default
|
||||
return pb.MxDataType.Value(name)
|
||||
|
||||
|
||||
def _ensure_finite(value: float) -> None:
|
||||
"""Reject non-finite doubles, which MXAccess cannot represent on the wire."""
|
||||
|
||||
if not math.isfinite(value):
|
||||
raise ValueError(
|
||||
f"MxValue double inputs must be finite; got {value!r}",
|
||||
)
|
||||
|
||||
@@ -5,11 +5,13 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
from click.testing import CliRunner
|
||||
from google.protobuf.json_format import MessageToDict
|
||||
|
||||
from mxgateway import __version__
|
||||
@@ -18,10 +20,13 @@ from mxgateway.client import GatewayClient
|
||||
from mxgateway.errors import MxGatewayError
|
||||
from mxgateway.generated import mxaccess_gateway_pb2 as pb
|
||||
from mxgateway.options import ClientOptions
|
||||
from mxgateway.values import MxValueInput
|
||||
from mxgateway.session import Session
|
||||
from mxgateway.values import MxValueInput, to_mx_value
|
||||
|
||||
MAX_AGGREGATE_EVENTS = 10_000
|
||||
|
||||
_BATCH_EOR = "__MXGW_BATCH_EOR__"
|
||||
|
||||
|
||||
@click.group()
|
||||
def main() -> None:
|
||||
@@ -41,6 +46,80 @@ def version(output_json: bool) -> None:
|
||||
_emit(payload, output_json=output_json, text=f"mxgw-py {__version__}")
|
||||
|
||||
|
||||
@main.command()
|
||||
def batch() -> None:
|
||||
"""Read commands from stdin and execute each, writing output + __MXGW_BATCH_EOR__ after each.
|
||||
|
||||
Each non-empty line of stdin is a complete argument string (no quoting support — the
|
||||
harness never passes whitespace-containing arguments). Lines are split on runs of ASCII
|
||||
whitespace and dispatched through the normal CLI parser. On EOF or an empty line, exit 0.
|
||||
|
||||
Errors do NOT terminate the loop. Each command's output (including any error JSON) is
|
||||
written to stdout followed by a line containing exactly ``__MXGW_BATCH_EOR__``, then
|
||||
stdout is flushed. Error output is formatted as ``{"error": "...", "type": "..."}``.
|
||||
"""
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
for raw_line in sys.stdin:
|
||||
line = raw_line.rstrip("\n").rstrip("\r")
|
||||
if not line:
|
||||
# Empty line signals clean exit (matches the spec and .NET behaviour).
|
||||
break
|
||||
|
||||
args = line.split()
|
||||
|
||||
try:
|
||||
result = runner.invoke(main, args, catch_exceptions=True)
|
||||
except Exception as exc: # noqa: BLE001 — be safe; never let batch loop die
|
||||
_batch_write_error(exc.__class__.__name__, str(exc))
|
||||
_batch_flush_eor()
|
||||
continue
|
||||
|
||||
if result.exit_code == 0:
|
||||
# Normal success — write captured output as-is.
|
||||
sys.stdout.write(result.output)
|
||||
else:
|
||||
# Something went wrong. If the command already emitted a JSON object
|
||||
# (e.g. the output starts with '{'), trust that and relay it verbatim.
|
||||
# Otherwise synthesise the standard {"error": ..., "type": ...} shape.
|
||||
output = result.output or ""
|
||||
exc = result.exception
|
||||
|
||||
if output.lstrip().startswith("{"):
|
||||
# Already JSON — relay verbatim (may or may not end with newline).
|
||||
sys.stdout.write(output)
|
||||
if not output.endswith("\n"):
|
||||
sys.stdout.write("\n")
|
||||
elif exc is not None and not isinstance(exc, SystemExit):
|
||||
_batch_write_error(type(exc).__name__, str(exc))
|
||||
else:
|
||||
# Click's default error format is "Error: <message>\n"; extract the
|
||||
# message so the harness gets clean JSON.
|
||||
msg = output.strip()
|
||||
if msg.startswith("Error: "):
|
||||
msg = msg[len("Error: "):]
|
||||
exc_type = (
|
||||
type(exc).__name__
|
||||
if exc is not None and not isinstance(exc, SystemExit)
|
||||
else "CliError"
|
||||
)
|
||||
_batch_write_error(exc_type, msg)
|
||||
|
||||
_batch_flush_eor()
|
||||
|
||||
|
||||
def _batch_write_error(exc_type: str, message: str) -> None:
|
||||
"""Write a JSON error record to stdout in the standard batch error shape."""
|
||||
sys.stdout.write(json.dumps({"error": message, "type": exc_type}) + "\n")
|
||||
|
||||
|
||||
def _batch_flush_eor() -> None:
|
||||
"""Write the end-of-record sentinel and flush stdout."""
|
||||
sys.stdout.write(_BATCH_EOR + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def gateway_options(command: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""Apply the shared gateway connection options to a Click command."""
|
||||
command = click.option("--endpoint", default="localhost:5000", show_default=True)(command)
|
||||
@@ -50,8 +129,25 @@ def gateway_options(command: Callable[..., Any]) -> Callable[..., Any]:
|
||||
default=None,
|
||||
help="Environment variable containing the gateway API key.",
|
||||
)(command)
|
||||
command = click.option("--plaintext", is_flag=True, help="Use plaintext gRPC.")(command)
|
||||
command = click.option("--tls", "use_tls", is_flag=True, help="Use TLS gRPC.")(command)
|
||||
command = click.option(
|
||||
"--plaintext",
|
||||
is_flag=True,
|
||||
help=(
|
||||
"Use a plaintext gRPC channel. TLS is the default; pass --plaintext "
|
||||
"explicitly to opt in to an unencrypted channel (no implicit "
|
||||
"localhost downgrade)."
|
||||
),
|
||||
)(command)
|
||||
command = click.option(
|
||||
"--tls",
|
||||
"use_tls",
|
||||
is_flag=True,
|
||||
help=(
|
||||
"Use a TLS gRPC channel. Redundant with the default; retained for "
|
||||
"symmetry with other client CLIs. Cannot be combined with "
|
||||
"--plaintext."
|
||||
),
|
||||
)(command)
|
||||
command = click.option("--ca-file", default=None, help="Custom root certificate file.")(command)
|
||||
command = click.option(
|
||||
"--server-name-override",
|
||||
@@ -185,6 +281,112 @@ def unsubscribe_bulk(**kwargs: Any) -> None:
|
||||
)
|
||||
|
||||
|
||||
@main.command("read-bulk")
|
||||
@gateway_options
|
||||
@click.option("--session-id", required=True, help="Gateway session id.")
|
||||
@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
|
||||
@click.option("--items", required=True, help="Comma-separated MXAccess tag addresses.")
|
||||
@click.option("--timeout-ms", default=0, type=int, show_default=True,
|
||||
help="Per-tag snapshot timeout in milliseconds. 0 = worker default.")
|
||||
@click.option("--correlation-id", default="", help="Client correlation id.")
|
||||
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
||||
def read_bulk(**kwargs: Any) -> None:
|
||||
"""Invoke MXAccess ReadBulk — cached value when advised, snapshot otherwise."""
|
||||
|
||||
_run(_read_bulk(**kwargs), output_json=kwargs["output_json"], secrets=_secrets(kwargs))
|
||||
|
||||
|
||||
@main.command("write-bulk")
|
||||
@gateway_options
|
||||
@click.option("--session-id", required=True, help="Gateway session id.")
|
||||
@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
|
||||
@click.option("--item-handles", required=True, help="Comma-separated MXAccess item handles.")
|
||||
@click.option("--type", "value_type", default="string", show_default=True)
|
||||
@click.option("--values", required=True, help="Comma-separated values, one per item handle.")
|
||||
@click.option("--user-id", default=0, type=int, show_default=True)
|
||||
@click.option("--correlation-id", default="", help="Client correlation id.")
|
||||
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
||||
def write_bulk(**kwargs: Any) -> None:
|
||||
"""Invoke MXAccess WriteBulk — sequential Write per entry."""
|
||||
|
||||
_run(_write_bulk(**kwargs), output_json=kwargs["output_json"], secrets=_secrets(kwargs))
|
||||
|
||||
|
||||
@main.command("write2-bulk")
|
||||
@gateway_options
|
||||
@click.option("--session-id", required=True, help="Gateway session id.")
|
||||
@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
|
||||
@click.option("--item-handles", required=True, help="Comma-separated MXAccess item handles.")
|
||||
@click.option("--type", "value_type", default="string", show_default=True)
|
||||
@click.option("--values", required=True, help="Comma-separated values, one per item handle.")
|
||||
@click.option("--timestamp", required=True, help="ISO-8601 timestamp shared across all entries.")
|
||||
@click.option("--user-id", default=0, type=int, show_default=True)
|
||||
@click.option("--correlation-id", default="", help="Client correlation id.")
|
||||
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
||||
def write2_bulk(**kwargs: Any) -> None:
|
||||
"""Invoke MXAccess Write2Bulk — timestamped sequential Write2 per entry."""
|
||||
|
||||
_run(_write2_bulk(**kwargs), output_json=kwargs["output_json"], secrets=_secrets(kwargs))
|
||||
|
||||
|
||||
@main.command("write-secured-bulk")
|
||||
@gateway_options
|
||||
@click.option("--session-id", required=True, help="Gateway session id.")
|
||||
@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
|
||||
@click.option("--item-handles", required=True, help="Comma-separated MXAccess item handles.")
|
||||
@click.option("--type", "value_type", default="string", show_default=True)
|
||||
@click.option("--values", required=True, help="Comma-separated values, one per item handle.")
|
||||
@click.option("--current-user-id", default=0, type=int, show_default=True)
|
||||
@click.option("--verifier-user-id", default=0, type=int, show_default=True)
|
||||
@click.option("--correlation-id", default="", help="Client correlation id.")
|
||||
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
||||
def write_secured_bulk(**kwargs: Any) -> None:
|
||||
"""Invoke MXAccess WriteSecuredBulk — credential-sensitive."""
|
||||
|
||||
_run(_write_secured_bulk(**kwargs), output_json=kwargs["output_json"], secrets=_secrets(kwargs))
|
||||
|
||||
|
||||
@main.command("write-secured2-bulk")
|
||||
@gateway_options
|
||||
@click.option("--session-id", required=True, help="Gateway session id.")
|
||||
@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
|
||||
@click.option("--item-handles", required=True, help="Comma-separated MXAccess item handles.")
|
||||
@click.option("--type", "value_type", default="string", show_default=True)
|
||||
@click.option("--values", required=True, help="Comma-separated values, one per item handle.")
|
||||
@click.option("--timestamp", required=True, help="ISO-8601 timestamp shared across all entries.")
|
||||
@click.option("--current-user-id", default=0, type=int, show_default=True)
|
||||
@click.option("--verifier-user-id", default=0, type=int, show_default=True)
|
||||
@click.option("--correlation-id", default="", help="Client correlation id.")
|
||||
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
||||
def write_secured2_bulk(**kwargs: Any) -> None:
|
||||
"""Invoke MXAccess WriteSecured2Bulk — timestamped + credential-sensitive."""
|
||||
|
||||
_run(_write_secured2_bulk(**kwargs), output_json=kwargs["output_json"], secrets=_secrets(kwargs))
|
||||
|
||||
|
||||
@main.command("bench-read-bulk")
|
||||
@gateway_options
|
||||
@click.option("--client-name", default="mxgw-python-bench", show_default=True)
|
||||
@click.option("--duration-seconds", default=30, type=int, show_default=True)
|
||||
@click.option("--warmup-seconds", default=3, type=int, show_default=True)
|
||||
@click.option("--bulk-size", default=6, type=int, show_default=True)
|
||||
@click.option("--tag-start", default=1, type=int, show_default=True)
|
||||
@click.option("--tag-prefix", default="TestMachine_", show_default=True)
|
||||
@click.option("--tag-attribute", default="TestChangingInt", show_default=True)
|
||||
@click.option("--timeout-ms", default=1500, type=int, show_default=True)
|
||||
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
||||
def bench_read_bulk(**kwargs: Any) -> None:
|
||||
"""Cross-language ReadBulk stress benchmark.
|
||||
|
||||
Opens its own session, subscribes to bulk-size tags so the worker value
|
||||
cache populates from real OnDataChange events, runs ReadBulk in a tight
|
||||
loop for duration-seconds, and emits the shared JSON stats schema the
|
||||
scripts/bench-read-bulk.ps1 driver collates across all five clients.
|
||||
"""
|
||||
|
||||
_run(_bench_read_bulk(**kwargs), output_json=kwargs["output_json"], secrets=_secrets(kwargs))
|
||||
|
||||
|
||||
@main.command("stream-events")
|
||||
@gateway_options
|
||||
@click.option("--session-id", required=True, help="Gateway session id.")
|
||||
@@ -202,6 +404,40 @@ def stream_events(**kwargs: Any) -> None:
|
||||
)
|
||||
|
||||
|
||||
@main.command("stream-alarms")
|
||||
@gateway_options
|
||||
@click.option("--filter-prefix", default="", help="Alarm-reference prefix filter.")
|
||||
@click.option("--max-messages", default=1, type=int, show_default=True)
|
||||
@click.option("--timeout", default=5.0, type=float, show_default=True)
|
||||
@click.option("--correlation-id", default="", help="Client correlation id.")
|
||||
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
||||
def stream_alarms(**kwargs: Any) -> None:
|
||||
"""Stream a bounded number of messages from the gateway's central alarm feed."""
|
||||
|
||||
_run(
|
||||
_stream_alarms(**kwargs),
|
||||
output_json=kwargs["output_json"],
|
||||
secrets=_secrets(kwargs),
|
||||
)
|
||||
|
||||
|
||||
@main.command("acknowledge-alarm")
|
||||
@gateway_options
|
||||
@click.option("--reference", required=True, help="Alarm full reference to acknowledge.")
|
||||
@click.option("--comment", default="", help="Acknowledgement comment.")
|
||||
@click.option("--operator", default="", help="Operator user name.")
|
||||
@click.option("--correlation-id", default="", help="Client correlation id.")
|
||||
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
||||
def acknowledge_alarm(**kwargs: Any) -> None:
|
||||
"""Acknowledge an active MXAccess alarm condition (session-less)."""
|
||||
|
||||
_run(
|
||||
_acknowledge_alarm(**kwargs),
|
||||
output_json=kwargs["output_json"],
|
||||
secrets=_secrets(kwargs),
|
||||
)
|
||||
|
||||
|
||||
@main.command()
|
||||
@gateway_options
|
||||
@click.option("--session-id", required=True, help="Gateway session id.")
|
||||
@@ -339,6 +575,233 @@ async def _unsubscribe_bulk(**kwargs: Any) -> dict[str, Any]:
|
||||
return {"results": [_message_dict(result) for result in results]}
|
||||
|
||||
|
||||
async def _read_bulk(**kwargs: Any) -> dict[str, Any]:
|
||||
async with await _connect(kwargs) as client:
|
||||
session = _session(client, kwargs["session_id"])
|
||||
results = await session.read_bulk(
|
||||
kwargs["server_handle"],
|
||||
_parse_string_list(kwargs["items"]),
|
||||
timeout_ms=kwargs["timeout_ms"],
|
||||
correlation_id=kwargs["correlation_id"],
|
||||
)
|
||||
return {"results": [_message_dict(result) for result in results]}
|
||||
|
||||
|
||||
def _build_write_bulk_entries(kwargs: dict[str, Any]):
|
||||
"""Build (item_handle, MxValue) pairs for the bulk-write families.
|
||||
|
||||
The CLI accepts a single ``--type`` plus ``--values`` (comma-separated
|
||||
string-encoded values, one per ``--item-handles`` entry). Returns the
|
||||
parsed item-handle list and the per-entry MxValue protobuf instances —
|
||||
callers wrap these into the appropriate per-entry message type.
|
||||
"""
|
||||
|
||||
handles = _parse_int_list(kwargs["item_handles"])
|
||||
value_texts = _parse_string_list(kwargs["values"])
|
||||
if len(handles) != len(value_texts):
|
||||
raise click.UsageError(
|
||||
f"item-handles count ({len(handles)}) does not match values count ({len(value_texts)})",
|
||||
)
|
||||
parsed = [_parse_value(text, kwargs["value_type"]) for text in value_texts]
|
||||
values = [to_mx_value(v) for v in parsed]
|
||||
return handles, values
|
||||
|
||||
|
||||
async def _write_bulk(**kwargs: Any) -> dict[str, Any]:
|
||||
handles, values = _build_write_bulk_entries(kwargs)
|
||||
entries = [
|
||||
pb.WriteBulkEntry(item_handle=handle, user_id=kwargs["user_id"], value=value)
|
||||
for handle, value in zip(handles, values)
|
||||
]
|
||||
async with await _connect(kwargs) as client:
|
||||
session = _session(client, kwargs["session_id"])
|
||||
results = await session.write_bulk(
|
||||
kwargs["server_handle"],
|
||||
entries,
|
||||
correlation_id=kwargs["correlation_id"],
|
||||
)
|
||||
return {"results": [_message_dict(result) for result in results]}
|
||||
|
||||
|
||||
async def _write2_bulk(**kwargs: Any) -> dict[str, Any]:
|
||||
handles, values = _build_write_bulk_entries(kwargs)
|
||||
timestamp_value = to_mx_value(_parse_datetime(kwargs["timestamp"]))
|
||||
entries = [
|
||||
pb.Write2BulkEntry(
|
||||
item_handle=handle,
|
||||
user_id=kwargs["user_id"],
|
||||
value=value,
|
||||
timestamp_value=timestamp_value,
|
||||
)
|
||||
for handle, value in zip(handles, values)
|
||||
]
|
||||
async with await _connect(kwargs) as client:
|
||||
session = _session(client, kwargs["session_id"])
|
||||
results = await session.write2_bulk(
|
||||
kwargs["server_handle"],
|
||||
entries,
|
||||
correlation_id=kwargs["correlation_id"],
|
||||
)
|
||||
return {"results": [_message_dict(result) for result in results]}
|
||||
|
||||
|
||||
async def _write_secured_bulk(**kwargs: Any) -> dict[str, Any]:
|
||||
handles, values = _build_write_bulk_entries(kwargs)
|
||||
entries = [
|
||||
pb.WriteSecuredBulkEntry(
|
||||
item_handle=handle,
|
||||
current_user_id=kwargs["current_user_id"],
|
||||
verifier_user_id=kwargs["verifier_user_id"],
|
||||
value=value,
|
||||
)
|
||||
for handle, value in zip(handles, values)
|
||||
]
|
||||
async with await _connect(kwargs) as client:
|
||||
session = _session(client, kwargs["session_id"])
|
||||
results = await session.write_secured_bulk(
|
||||
kwargs["server_handle"],
|
||||
entries,
|
||||
correlation_id=kwargs["correlation_id"],
|
||||
)
|
||||
return {"results": [_message_dict(result) for result in results]}
|
||||
|
||||
|
||||
async def _write_secured2_bulk(**kwargs: Any) -> dict[str, Any]:
|
||||
handles, values = _build_write_bulk_entries(kwargs)
|
||||
timestamp_value = to_mx_value(_parse_datetime(kwargs["timestamp"]))
|
||||
entries = [
|
||||
pb.WriteSecured2BulkEntry(
|
||||
item_handle=handle,
|
||||
current_user_id=kwargs["current_user_id"],
|
||||
verifier_user_id=kwargs["verifier_user_id"],
|
||||
value=value,
|
||||
timestamp_value=timestamp_value,
|
||||
)
|
||||
for handle, value in zip(handles, values)
|
||||
]
|
||||
async with await _connect(kwargs) as client:
|
||||
session = _session(client, kwargs["session_id"])
|
||||
results = await session.write_secured2_bulk(
|
||||
kwargs["server_handle"],
|
||||
entries,
|
||||
correlation_id=kwargs["correlation_id"],
|
||||
)
|
||||
return {"results": [_message_dict(result) for result in results]}
|
||||
|
||||
|
||||
async def _bench_read_bulk(**kwargs: Any) -> dict[str, Any]:
|
||||
"""ReadBulk stress benchmark — matches the .NET / Go / Rust / Java schema."""
|
||||
import time
|
||||
|
||||
bulk_size = int(kwargs["bulk_size"])
|
||||
if bulk_size < 1:
|
||||
raise click.UsageError("bulk-size must be positive")
|
||||
duration_seconds = int(kwargs["duration_seconds"])
|
||||
warmup_seconds = int(kwargs["warmup_seconds"])
|
||||
tag_start = int(kwargs["tag_start"])
|
||||
tag_prefix = kwargs["tag_prefix"]
|
||||
tag_attribute = kwargs["tag_attribute"]
|
||||
timeout_ms = int(kwargs["timeout_ms"])
|
||||
client_name = kwargs["client_name"]
|
||||
tags = [f"{tag_prefix}{i:03d}.{tag_attribute}" for i in range(tag_start, tag_start + bulk_size)]
|
||||
|
||||
async with await _connect(kwargs) as client:
|
||||
session = await client.open_session(client_session_name=client_name)
|
||||
server_handle = 0
|
||||
item_handles: list[int] = []
|
||||
try:
|
||||
server_handle = await session.register(client_name)
|
||||
subscribe_results = await session.subscribe_bulk(server_handle, tags)
|
||||
item_handles = [r.item_handle for r in subscribe_results if r.was_successful]
|
||||
|
||||
# Warm-up window so JIT / connection pool / first-call costs are
|
||||
# amortised before the measurement window opens.
|
||||
warmup_deadline = time.perf_counter() + warmup_seconds
|
||||
while time.perf_counter() < warmup_deadline:
|
||||
await session.read_bulk(server_handle, tags, timeout_ms=timeout_ms)
|
||||
|
||||
latencies_ms: list[float] = []
|
||||
total_results = 0
|
||||
cached_results = 0
|
||||
successful = 0
|
||||
failed = 0
|
||||
steady_start = time.perf_counter()
|
||||
steady_deadline = steady_start + duration_seconds
|
||||
while time.perf_counter() < steady_deadline:
|
||||
call_start = time.perf_counter()
|
||||
try:
|
||||
results = await session.read_bulk(server_handle, tags, timeout_ms=timeout_ms)
|
||||
except Exception:
|
||||
failed += 1
|
||||
latencies_ms.append((time.perf_counter() - call_start) * 1000.0)
|
||||
continue
|
||||
latencies_ms.append((time.perf_counter() - call_start) * 1000.0)
|
||||
successful += 1
|
||||
for r in results:
|
||||
total_results += 1
|
||||
if r.was_cached:
|
||||
cached_results += 1
|
||||
steady_elapsed = time.perf_counter() - steady_start
|
||||
total_calls = successful + failed
|
||||
calls_per_second = total_calls / steady_elapsed if steady_elapsed > 0 else 0.0
|
||||
finally:
|
||||
if item_handles:
|
||||
try:
|
||||
await session.unsubscribe_bulk(server_handle, item_handles)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await session.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"language": "python",
|
||||
"command": "bench-read-bulk",
|
||||
"endpoint": kwargs.get("endpoint"),
|
||||
"clientName": client_name,
|
||||
"bulkSize": bulk_size,
|
||||
"durationSeconds": duration_seconds,
|
||||
"warmupSeconds": warmup_seconds,
|
||||
"durationMs": int(steady_elapsed * 1000),
|
||||
"tags": tags,
|
||||
"totalCalls": total_calls,
|
||||
"successfulCalls": successful,
|
||||
"failedCalls": failed,
|
||||
"totalReadResults": total_results,
|
||||
"cachedReadResults": cached_results,
|
||||
"callsPerSecond": round(calls_per_second, 2),
|
||||
"latencyMs": _percentile_summary(latencies_ms),
|
||||
}
|
||||
|
||||
|
||||
def _percentile_summary(sample: list[float]) -> dict[str, float]:
|
||||
if not sample:
|
||||
return {"p50": 0.0, "p95": 0.0, "p99": 0.0, "max": 0.0, "mean": 0.0}
|
||||
sorted_sample = sorted(sample)
|
||||
return {
|
||||
"p50": round(_percentile(sorted_sample, 0.50), 3),
|
||||
"p95": round(_percentile(sorted_sample, 0.95), 3),
|
||||
"p99": round(_percentile(sorted_sample, 0.99), 3),
|
||||
"max": round(sorted_sample[-1], 3),
|
||||
"mean": round(sum(sample) / len(sample), 3),
|
||||
}
|
||||
|
||||
|
||||
def _percentile(sorted_sample: list[float], quantile: float) -> float:
|
||||
"""Nearest-rank with linear interpolation; matches every other client."""
|
||||
n = len(sorted_sample)
|
||||
if n == 0:
|
||||
return 0.0
|
||||
if n == 1:
|
||||
return sorted_sample[0]
|
||||
rank = quantile * (n - 1)
|
||||
lower = int(rank)
|
||||
upper = min(lower + 1, n - 1)
|
||||
fraction = rank - lower
|
||||
return sorted_sample[lower] + (sorted_sample[upper] - sorted_sample[lower]) * fraction
|
||||
|
||||
|
||||
async def _stream_events(**kwargs: Any) -> dict[str, Any]:
|
||||
async with await _connect(kwargs) as client:
|
||||
session = _session(client, kwargs["session_id"])
|
||||
@@ -350,6 +813,34 @@ async def _stream_events(**kwargs: Any) -> dict[str, Any]:
|
||||
return {"events": [_message_dict(event) for event in events]}
|
||||
|
||||
|
||||
async def _stream_alarms(**kwargs: Any) -> dict[str, Any]:
|
||||
async with await _connect(kwargs) as client:
|
||||
messages = await _collect_alarm_messages(
|
||||
client.stream_alarms(
|
||||
pb.StreamAlarmsRequest(
|
||||
client_correlation_id=kwargs["correlation_id"],
|
||||
alarm_filter_prefix=kwargs["filter_prefix"],
|
||||
),
|
||||
),
|
||||
max_messages=kwargs["max_messages"],
|
||||
timeout=kwargs["timeout"],
|
||||
)
|
||||
return {"messages": [_message_dict(message) for message in messages]}
|
||||
|
||||
|
||||
async def _acknowledge_alarm(**kwargs: Any) -> dict[str, Any]:
|
||||
async with await _connect(kwargs) as client:
|
||||
reply = await client.acknowledge_alarm(
|
||||
pb.AcknowledgeAlarmRequest(
|
||||
client_correlation_id=kwargs["correlation_id"],
|
||||
alarm_full_reference=kwargs["reference"],
|
||||
comment=kwargs["comment"],
|
||||
operator_user=kwargs["operator"],
|
||||
),
|
||||
)
|
||||
return {"rawReply": _message_dict(reply)}
|
||||
|
||||
|
||||
async def _write(**kwargs: Any) -> dict[str, Any]:
|
||||
value = _parse_value(kwargs["value"], kwargs["value_type"])
|
||||
async with await _connect(kwargs) as client:
|
||||
@@ -383,8 +874,7 @@ async def _write2(**kwargs: Any) -> dict[str, Any]:
|
||||
async def _smoke(**kwargs: Any) -> dict[str, Any]:
|
||||
async with await _connect(kwargs) as client:
|
||||
session = await client.open_session(client_session_name=kwargs["client_name"])
|
||||
closed = False
|
||||
try:
|
||||
async with session:
|
||||
server_handle = await session.register(kwargs["client_name"])
|
||||
item_handle = await session.add_item(server_handle, kwargs["item"])
|
||||
await session.advise(server_handle, item_handle)
|
||||
@@ -399,9 +889,6 @@ async def _smoke(**kwargs: Any) -> dict[str, Any]:
|
||||
"itemHandle": item_handle,
|
||||
"events": [_message_dict(event) for event in events],
|
||||
}
|
||||
finally:
|
||||
if not closed:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def _connect(kwargs: dict[str, Any]) -> GatewayClient:
|
||||
@@ -419,18 +906,28 @@ async def _connect(kwargs: dict[str, Any]) -> GatewayClient:
|
||||
)
|
||||
|
||||
|
||||
def _session(client: GatewayClient, session_id: str):
|
||||
from mxgateway.session import Session
|
||||
|
||||
def _session(client: GatewayClient, session_id: str) -> Session:
|
||||
return Session(client=client, session_id=session_id)
|
||||
|
||||
|
||||
def _use_plaintext(kwargs: dict[str, Any]) -> bool:
|
||||
if kwargs.get("use_tls"):
|
||||
return False
|
||||
if kwargs.get("plaintext"):
|
||||
return True
|
||||
return kwargs["endpoint"].startswith("localhost:") or kwargs["endpoint"].startswith("127.0.0.1:")
|
||||
"""Resolve whether to open a plaintext gRPC channel.
|
||||
|
||||
The contract matches the Go and Java CLIs (and is stricter than the
|
||||
previous behaviour): TLS is the default, and the user must pass
|
||||
``--plaintext`` to opt in to an unencrypted channel. There is no implicit
|
||||
localhost downgrade -- silently transmitting a bearer token in cleartext
|
||||
just because the endpoint starts with ``localhost:`` or ``127.0.0.1:`` was
|
||||
the security regression Client.Python-013 closed. ``--tls`` is accepted as
|
||||
a redundant, explicit affirmation of the default and must not be combined
|
||||
with ``--plaintext``.
|
||||
"""
|
||||
|
||||
plaintext = bool(kwargs.get("plaintext"))
|
||||
use_tls = bool(kwargs.get("use_tls"))
|
||||
if plaintext and use_tls:
|
||||
raise click.UsageError("--plaintext and --tls are mutually exclusive.")
|
||||
return plaintext
|
||||
|
||||
|
||||
def _api_key_from_env(name: str | None) -> str | None:
|
||||
@@ -501,6 +998,34 @@ async def _collect_events(
|
||||
return collected
|
||||
|
||||
|
||||
async def _collect_alarm_messages(
|
||||
messages: Any,
|
||||
*,
|
||||
max_messages: int,
|
||||
timeout: float,
|
||||
) -> list[pb.AlarmFeedMessage]:
|
||||
if max_messages > MAX_AGGREGATE_EVENTS:
|
||||
raise click.BadParameter(
|
||||
f"must be less than or equal to {MAX_AGGREGATE_EVENTS}",
|
||||
param_hint="--max-messages",
|
||||
)
|
||||
|
||||
collected: list[pb.AlarmFeedMessage] = []
|
||||
iterator = messages.__aiter__()
|
||||
try:
|
||||
while len(collected) < max_messages:
|
||||
collected.append(await asyncio.wait_for(iterator.__anext__(), timeout=timeout))
|
||||
except StopAsyncIteration:
|
||||
pass
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
finally:
|
||||
close = getattr(iterator, "aclose", None)
|
||||
if close is not None:
|
||||
await close()
|
||||
return collected
|
||||
|
||||
|
||||
def _parse_value(raw_value: str, value_type: str) -> MxValueInput:
|
||||
normalized = value_type.lower()
|
||||
if normalized == "bool":
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Tests for the AcknowledgeAlarm + StreamAlarms client surface."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import grpc
|
||||
import pytest
|
||||
|
||||
from mxgateway import ClientOptions, GatewayClient
|
||||
from mxgateway.errors import MxGatewayAuthenticationError, MxGatewayAuthorizationError
|
||||
from mxgateway.generated import mxaccess_gateway_pb2 as pb
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acknowledge_alarm_sends_request_and_returns_reply() -> None:
|
||||
stub = FakeGatewayStub()
|
||||
stub.acknowledge_alarm.replies = [
|
||||
pb.AcknowledgeAlarmReply(
|
||||
correlation_id="corr-7",
|
||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||
status=pb.MxStatusProxy(success=1, category=pb.MX_STATUS_CATEGORY_OK),
|
||||
),
|
||||
]
|
||||
client = await GatewayClient.connect(
|
||||
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
|
||||
reply = await client.acknowledge_alarm(
|
||||
pb.AcknowledgeAlarmRequest(
|
||||
client_correlation_id="corr-7",
|
||||
alarm_full_reference="Tank01.Level.HiHi",
|
||||
comment="investigating",
|
||||
operator_user="alice",
|
||||
),
|
||||
)
|
||||
|
||||
assert reply.protocol_status.code == pb.PROTOCOL_STATUS_CODE_OK
|
||||
assert reply.status.category == pb.MX_STATUS_CATEGORY_OK
|
||||
|
||||
captured_request = stub.acknowledge_alarm.requests[0]
|
||||
assert captured_request.alarm_full_reference == "Tank01.Level.HiHi"
|
||||
assert captured_request.comment == "investigating"
|
||||
assert captured_request.operator_user == "alice"
|
||||
assert stub.acknowledge_alarm.metadata == (("authorization", "Bearer mxgw_test_secret"),)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acknowledge_alarm_unauthenticated_raises_typed_error() -> None:
|
||||
stub = FakeGatewayStub()
|
||||
stub.acknowledge_alarm.exception = FakeRpcError(grpc.StatusCode.UNAUTHENTICATED, "expired key")
|
||||
client = await GatewayClient.connect(
|
||||
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
|
||||
with pytest.raises(MxGatewayAuthenticationError):
|
||||
await client.acknowledge_alarm(
|
||||
pb.AcknowledgeAlarmRequest(
|
||||
alarm_full_reference="Tank01.Level.HiHi",
|
||||
comment="",
|
||||
operator_user="alice",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acknowledge_alarm_permission_denied_raises_typed_error() -> None:
|
||||
stub = FakeGatewayStub()
|
||||
stub.acknowledge_alarm.exception = FakeRpcError(grpc.StatusCode.PERMISSION_DENIED, "missing scope")
|
||||
client = await GatewayClient.connect(
|
||||
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
|
||||
with pytest.raises(MxGatewayAuthorizationError):
|
||||
await client.acknowledge_alarm(
|
||||
pb.AcknowledgeAlarmRequest(
|
||||
alarm_full_reference="Tank01.Level.HiHi",
|
||||
comment="",
|
||||
operator_user="alice",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_alarms_streams_snapshot_then_snapshot_complete() -> None:
|
||||
messages = [
|
||||
pb.AlarmFeedMessage(
|
||||
active_alarm=pb.ActiveAlarmSnapshot(
|
||||
alarm_full_reference="Tank01.Level.HiHi",
|
||||
current_state=pb.ALARM_CONDITION_STATE_ACTIVE,
|
||||
severity=750,
|
||||
),
|
||||
),
|
||||
pb.AlarmFeedMessage(
|
||||
active_alarm=pb.ActiveAlarmSnapshot(
|
||||
alarm_full_reference="Tank02.Level.HiHi",
|
||||
current_state=pb.ALARM_CONDITION_STATE_ACTIVE_ACKED,
|
||||
severity=750,
|
||||
),
|
||||
),
|
||||
pb.AlarmFeedMessage(snapshot_complete=True),
|
||||
]
|
||||
stream = FakeAlarmFeedStream(messages)
|
||||
stub = FakeGatewayStub(alarm_feed_stream=stream)
|
||||
client = await GatewayClient.connect(
|
||||
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
|
||||
received: list[pb.AlarmFeedMessage] = []
|
||||
async for message in client.stream_alarms(pb.StreamAlarmsRequest()):
|
||||
received.append(message)
|
||||
|
||||
assert len(received) == 3
|
||||
assert received[0].active_alarm.alarm_full_reference == "Tank01.Level.HiHi"
|
||||
assert received[0].active_alarm.current_state == pb.ALARM_CONDITION_STATE_ACTIVE
|
||||
assert received[1].active_alarm.current_state == pb.ALARM_CONDITION_STATE_ACTIVE_ACKED
|
||||
assert received[2].snapshot_complete is True
|
||||
assert stub.stream_metadata == (("authorization", "Bearer mxgw_test_secret"),)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_alarms_passes_filter_prefix() -> None:
|
||||
stream = FakeAlarmFeedStream([])
|
||||
stub = FakeGatewayStub(alarm_feed_stream=stream)
|
||||
client = await GatewayClient.connect(
|
||||
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
|
||||
iterator = client.stream_alarms(
|
||||
pb.StreamAlarmsRequest(alarm_filter_prefix="Tank01."),
|
||||
)
|
||||
# Drain to trigger the stub call.
|
||||
async for _ in iterator:
|
||||
pass
|
||||
|
||||
assert stub.stream_request is not None
|
||||
assert stub.stream_request.alarm_filter_prefix == "Tank01."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_alarms_cancels_underlying_stream_on_close() -> None:
|
||||
messages = [
|
||||
pb.AlarmFeedMessage(
|
||||
active_alarm=pb.ActiveAlarmSnapshot(
|
||||
alarm_full_reference="Tank01.Level.HiHi",
|
||||
current_state=pb.ALARM_CONDITION_STATE_ACTIVE,
|
||||
),
|
||||
),
|
||||
]
|
||||
stream = FakeAlarmFeedStream(messages)
|
||||
stub = FakeGatewayStub(alarm_feed_stream=stream)
|
||||
client = await GatewayClient.connect(
|
||||
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
|
||||
iterator = client.stream_alarms(pb.StreamAlarmsRequest())
|
||||
first = await anext(iterator)
|
||||
await iterator.aclose()
|
||||
|
||||
assert first.active_alarm.alarm_full_reference == "Tank01.Level.HiHi"
|
||||
assert stream.cancelled
|
||||
|
||||
|
||||
class FakeGatewayStub:
|
||||
def __init__(self, alarm_feed_stream: "FakeAlarmFeedStream | None" = None) -> None:
|
||||
self.open_session = FakeUnary(
|
||||
[
|
||||
pb.OpenSessionReply(
|
||||
session_id="session-1",
|
||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||
),
|
||||
],
|
||||
)
|
||||
self.acknowledge_alarm = FakeUnary([])
|
||||
self.OpenSession = self.open_session
|
||||
self.AcknowledgeAlarm = self.acknowledge_alarm
|
||||
self._alarm_feed_stream = alarm_feed_stream or FakeAlarmFeedStream([])
|
||||
self.stream_request: pb.StreamAlarmsRequest | None = None
|
||||
self.stream_metadata: tuple[tuple[str, str], ...] | None = None
|
||||
|
||||
def StreamAlarms(
|
||||
self,
|
||||
request: pb.StreamAlarmsRequest,
|
||||
*,
|
||||
metadata: tuple[tuple[str, str], ...],
|
||||
) -> "FakeAlarmFeedStream":
|
||||
self.stream_request = request
|
||||
self.stream_metadata = metadata
|
||||
return self._alarm_feed_stream
|
||||
|
||||
|
||||
class FakeUnary:
|
||||
def __init__(self, replies: list[Any]) -> None:
|
||||
self.replies = replies
|
||||
self.requests: list[Any] = []
|
||||
self.metadata: tuple[tuple[str, str], ...] | None = None
|
||||
self.exception: Exception | None = None
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
request: Any,
|
||||
*,
|
||||
metadata: tuple[tuple[str, str], ...],
|
||||
) -> Any:
|
||||
self.requests.append(request)
|
||||
self.metadata = metadata
|
||||
if self.exception is not None:
|
||||
raise self.exception
|
||||
return self.replies.pop(0)
|
||||
|
||||
|
||||
class FakeAlarmFeedStream:
|
||||
def __init__(self, messages: list[pb.AlarmFeedMessage]) -> None:
|
||||
self._messages = list(messages)
|
||||
self.cancelled = False
|
||||
|
||||
def __aiter__(self) -> "FakeAlarmFeedStream":
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> pb.AlarmFeedMessage:
|
||||
if not self._messages:
|
||||
raise StopAsyncIteration
|
||||
return self._messages.pop(0)
|
||||
|
||||
def cancel(self) -> None:
|
||||
self.cancelled = True
|
||||
|
||||
|
||||
class FakeRpcError(grpc.RpcError):
|
||||
def __init__(self, code: grpc.StatusCode, details: str) -> None:
|
||||
self._code = code
|
||||
self._details = details
|
||||
|
||||
def code(self) -> grpc.StatusCode: # noqa: D401
|
||||
return self._code
|
||||
|
||||
def details(self) -> str: # noqa: D401
|
||||
return self._details
|
||||
@@ -1,11 +1,15 @@
|
||||
"""Tests for the Python CLI."""
|
||||
|
||||
import io
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from mxgateway import __version__
|
||||
from mxgateway_cli.commands import main
|
||||
from mxgateway_cli.commands import _BATCH_EOR, _use_plaintext, main
|
||||
|
||||
|
||||
def test_version_json_is_deterministic() -> None:
|
||||
@@ -48,6 +52,28 @@ def test_write_parser_rejects_unknown_value_type() -> None:
|
||||
assert "unsupported value type" in result.output
|
||||
|
||||
|
||||
def test_stream_alarms_is_registered() -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
result = runner.invoke(main, ["stream-alarms", "--help"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "--filter-prefix" in result.output
|
||||
assert "--max-messages" in result.output
|
||||
|
||||
|
||||
def test_acknowledge_alarm_requires_reference() -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["acknowledge-alarm", "--api-key", "mxgw_test_secret", "--json"],
|
||||
)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "--reference" in result.output
|
||||
|
||||
|
||||
def test_cli_error_output_redacts_api_key() -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
@@ -66,3 +92,290 @@ def test_cli_error_output_redacts_api_key() -> None:
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "mxgw_test_secret" not in result.output
|
||||
|
||||
|
||||
# Regression tests for Client.Python-013: ``_use_plaintext`` must not silently
|
||||
# downgrade ``localhost:`` / ``127.0.0.1:`` endpoints to plaintext. TLS is the
|
||||
# default; users must pass ``--plaintext`` to opt in.
|
||||
|
||||
|
||||
def test_use_plaintext_requires_explicit_flag_for_localhost_endpoint() -> None:
|
||||
"""A ``localhost:`` endpoint with no flags must resolve to TLS."""
|
||||
|
||||
assert (
|
||||
_use_plaintext(
|
||||
{"endpoint": "localhost:5000", "plaintext": False, "use_tls": False}
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_use_plaintext_requires_explicit_flag_for_loopback_ip_endpoint() -> None:
|
||||
"""A ``127.0.0.1:`` endpoint with no flags must resolve to TLS."""
|
||||
|
||||
assert (
|
||||
_use_plaintext(
|
||||
{"endpoint": "127.0.0.1:5000", "plaintext": False, "use_tls": False}
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_use_plaintext_explicit_plaintext_flag_opts_in() -> None:
|
||||
"""``--plaintext`` must select plaintext regardless of endpoint host."""
|
||||
|
||||
assert (
|
||||
_use_plaintext(
|
||||
{"endpoint": "localhost:5000", "plaintext": True, "use_tls": False}
|
||||
)
|
||||
is True
|
||||
)
|
||||
assert (
|
||||
_use_plaintext(
|
||||
{
|
||||
"endpoint": "mxgateway.example.local:5001",
|
||||
"plaintext": True,
|
||||
"use_tls": False,
|
||||
}
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
def test_use_plaintext_explicit_tls_flag_is_accepted_and_idempotent() -> None:
|
||||
"""``--tls`` is accepted as a redundant affirmation of the default."""
|
||||
|
||||
assert (
|
||||
_use_plaintext(
|
||||
{
|
||||
"endpoint": "mxgateway.example.local:5001",
|
||||
"plaintext": False,
|
||||
"use_tls": True,
|
||||
}
|
||||
)
|
||||
is False
|
||||
)
|
||||
# Even for a localhost endpoint, ``--tls`` (the default) must yield TLS.
|
||||
assert (
|
||||
_use_plaintext(
|
||||
{"endpoint": "localhost:5000", "plaintext": False, "use_tls": True}
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_use_plaintext_rejects_conflicting_flags() -> None:
|
||||
"""``--plaintext`` combined with ``--tls`` is a usage error."""
|
||||
|
||||
with pytest.raises(click.UsageError):
|
||||
_use_plaintext(
|
||||
{"endpoint": "localhost:5000", "plaintext": True, "use_tls": True}
|
||||
)
|
||||
|
||||
|
||||
def test_cli_localhost_endpoint_defaults_to_tls_via_open_session(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""End-to-end: ``open-session`` against ``localhost:`` with no flags
|
||||
must build a TLS ``ClientOptions`` (plaintext=False)."""
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def _fake_connect(options): # type: ignore[no-untyped-def]
|
||||
captured["plaintext"] = options.plaintext
|
||||
raise RuntimeError("stop-before-network")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"mxgateway_cli.commands.GatewayClient.connect", _fake_connect
|
||||
)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
[
|
||||
"open-session",
|
||||
"--endpoint",
|
||||
"localhost:5000",
|
||||
"--api-key",
|
||||
"mxgw_test_secret",
|
||||
"--json",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code != 0 # connect was stubbed to raise
|
||||
assert captured.get("plaintext") is False, (
|
||||
"localhost endpoint must default to TLS without an explicit --plaintext "
|
||||
"flag (Client.Python-013 regression)."
|
||||
)
|
||||
|
||||
|
||||
def test_cli_localhost_endpoint_with_plaintext_flag_uses_plaintext(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""End-to-end: ``--plaintext`` opts in to plaintext as expected."""
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def _fake_connect(options): # type: ignore[no-untyped-def]
|
||||
captured["plaintext"] = options.plaintext
|
||||
raise RuntimeError("stop-before-network")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"mxgateway_cli.commands.GatewayClient.connect", _fake_connect
|
||||
)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
[
|
||||
"open-session",
|
||||
"--endpoint",
|
||||
"localhost:5000",
|
||||
"--api-key",
|
||||
"mxgw_test_secret",
|
||||
"--plaintext",
|
||||
"--json",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert captured.get("plaintext") is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# batch subcommand tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _run_batch(lines: list[str]) -> tuple[int, list[str]]:
|
||||
"""Invoke ``batch`` with the given stdin lines; return (exit_code, stdout_lines)."""
|
||||
runner = CliRunner()
|
||||
stdin_text = "\n".join(lines) + "\n"
|
||||
result = runner.invoke(main, ["batch"], input=stdin_text)
|
||||
stdout_lines = result.output.splitlines()
|
||||
return result.exit_code, stdout_lines
|
||||
|
||||
|
||||
def _split_records(stdout_lines: list[str]) -> list[list[str]]:
|
||||
"""Split stdout lines on ``__MXGW_BATCH_EOR__`` sentinels into per-command records."""
|
||||
records: list[list[str]] = []
|
||||
current: list[str] = []
|
||||
for line in stdout_lines:
|
||||
if line == _BATCH_EOR:
|
||||
records.append(current)
|
||||
current = []
|
||||
else:
|
||||
current.append(line)
|
||||
# Any trailing lines without a sentinel are ignored (shouldn't occur).
|
||||
return records
|
||||
|
||||
|
||||
def test_batch_version_json_produces_eor_sentinel() -> None:
|
||||
"""A single ``version --json`` line produces the version JSON followed by the EOR sentinel."""
|
||||
exit_code, lines = _run_batch(["version --json"])
|
||||
|
||||
assert exit_code == 0
|
||||
records = _split_records(lines)
|
||||
assert len(records) == 1
|
||||
payload = json.loads(records[0][0])
|
||||
assert payload == {
|
||||
"client": "mxgw-py",
|
||||
"package": "mxaccess-gateway-client",
|
||||
"version": __version__,
|
||||
}
|
||||
|
||||
|
||||
def test_batch_two_commands_produce_two_delimited_records() -> None:
|
||||
"""Two input lines produce exactly two EOR-delimited records."""
|
||||
exit_code, lines = _run_batch(["version --json", "version --json"])
|
||||
|
||||
assert exit_code == 0
|
||||
records = _split_records(lines)
|
||||
assert len(records) == 2
|
||||
for record in records:
|
||||
payload = json.loads(record[0])
|
||||
assert payload["client"] == "mxgw-py"
|
||||
|
||||
|
||||
def test_batch_eof_exits_zero() -> None:
|
||||
"""EOF on stdin exits with code 0."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["batch"], input="")
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
def test_batch_empty_line_exits_zero() -> None:
|
||||
"""An empty line signals a clean exit with code 0."""
|
||||
exit_code, lines = _run_batch([""])
|
||||
assert exit_code == 0
|
||||
# No EOR sentinels should have been emitted.
|
||||
assert _BATCH_EOR not in lines
|
||||
|
||||
|
||||
def test_batch_empty_line_stops_processing_subsequent_commands() -> None:
|
||||
"""Commands after the first empty line must not be executed."""
|
||||
exit_code, lines = _run_batch(["", "version --json"])
|
||||
|
||||
assert exit_code == 0
|
||||
# No records should appear because the empty line stopped the loop.
|
||||
records = _split_records(lines)
|
||||
assert records == []
|
||||
|
||||
|
||||
def test_batch_failure_does_not_terminate_loop() -> None:
|
||||
"""A failing command (bad parse) must not terminate the batch loop."""
|
||||
exit_code, lines = _run_batch([
|
||||
"open-session --unknown-flag",
|
||||
"version --json",
|
||||
])
|
||||
|
||||
assert exit_code == 0
|
||||
records = _split_records(lines)
|
||||
# Two records: one error + one success.
|
||||
assert len(records) == 2
|
||||
# First record must be a JSON error object.
|
||||
error_payload = json.loads(records[0][0])
|
||||
assert "error" in error_payload
|
||||
assert "type" in error_payload
|
||||
# Second record must be the version JSON.
|
||||
version_payload = json.loads(records[1][0])
|
||||
assert version_payload["client"] == "mxgw-py"
|
||||
|
||||
|
||||
def test_batch_error_record_has_required_json_shape() -> None:
|
||||
"""A failing command must produce ``{"error": "...", "type": "..."}`` JSON."""
|
||||
exit_code, lines = _run_batch(["open-session --unknown-flag"])
|
||||
|
||||
assert exit_code == 0
|
||||
records = _split_records(lines)
|
||||
assert len(records) == 1
|
||||
payload = json.loads(records[0][0])
|
||||
assert isinstance(payload.get("error"), str)
|
||||
assert isinstance(payload.get("type"), str)
|
||||
|
||||
|
||||
def test_batch_network_error_produces_error_json_not_terminates(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A network-level failure (MxGatewayError) on one command must not stop the loop."""
|
||||
|
||||
async def _fake_connect(kwargs: dict[str, Any]) -> Any:
|
||||
raise RuntimeError("injected-network-failure")
|
||||
|
||||
monkeypatch.setattr("mxgateway_cli.commands.GatewayClient.connect", _fake_connect)
|
||||
|
||||
exit_code, lines = _run_batch([
|
||||
"open-session --endpoint localhost:5000 --api-key mxgw_test --plaintext --json",
|
||||
"version --json",
|
||||
])
|
||||
|
||||
assert exit_code == 0
|
||||
records = _split_records(lines)
|
||||
assert len(records) == 2
|
||||
# First record is an error.
|
||||
error_payload = json.loads(records[0][0])
|
||||
assert "error" in error_payload
|
||||
assert "type" in error_payload
|
||||
# Second record is success.
|
||||
version_payload = json.loads(records[1][0])
|
||||
assert version_payload["client"] == "mxgw-py"
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
"""Regression tests for Client.Python-015 and Client.Python-016.
|
||||
|
||||
Client.Python-015 — coverage for the ``bench-read-bulk`` CLI body and the
|
||||
``_percentile`` / ``_percentile_summary`` helpers. The percentile algorithm
|
||||
must remain byte-for-byte equivalent across the five client languages
|
||||
(.NET, Go, Rust, Java, Python) so cross-language stats are directly
|
||||
comparable; the unit tests here lock that contract down with known inputs.
|
||||
|
||||
Client.Python-016 — coverage for the two remaining untested CLI helpers
|
||||
after Client.Python-013 removed the localhost auto-plaintext branch from
|
||||
``_use_plaintext``: the ``MAX_AGGREGATE_EVENTS`` guard inside
|
||||
``_collect_events`` and the ``_api_key_from_env`` env-var helper.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from mxgateway import ClientOptions, GatewayClient
|
||||
from mxgateway.generated import mxaccess_gateway_pb2 as pb
|
||||
from mxgateway_cli import commands
|
||||
from mxgateway_cli.commands import (
|
||||
MAX_AGGREGATE_EVENTS,
|
||||
_api_key_from_env,
|
||||
_percentile,
|
||||
_percentile_summary,
|
||||
)
|
||||
|
||||
|
||||
# --- Client.Python-015: _percentile / _percentile_summary ------------------
|
||||
#
|
||||
# The algorithm is "linear interpolation between the two closest ranks", with
|
||||
# the rank computed as ``q * (n - 1)``. This matches the .NET, Go, Rust and
|
||||
# Java drivers; any divergence corrupts cross-language comparisons.
|
||||
|
||||
|
||||
def test_percentile_empty_sample_returns_zero() -> None:
|
||||
assert _percentile([], 0.50) == 0.0
|
||||
assert _percentile([], 0.95) == 0.0
|
||||
assert _percentile([], 0.99) == 0.0
|
||||
|
||||
|
||||
def test_percentile_single_element_returns_that_element() -> None:
|
||||
assert _percentile([42.0], 0.0) == 42.0
|
||||
assert _percentile([42.0], 0.50) == 42.0
|
||||
assert _percentile([42.0], 0.95) == 42.0
|
||||
assert _percentile([42.0], 1.0) == 42.0
|
||||
|
||||
|
||||
def test_percentile_exact_rank_returns_sample_value() -> None:
|
||||
# n = 5 → rank for p50 = 0.5 * 4 = 2 → exact index 2 (value 30.0).
|
||||
sample = [10.0, 20.0, 30.0, 40.0, 50.0]
|
||||
assert _percentile(sample, 0.50) == 30.0
|
||||
assert _percentile(sample, 0.0) == 10.0
|
||||
assert _percentile(sample, 1.0) == 50.0
|
||||
|
||||
|
||||
def test_percentile_interpolates_between_ranks() -> None:
|
||||
# n = 5 → rank for p95 = 0.95 * 4 = 3.8 → between index 3 (40.0) and
|
||||
# index 4 (50.0) with fraction 0.8 → 40.0 + (50.0 - 40.0) * 0.8 = 48.0.
|
||||
sample = [10.0, 20.0, 30.0, 40.0, 50.0]
|
||||
assert _percentile(sample, 0.95) == pytest.approx(48.0)
|
||||
# p99 = 0.99 * 4 = 3.96 → 40.0 + 10.0 * 0.96 = 49.6.
|
||||
assert _percentile(sample, 0.99) == pytest.approx(49.6)
|
||||
|
||||
|
||||
def test_percentile_summary_empty_sample_zeros_all_fields() -> None:
|
||||
assert _percentile_summary([]) == {
|
||||
"p50": 0.0,
|
||||
"p95": 0.0,
|
||||
"p99": 0.0,
|
||||
"max": 0.0,
|
||||
"mean": 0.0,
|
||||
}
|
||||
|
||||
|
||||
def test_percentile_summary_known_sample_matches_cross_language_contract() -> None:
|
||||
# The same five-element sample as the percentile interpolation test; the
|
||||
# summary must be byte-for-byte the values the .NET / Go / Rust / Java
|
||||
# drivers produce for the same input (linear interpolation, q * (n-1)).
|
||||
sample = [10.0, 20.0, 30.0, 40.0, 50.0]
|
||||
summary = _percentile_summary(sample)
|
||||
|
||||
assert summary == {
|
||||
"p50": 30.0,
|
||||
"p95": 48.0,
|
||||
"p99": 49.6,
|
||||
"max": 50.0,
|
||||
"mean": 30.0,
|
||||
}
|
||||
|
||||
|
||||
def test_percentile_summary_rounds_to_three_decimal_places() -> None:
|
||||
# 1, 2, 3 → p95 = 0.95 * 2 = 1.9 → 2 + (3 - 2) * 0.9 = 2.9; round(2.9, 3)
|
||||
# is 2.9. Use a sample that exercises the round() call non-trivially.
|
||||
sample = [1.0, 2.0, 3.0001, 4.0001]
|
||||
summary = _percentile_summary(sample)
|
||||
# mean = (1 + 2 + 3.0001 + 4.0001) / 4 = 2.50005 → rounded to 2.5
|
||||
assert summary["mean"] == 2.5
|
||||
# max round to 3dp = 4.0
|
||||
assert summary["max"] == 4.0
|
||||
|
||||
|
||||
# --- Client.Python-015: bench-read-bulk CLI smoke test ---------------------
|
||||
|
||||
|
||||
class _BenchFakeUnary:
|
||||
"""A fake unary RPC that pops a reply per call (cycling on exhaustion)."""
|
||||
|
||||
def __init__(self, replies_factory: Any) -> None:
|
||||
self._factory = replies_factory
|
||||
self.requests: list[Any] = []
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
request: Any,
|
||||
*,
|
||||
metadata: tuple[tuple[str, str], ...],
|
||||
) -> Any:
|
||||
self.requests.append(request)
|
||||
return self._factory(request)
|
||||
|
||||
|
||||
def _ok_reply(kind: int, **fields: Any) -> pb.MxCommandReply:
|
||||
return pb.MxCommandReply(
|
||||
session_id="session-bench",
|
||||
kind=kind,
|
||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||
**fields,
|
||||
)
|
||||
|
||||
|
||||
class _BenchStub:
|
||||
"""Fake gateway stub that handles OpenSession + Invoke for bench-read-bulk."""
|
||||
|
||||
def __init__(self, tags: list[str]) -> None:
|
||||
self._tags = tags
|
||||
|
||||
async def open_session(
|
||||
request: Any,
|
||||
*,
|
||||
metadata: tuple[tuple[str, str], ...],
|
||||
) -> Any:
|
||||
return pb.OpenSessionReply(
|
||||
session_id="session-bench",
|
||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||
)
|
||||
|
||||
async def close_session(
|
||||
request: Any,
|
||||
*,
|
||||
metadata: tuple[tuple[str, str], ...],
|
||||
) -> Any:
|
||||
return pb.CloseSessionReply(
|
||||
session_id=request.session_id,
|
||||
final_state=pb.SESSION_STATE_CLOSED,
|
||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||
)
|
||||
|
||||
def _reply_for(request: Any) -> Any:
|
||||
kind = request.command.kind
|
||||
if kind == pb.MX_COMMAND_KIND_REGISTER:
|
||||
return _ok_reply(
|
||||
kind,
|
||||
register=pb.RegisterReply(server_handle=7),
|
||||
)
|
||||
if kind == pb.MX_COMMAND_KIND_SUBSCRIBE_BULK:
|
||||
results = [
|
||||
pb.SubscribeResult(
|
||||
server_handle=7,
|
||||
tag_address=tag,
|
||||
item_handle=100 + i,
|
||||
was_successful=True,
|
||||
)
|
||||
for i, tag in enumerate(self._tags)
|
||||
]
|
||||
return _ok_reply(
|
||||
kind,
|
||||
subscribe_bulk=pb.BulkSubscribeReply(results=results),
|
||||
)
|
||||
if kind == pb.MX_COMMAND_KIND_UNSUBSCRIBE_BULK:
|
||||
results = [
|
||||
pb.SubscribeResult(
|
||||
server_handle=7,
|
||||
item_handle=100 + i,
|
||||
was_successful=True,
|
||||
)
|
||||
for i in range(len(self._tags))
|
||||
]
|
||||
return _ok_reply(
|
||||
kind,
|
||||
unsubscribe_bulk=pb.BulkSubscribeReply(results=results),
|
||||
)
|
||||
if kind == pb.MX_COMMAND_KIND_READ_BULK:
|
||||
results = [
|
||||
pb.BulkReadResult(
|
||||
server_handle=7,
|
||||
tag_address=tag,
|
||||
item_handle=100 + i,
|
||||
was_successful=True,
|
||||
was_cached=True,
|
||||
)
|
||||
for i, tag in enumerate(self._tags)
|
||||
]
|
||||
return _ok_reply(
|
||||
kind,
|
||||
read_bulk=pb.BulkReadReply(results=results),
|
||||
)
|
||||
raise AssertionError(f"unexpected MxCommand kind in bench test: {kind}")
|
||||
|
||||
self.OpenSession = open_session
|
||||
self.CloseSession = close_session
|
||||
self.Invoke = _BenchFakeUnary(_reply_for)
|
||||
|
||||
|
||||
def test_bench_read_bulk_emits_cross_language_schema(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Drive bench-read-bulk with duration=0 / warmup=0 and assert the schema.
|
||||
|
||||
A drift in any of these field names (callsPerSecond, cachedReadResults,
|
||||
latencyMs.p50, …) would break the cross-language
|
||||
scripts/bench-read-bulk.ps1 aggregation silently.
|
||||
"""
|
||||
|
||||
bulk_size = 3
|
||||
tags = [f"TestMachine_{i:03d}.TestChangingInt" for i in range(1, 1 + bulk_size)]
|
||||
|
||||
async def _fake_connect(kwargs: dict[str, Any]) -> GatewayClient:
|
||||
return await GatewayClient.connect(
|
||||
ClientOptions(endpoint=kwargs["endpoint"], plaintext=True),
|
||||
stub=_BenchStub(tags),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(commands, "_connect", _fake_connect)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
commands.main,
|
||||
[
|
||||
"bench-read-bulk",
|
||||
"--endpoint",
|
||||
"localhost:5000",
|
||||
"--client-name",
|
||||
"pytest-bench",
|
||||
"--duration-seconds",
|
||||
"0",
|
||||
"--warmup-seconds",
|
||||
"0",
|
||||
"--bulk-size",
|
||||
str(bulk_size),
|
||||
"--tag-start",
|
||||
"1",
|
||||
"--json",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
payload = json.loads(result.output)
|
||||
|
||||
# Locked cross-language schema (matches .NET / Go / Rust / Java drivers).
|
||||
expected_top_level = {
|
||||
"language",
|
||||
"command",
|
||||
"endpoint",
|
||||
"clientName",
|
||||
"bulkSize",
|
||||
"durationSeconds",
|
||||
"warmupSeconds",
|
||||
"durationMs",
|
||||
"tags",
|
||||
"totalCalls",
|
||||
"successfulCalls",
|
||||
"failedCalls",
|
||||
"totalReadResults",
|
||||
"cachedReadResults",
|
||||
"callsPerSecond",
|
||||
"latencyMs",
|
||||
}
|
||||
assert set(payload.keys()) == expected_top_level
|
||||
assert payload["language"] == "python"
|
||||
assert payload["command"] == "bench-read-bulk"
|
||||
assert payload["endpoint"] == "localhost:5000"
|
||||
assert payload["clientName"] == "pytest-bench"
|
||||
assert payload["bulkSize"] == bulk_size
|
||||
assert payload["durationSeconds"] == 0
|
||||
assert payload["warmupSeconds"] == 0
|
||||
assert payload["tags"] == tags
|
||||
|
||||
# latencyMs sub-shape is the percentile-summary contract.
|
||||
assert set(payload["latencyMs"].keys()) == {"p50", "p95", "p99", "max", "mean"}
|
||||
for key in ("p50", "p95", "p99", "max", "mean"):
|
||||
assert isinstance(payload["latencyMs"][key], (int, float))
|
||||
|
||||
|
||||
# --- Client.Python-016: MAX_AGGREGATE_EVENTS guard -------------------------
|
||||
|
||||
|
||||
def test_collect_events_rejects_max_events_above_aggregate_cap(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""``--max-events`` greater than ``MAX_AGGREGATE_EVENTS`` exits non-zero
|
||||
with the documented error message.
|
||||
|
||||
The guard lives inside ``_collect_events`` (after a session is opened),
|
||||
so the test routes the CLI through stubbed ``_connect`` / ``_session``
|
||||
fakes and asserts the guard fires before any event is pulled.
|
||||
"""
|
||||
|
||||
class _EventStreamShouldNotBeUsed:
|
||||
def __aiter__(self) -> "_EventStreamShouldNotBeUsed":
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> pb.MxEvent:
|
||||
raise AssertionError(
|
||||
"MAX_AGGREGATE_EVENTS guard must trip before any event is pulled",
|
||||
)
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self) -> None:
|
||||
self.session_id = "session-1"
|
||||
|
||||
def stream_events(
|
||||
self, *, after_worker_sequence: int = 0
|
||||
) -> _EventStreamShouldNotBeUsed:
|
||||
return _EventStreamShouldNotBeUsed()
|
||||
|
||||
class _FakeClient:
|
||||
async def __aenter__(self) -> "_FakeClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc_info: object) -> None:
|
||||
return None
|
||||
|
||||
async def _fake_connect(kwargs: dict[str, Any]) -> _FakeClient:
|
||||
return _FakeClient()
|
||||
|
||||
def _fake_session(client: Any, session_id: str) -> _FakeSession:
|
||||
return _FakeSession()
|
||||
|
||||
monkeypatch.setattr(commands, "_connect", _fake_connect)
|
||||
monkeypatch.setattr(commands, "_session", _fake_session)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
commands.main,
|
||||
[
|
||||
"stream-events",
|
||||
"--endpoint",
|
||||
"localhost:5000",
|
||||
"--session-id",
|
||||
"session-1",
|
||||
"--max-events",
|
||||
str(MAX_AGGREGATE_EVENTS + 1),
|
||||
"--plaintext",
|
||||
"--json",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert f"less than or equal to {MAX_AGGREGATE_EVENTS}" in result.output
|
||||
assert "--max-events" in result.output
|
||||
|
||||
|
||||
def test_collect_events_accepts_max_events_at_aggregate_cap_boundary(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""``--max-events`` equal to ``MAX_AGGREGATE_EVENTS`` must not trip the guard."""
|
||||
|
||||
class _EmptyEventStream:
|
||||
def __aiter__(self) -> "_EmptyEventStream":
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> pb.MxEvent:
|
||||
raise StopAsyncIteration
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self) -> None:
|
||||
self.client = None # type: ignore[assignment]
|
||||
self.session_id = "session-1"
|
||||
|
||||
def stream_events(self, *, after_worker_sequence: int = 0) -> _EmptyEventStream:
|
||||
return _EmptyEventStream()
|
||||
|
||||
class _FakeClient:
|
||||
async def __aenter__(self) -> "_FakeClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc_info: object) -> None:
|
||||
return None
|
||||
|
||||
async def _fake_connect(kwargs: dict[str, Any]) -> _FakeClient:
|
||||
return _FakeClient()
|
||||
|
||||
def _fake_session(client: Any, session_id: str) -> _FakeSession:
|
||||
return _FakeSession()
|
||||
|
||||
monkeypatch.setattr(commands, "_connect", _fake_connect)
|
||||
monkeypatch.setattr(commands, "_session", _fake_session)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
commands.main,
|
||||
[
|
||||
"stream-events",
|
||||
"--endpoint",
|
||||
"localhost:5000",
|
||||
"--session-id",
|
||||
"session-1",
|
||||
"--max-events",
|
||||
str(MAX_AGGREGATE_EVENTS),
|
||||
"--timeout",
|
||||
"0.01",
|
||||
"--plaintext",
|
||||
"--json",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
payload = json.loads(result.output)
|
||||
assert payload == {"events": []}
|
||||
|
||||
|
||||
# --- Client.Python-016: _api_key_from_env ----------------------------------
|
||||
|
||||
|
||||
def test_api_key_from_env_resolves_value_when_variable_is_set(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("MXGATEWAY_TEST_API_KEY", "mxgw_envtest_secret")
|
||||
|
||||
assert _api_key_from_env("MXGATEWAY_TEST_API_KEY") == "mxgw_envtest_secret"
|
||||
|
||||
|
||||
def test_api_key_from_env_returns_none_when_variable_is_unset(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("MXGATEWAY_TEST_API_KEY_NOT_SET", raising=False)
|
||||
|
||||
assert _api_key_from_env("MXGATEWAY_TEST_API_KEY_NOT_SET") is None
|
||||
|
||||
|
||||
def test_api_key_from_env_returns_none_when_name_is_none() -> None:
|
||||
assert _api_key_from_env(None) is None
|
||||
|
||||
|
||||
def test_api_key_from_env_returns_none_when_name_is_empty_string() -> None:
|
||||
# The implementation guards on ``if not name`` so empty string is treated
|
||||
# the same as ``None`` — no env lookup is attempted.
|
||||
assert _api_key_from_env("") is None
|
||||
@@ -93,6 +93,79 @@ async def test_subscribe_bulk_sends_one_bulk_command_and_returns_results() -> No
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_bulk_sends_one_bulk_command_and_returns_per_entry_results() -> None:
|
||||
stub = FakeGatewayStub()
|
||||
bulk_reply = pb.MxCommandReply(
|
||||
session_id="session-1",
|
||||
kind=pb.MX_COMMAND_KIND_WRITE_BULK,
|
||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||
write_bulk=pb.BulkWriteReply(
|
||||
results=[
|
||||
pb.BulkWriteResult(server_handle=12, item_handle=901, was_successful=True),
|
||||
pb.BulkWriteResult(server_handle=12, item_handle=902, was_successful=False, error_message="invalid handle"),
|
||||
],
|
||||
),
|
||||
)
|
||||
stub.invoke.replies = [bulk_reply]
|
||||
client = await GatewayClient.connect(
|
||||
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
session = await client.open_session()
|
||||
|
||||
entries = [
|
||||
pb.WriteBulkEntry(item_handle=901, user_id=5, value=pb.MxValue(data_type=pb.MX_DATA_TYPE_INTEGER, int32_value=11)),
|
||||
pb.WriteBulkEntry(item_handle=902, user_id=5, value=pb.MxValue(data_type=pb.MX_DATA_TYPE_INTEGER, int32_value=22)),
|
||||
]
|
||||
results = await session.write_bulk(12, entries)
|
||||
|
||||
assert len(results) == 2
|
||||
assert results[0].was_successful is True
|
||||
assert results[1].was_successful is False
|
||||
sent = stub.invoke.requests[0].command
|
||||
assert sent.kind == pb.MX_COMMAND_KIND_WRITE_BULK
|
||||
assert len(sent.write_bulk.entries) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_bulk_forwards_timeout_and_unpacks_cached_flag() -> None:
|
||||
stub = FakeGatewayStub()
|
||||
bulk_reply = pb.MxCommandReply(
|
||||
session_id="session-1",
|
||||
kind=pb.MX_COMMAND_KIND_READ_BULK,
|
||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||
read_bulk=pb.BulkReadReply(
|
||||
results=[
|
||||
pb.BulkReadResult(
|
||||
server_handle=12,
|
||||
tag_address="Area001.Pump001.Speed",
|
||||
item_handle=34,
|
||||
was_successful=True,
|
||||
was_cached=True,
|
||||
value=pb.MxValue(data_type=pb.MX_DATA_TYPE_INTEGER, int32_value=99),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
stub.invoke.replies = [bulk_reply]
|
||||
client = await GatewayClient.connect(
|
||||
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
session = await client.open_session()
|
||||
|
||||
results = await session.read_bulk(12, ["Area001.Pump001.Speed"], timeout_ms=750)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].was_cached is True
|
||||
assert results[0].value.int32_value == 99
|
||||
sent = stub.invoke.requests[0].command
|
||||
assert sent.kind == pb.MX_COMMAND_KIND_READ_BULK
|
||||
assert list(sent.read_bulk.tag_addresses) == ["Area001.Pump001.Speed"]
|
||||
assert sent.read_bulk.timeout_ms == 750
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_events_cancels_underlying_call_when_closed() -> None:
|
||||
stream = FakeStream(
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
"""Regression tests for Client.Python-009: untested public paths.
|
||||
|
||||
Covers `Session.write2`/`add_item2` request construction, the bulk-size limit
|
||||
guard, the ``None``-argument ``TypeError`` guards, the TLS ``ca_file`` read
|
||||
path in `create_channel`, the generic `map_rpc_error` fallthrough, and a
|
||||
happy-path CLI command body driven by a fake stub.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import grpc
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from mxgateway import ClientOptions, GatewayClient
|
||||
from mxgateway.errors import MxGatewayTransportError, map_rpc_error
|
||||
from mxgateway.generated import mxaccess_gateway_pb2 as pb
|
||||
from mxgateway.options import create_channel
|
||||
from mxgateway.session import MAX_BULK_ITEMS, Session
|
||||
|
||||
|
||||
class _FakeUnary:
|
||||
def __init__(self, replies: list[Any]) -> None:
|
||||
self.replies = list(replies)
|
||||
self.requests: list[Any] = []
|
||||
self.metadata: tuple[tuple[str, str], ...] | None = None
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
request: Any,
|
||||
*,
|
||||
metadata: tuple[tuple[str, str], ...],
|
||||
) -> Any:
|
||||
self.requests.append(request)
|
||||
self.metadata = metadata
|
||||
return self.replies.pop(0)
|
||||
|
||||
|
||||
class _FakeGatewayStub:
|
||||
def __init__(self) -> None:
|
||||
self.open_session = _FakeUnary(
|
||||
[
|
||||
pb.OpenSessionReply(
|
||||
session_id="session-1",
|
||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||
),
|
||||
],
|
||||
)
|
||||
self.invoke = _FakeUnary([])
|
||||
self.OpenSession = self.open_session
|
||||
self.Invoke = self.invoke
|
||||
|
||||
|
||||
def _ok_reply(kind: int, **fields: Any) -> pb.MxCommandReply:
|
||||
return pb.MxCommandReply(
|
||||
session_id="session-1",
|
||||
kind=kind,
|
||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||
**fields,
|
||||
)
|
||||
|
||||
|
||||
# --- write2 / add_item2 request construction -------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_item2_sends_item_context_and_returns_handle() -> None:
|
||||
stub = _FakeGatewayStub()
|
||||
stub.invoke.replies = [
|
||||
_ok_reply(pb.MX_COMMAND_KIND_ADD_ITEM2, add_item2=pb.AddItem2Reply(item_handle=77)),
|
||||
]
|
||||
client = await GatewayClient.connect(
|
||||
ClientOptions(endpoint="fake", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
session = await client.open_session()
|
||||
|
||||
item_handle = await session.add_item2(12, "Object.Attribute", "ctx-A")
|
||||
|
||||
assert item_handle == 77
|
||||
command = stub.invoke.requests[0].command
|
||||
assert command.kind == pb.MX_COMMAND_KIND_ADD_ITEM2
|
||||
assert command.add_item2.server_handle == 12
|
||||
assert command.add_item2.item_definition == "Object.Attribute"
|
||||
assert command.add_item2.item_context == "ctx-A"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write2_sends_value_and_timestamp_value() -> None:
|
||||
stub = _FakeGatewayStub()
|
||||
stub.invoke.replies = [_ok_reply(pb.MX_COMMAND_KIND_WRITE2)]
|
||||
client = await GatewayClient.connect(
|
||||
ClientOptions(endpoint="fake", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
session = await client.open_session()
|
||||
|
||||
when = datetime(2025, 4, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
await session.write2(12, 34, 123, when, user_id=5)
|
||||
|
||||
command = stub.invoke.requests[0].command
|
||||
assert command.kind == pb.MX_COMMAND_KIND_WRITE2
|
||||
assert command.write2.server_handle == 12
|
||||
assert command.write2.item_handle == 34
|
||||
assert command.write2.user_id == 5
|
||||
# The integer value is carried as the int32 field of the MxValue oneof.
|
||||
assert command.write2.value.WhichOneof("kind") == "int32_value"
|
||||
assert command.write2.value.int32_value == 123
|
||||
# The timestamp value carries the datetime via the timestamp_value oneof.
|
||||
assert command.write2.timestamp_value.WhichOneof("kind") == "timestamp_value"
|
||||
assert command.write2.timestamp_value.timestamp_value.ToDatetime(
|
||||
tzinfo=timezone.utc,
|
||||
) == when
|
||||
|
||||
|
||||
# --- bulk-size limit + None-argument guards --------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_bulk_rejects_oversized_request() -> None:
|
||||
stub = _FakeGatewayStub()
|
||||
client = await GatewayClient.connect(
|
||||
ClientOptions(endpoint="fake", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
session = await client.open_session()
|
||||
|
||||
oversized = [f"Tag_{i}" for i in range(MAX_BULK_ITEMS + 1)]
|
||||
with pytest.raises(ValueError, match=str(MAX_BULK_ITEMS)):
|
||||
await session.subscribe_bulk(12, oversized)
|
||||
|
||||
# No RPC should have been issued for a rejected request.
|
||||
assert stub.invoke.requests == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_advise_item_bulk_rejects_none_argument() -> None:
|
||||
stub = _FakeGatewayStub()
|
||||
client = await GatewayClient.connect(
|
||||
ClientOptions(endpoint="fake", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
session = await client.open_session()
|
||||
|
||||
with pytest.raises(TypeError, match="item_handles is required"):
|
||||
await session.advise_item_bulk(12, None) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_item_bulk_at_limit_is_allowed() -> None:
|
||||
stub = _FakeGatewayStub()
|
||||
stub.invoke.replies = [
|
||||
_ok_reply(
|
||||
pb.MX_COMMAND_KIND_ADD_ITEM_BULK,
|
||||
add_item_bulk=pb.BulkSubscribeReply(results=[]),
|
||||
),
|
||||
]
|
||||
client = await GatewayClient.connect(
|
||||
ClientOptions(endpoint="fake", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
session = await client.open_session()
|
||||
|
||||
at_limit = [f"Tag_{i}" for i in range(MAX_BULK_ITEMS)]
|
||||
results = await session.add_item_bulk(12, at_limit)
|
||||
|
||||
assert results == []
|
||||
assert len(stub.invoke.requests) == 1
|
||||
assert len(stub.invoke.requests[0].command.add_item_bulk.tag_addresses) == MAX_BULK_ITEMS
|
||||
|
||||
|
||||
# --- TLS ca_file read path -------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_channel_reads_ca_file(tmp_path: Any) -> None:
|
||||
ca_path = tmp_path / "ca.pem"
|
||||
ca_path.write_bytes(b"-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n")
|
||||
|
||||
channel = create_channel(
|
||||
ClientOptions(
|
||||
endpoint="mxgateway.example.local:5001",
|
||||
ca_file=str(ca_path),
|
||||
server_name_override="mxgateway.example.local",
|
||||
),
|
||||
)
|
||||
|
||||
# A secure channel object is returned without raising; the ca_file was read.
|
||||
assert channel is not None
|
||||
await channel.close()
|
||||
|
||||
|
||||
def test_create_channel_missing_ca_file_raises() -> None:
|
||||
with pytest.raises(FileNotFoundError):
|
||||
create_channel(
|
||||
ClientOptions(
|
||||
endpoint="mxgateway.example.local:5001",
|
||||
ca_file="C:/does/not/exist/ca.pem",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# --- map_rpc_error generic fallthrough -------------------------------------
|
||||
|
||||
|
||||
class _FakeRpcError(grpc.RpcError):
|
||||
def __init__(self, code: grpc.StatusCode, details: str) -> None:
|
||||
self._code = code
|
||||
self._details = details
|
||||
|
||||
def code(self) -> grpc.StatusCode:
|
||||
return self._code
|
||||
|
||||
def details(self) -> str:
|
||||
return self._details
|
||||
|
||||
|
||||
def test_map_rpc_error_generic_branch_returns_transport_error() -> None:
|
||||
error = _FakeRpcError(grpc.StatusCode.UNAVAILABLE, "connection refused")
|
||||
|
||||
mapped = map_rpc_error("invoke", error)
|
||||
|
||||
assert type(mapped) is MxGatewayTransportError
|
||||
assert "invoke failed: connection refused" in str(mapped)
|
||||
|
||||
|
||||
def test_map_rpc_error_handles_error_without_code() -> None:
|
||||
mapped = map_rpc_error("invoke", grpc.RpcError())
|
||||
|
||||
assert type(mapped) is MxGatewayTransportError
|
||||
assert "invoke failed:" in str(mapped)
|
||||
|
||||
|
||||
# --- happy-path CLI command body -------------------------------------------
|
||||
|
||||
|
||||
def test_cli_register_happy_path_emits_server_handle(monkeypatch: Any) -> None:
|
||||
"""Drive the `register` CLI command end to end against a fake stub."""
|
||||
|
||||
from mxgateway_cli import commands
|
||||
|
||||
invoke = _FakeUnary(
|
||||
[
|
||||
_ok_reply(
|
||||
pb.MX_COMMAND_KIND_REGISTER,
|
||||
register=pb.RegisterReply(server_handle=99),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
class _Stub:
|
||||
def __init__(self) -> None:
|
||||
self.Invoke = invoke
|
||||
|
||||
async def _fake_connect(kwargs: dict[str, Any]) -> GatewayClient:
|
||||
return await GatewayClient.connect(
|
||||
ClientOptions(endpoint=kwargs["endpoint"], plaintext=True),
|
||||
stub=_Stub(),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(commands, "_connect", _fake_connect)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
commands.main,
|
||||
[
|
||||
"register",
|
||||
"--endpoint",
|
||||
"localhost:5000",
|
||||
"--session-id",
|
||||
"session-1",
|
||||
"--client-name",
|
||||
"pytest-client",
|
||||
"--json",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert json.loads(result.output) == {"serverHandle": 99}
|
||||
assert invoke.requests[0].command.register.client_name == "pytest-client"
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Regression tests for Client.Python-005: streaming hierarchy iteration.
|
||||
|
||||
`GalaxyRepositoryClient.iter_hierarchy` yields objects page by page instead of
|
||||
buffering the entire Galaxy hierarchy in memory, and `discover_hierarchy`
|
||||
remains a convenience wrapper built on top of it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from mxgateway import ClientOptions, GalaxyRepositoryClient
|
||||
from mxgateway.generated import galaxy_repository_pb2 as galaxy_pb
|
||||
|
||||
|
||||
class _FakeUnary:
|
||||
def __init__(self, replies: list[Any]) -> None:
|
||||
self.replies = list(replies)
|
||||
self.requests: list[Any] = []
|
||||
self.metadata: tuple[tuple[str, str], ...] | None = None
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
request: Any,
|
||||
*,
|
||||
metadata: tuple[tuple[str, str], ...],
|
||||
timeout: float | None = None,
|
||||
) -> Any:
|
||||
self.requests.append(request)
|
||||
self.metadata = metadata
|
||||
return self.replies.pop(0)
|
||||
|
||||
|
||||
class _FakeGalaxyStub:
|
||||
def __init__(self, discover_replies: list[Any]) -> None:
|
||||
self.DiscoverHierarchy = _FakeUnary(discover_replies)
|
||||
|
||||
|
||||
def _two_page_replies() -> list[galaxy_pb.DiscoverHierarchyReply]:
|
||||
return [
|
||||
galaxy_pb.DiscoverHierarchyReply(
|
||||
next_page_token="page-2",
|
||||
total_object_count=3,
|
||||
objects=[
|
||||
galaxy_pb.GalaxyObject(gobject_id=1, tag_name="Area_001", is_area=True),
|
||||
galaxy_pb.GalaxyObject(gobject_id=2, tag_name="Pump_001"),
|
||||
],
|
||||
),
|
||||
galaxy_pb.DiscoverHierarchyReply(
|
||||
total_object_count=3,
|
||||
objects=[
|
||||
galaxy_pb.GalaxyObject(gobject_id=3, tag_name="Pump_002"),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_iter_hierarchy_yields_objects_across_pages() -> None:
|
||||
stub = _FakeGalaxyStub(_two_page_replies())
|
||||
client = await GalaxyRepositoryClient.connect(
|
||||
ClientOptions(endpoint="fake", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
|
||||
tags = [obj.tag_name async for obj in client.iter_hierarchy()]
|
||||
|
||||
assert tags == ["Area_001", "Pump_001", "Pump_002"]
|
||||
assert len(stub.DiscoverHierarchy.requests) == 2
|
||||
assert stub.DiscoverHierarchy.requests[0].page_token == ""
|
||||
assert stub.DiscoverHierarchy.requests[1].page_token == "page-2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_iter_hierarchy_is_lazy_and_does_not_prefetch_next_page() -> None:
|
||||
"""Pulling only the first object must not have requested the second page."""
|
||||
|
||||
stub = _FakeGalaxyStub(_two_page_replies())
|
||||
client = await GalaxyRepositoryClient.connect(
|
||||
ClientOptions(endpoint="fake", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
|
||||
iterator = client.iter_hierarchy()
|
||||
first = await iterator.__anext__()
|
||||
|
||||
assert first.tag_name == "Area_001"
|
||||
# Only the first page should have been fetched so far.
|
||||
assert len(stub.DiscoverHierarchy.requests) == 1
|
||||
|
||||
await iterator.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_iter_hierarchy_rejects_repeated_page_token() -> None:
|
||||
stub = _FakeGalaxyStub(
|
||||
[
|
||||
galaxy_pb.DiscoverHierarchyReply(next_page_token="7:1"),
|
||||
galaxy_pb.DiscoverHierarchyReply(next_page_token="7:1"),
|
||||
],
|
||||
)
|
||||
client = await GalaxyRepositoryClient.connect(
|
||||
ClientOptions(endpoint="fake", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
|
||||
with pytest.raises(Exception, match="repeated page token"):
|
||||
async for _ in client.iter_hierarchy():
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discover_hierarchy_still_returns_full_list() -> None:
|
||||
"""The convenience wrapper must keep returning a buffered list."""
|
||||
|
||||
stub = _FakeGalaxyStub(_two_page_replies())
|
||||
client = await GalaxyRepositoryClient.connect(
|
||||
ClientOptions(endpoint="fake", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
|
||||
objects = await client.discover_hierarchy()
|
||||
|
||||
assert isinstance(objects, list)
|
||||
assert [obj.tag_name for obj in objects] == ["Area_001", "Pump_001", "Pump_002"]
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Regression tests for Client.Python low-severity code-review findings.
|
||||
|
||||
Covers Client.Python-006 (concurrent-close idempotency),
|
||||
Client.Python-007 (shared cancelling stream helper),
|
||||
Client.Python-008 (non-finite float / bytes value mapping), and
|
||||
Client.Python-011 (`success == 0` proto3-default ambiguity).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from mxgateway import ClientOptions, GalaxyRepositoryClient, GatewayClient
|
||||
from mxgateway.errors import ensure_mxaccess_success, MxAccessError
|
||||
from mxgateway.generated import mxaccess_gateway_pb2 as pb
|
||||
from mxgateway.values import to_mx_value
|
||||
|
||||
|
||||
# --- Client.Python-006: concurrent close() is idempotent -------------------
|
||||
|
||||
|
||||
class CountingChannel:
|
||||
"""A fake gRPC channel that records and stalls on close()."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.close_calls = 0
|
||||
self._gate = asyncio.Event()
|
||||
|
||||
async def close(self) -> None:
|
||||
self.close_calls += 1
|
||||
# Yield control so a second concurrent close() can interleave at the
|
||||
# exact point a check-then-set guard would have left the window open.
|
||||
await self._gate.wait()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_client_concurrent_close_closes_channel_once() -> None:
|
||||
channel = CountingChannel()
|
||||
client = GatewayClient(
|
||||
options=ClientOptions(endpoint="fake", plaintext=True),
|
||||
stub=object(),
|
||||
channel=channel, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
first = asyncio.create_task(client.close())
|
||||
second = asyncio.create_task(client.close())
|
||||
await asyncio.sleep(0) # let both coroutines pass the guard if racy
|
||||
|
||||
channel._gate.set()
|
||||
await asyncio.gather(first, second)
|
||||
|
||||
assert channel.close_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_galaxy_client_concurrent_close_closes_channel_once() -> None:
|
||||
channel = CountingChannel()
|
||||
client = GalaxyRepositoryClient(
|
||||
options=ClientOptions(endpoint="fake", plaintext=True),
|
||||
stub=object(),
|
||||
channel=channel, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
first = asyncio.create_task(client.close())
|
||||
second = asyncio.create_task(client.close())
|
||||
await asyncio.sleep(0)
|
||||
|
||||
channel._gate.set()
|
||||
await asyncio.gather(first, second)
|
||||
|
||||
assert channel.close_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_concurrent_close_sends_one_close_session_rpc() -> None:
|
||||
gate = asyncio.Event()
|
||||
rpc_calls = 0
|
||||
|
||||
class StallingClient:
|
||||
async def close_session_raw(self, request: Any) -> pb.CloseSessionReply:
|
||||
nonlocal rpc_calls
|
||||
rpc_calls += 1
|
||||
await gate.wait()
|
||||
return pb.CloseSessionReply(
|
||||
session_id=request.session_id,
|
||||
final_state=pb.SESSION_STATE_CLOSED,
|
||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||
)
|
||||
|
||||
from mxgateway.session import Session
|
||||
|
||||
session = Session(client=StallingClient(), session_id="session-1") # type: ignore[arg-type]
|
||||
|
||||
first = asyncio.create_task(session.close())
|
||||
second = asyncio.create_task(session.close())
|
||||
await asyncio.sleep(0)
|
||||
|
||||
gate.set()
|
||||
await asyncio.gather(first, second)
|
||||
|
||||
assert rpc_calls == 1
|
||||
|
||||
|
||||
# --- Client.Python-007: shared cancelling stream helper --------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_stream_iterator_cancels_call_on_task_cancellation() -> None:
|
||||
"""A cancelled gateway stream iterator must explicitly cancel the call."""
|
||||
|
||||
class CancellableStream:
|
||||
def __init__(self) -> None:
|
||||
self.cancelled = False
|
||||
|
||||
def __aiter__(self) -> "CancellableStream":
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> pb.MxEvent:
|
||||
await asyncio.Event().wait() # blocks until cancelled
|
||||
raise AssertionError("unreachable")
|
||||
|
||||
def cancel(self) -> None:
|
||||
self.cancelled = True
|
||||
|
||||
from mxgateway.client import _canceling_iterator
|
||||
|
||||
stream = CancellableStream()
|
||||
iterator = _canceling_iterator(stream, "stream events")
|
||||
|
||||
task = asyncio.create_task(anext(iterator))
|
||||
await asyncio.sleep(0)
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
# aclose() unwinds the generator's finally block.
|
||||
await iterator.aclose()
|
||||
|
||||
assert stream.cancelled
|
||||
|
||||
|
||||
# --- Client.Python-008: non-finite float and bytes value mapping -----------
|
||||
|
||||
|
||||
def test_to_mx_value_rejects_nan() -> None:
|
||||
with pytest.raises(ValueError, match="finite"):
|
||||
to_mx_value(float("nan"))
|
||||
|
||||
|
||||
def test_to_mx_value_rejects_positive_infinity() -> None:
|
||||
with pytest.raises(ValueError, match="finite"):
|
||||
to_mx_value(float("inf"))
|
||||
|
||||
|
||||
def test_to_mx_value_rejects_negative_infinity() -> None:
|
||||
with pytest.raises(ValueError, match="finite"):
|
||||
to_mx_value(float("-inf"))
|
||||
|
||||
|
||||
def test_to_mx_value_accepts_finite_float() -> None:
|
||||
assert to_mx_value(3.5).double_value == 3.5
|
||||
|
||||
|
||||
def test_to_mx_value_rejects_non_finite_float_in_sequence() -> None:
|
||||
with pytest.raises(ValueError, match="finite"):
|
||||
to_mx_value([1.0, math.inf])
|
||||
|
||||
|
||||
# --- Client.Python-011: success == 0 proto3-default ambiguity --------------
|
||||
|
||||
|
||||
def test_ensure_mxaccess_success_ignores_unpopulated_status_entry() -> None:
|
||||
"""A status entry left at proto3 defaults is not a real MXAccess failure.
|
||||
|
||||
The gateway emits such a placeholder for a null MXSTATUS_PROXY COM entry
|
||||
(``MxStatusProxyConverter.ConvertMany``): ``success`` stays 0 but the
|
||||
entry carries no failure category. It must not raise ``MxAccessError``.
|
||||
"""
|
||||
|
||||
reply = pb.MxCommandReply(
|
||||
session_id="session-1",
|
||||
kind=pb.MX_COMMAND_KIND_SUBSCRIBE_BULK,
|
||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||
statuses=[
|
||||
pb.MxStatusProxy(), # all-default: success == 0, category UNSPECIFIED
|
||||
pb.MxStatusProxy( # the gateway's null-entry placeholder
|
||||
category=pb.MX_STATUS_CATEGORY_UNKNOWN,
|
||||
detected_by=pb.MX_STATUS_SOURCE_UNKNOWN,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
assert ensure_mxaccess_success("subscribe bulk", reply) is reply
|
||||
|
||||
|
||||
def test_ensure_mxaccess_success_raises_on_populated_failure_status() -> None:
|
||||
"""A populated failure status (success == 0 with a failure category) raises."""
|
||||
|
||||
reply = pb.MxCommandReply(
|
||||
session_id="session-1",
|
||||
kind=pb.MX_COMMAND_KIND_WRITE,
|
||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||
statuses=[
|
||||
pb.MxStatusProxy(
|
||||
success=0,
|
||||
category=pb.MX_STATUS_CATEGORY_COMMUNICATION_ERROR,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(MxAccessError):
|
||||
ensure_mxaccess_success("write", reply)
|
||||
|
||||
|
||||
def test_ensure_mxaccess_success_passes_when_status_reports_success() -> None:
|
||||
reply = pb.MxCommandReply(
|
||||
session_id="session-1",
|
||||
kind=pb.MX_COMMAND_KIND_WRITE,
|
||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||
statuses=[
|
||||
pb.MxStatusProxy(success=1, category=pb.MX_STATUS_CATEGORY_OK),
|
||||
],
|
||||
)
|
||||
|
||||
assert ensure_mxaccess_success("write", reply) is reply
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Packaging smoke test.
|
||||
|
||||
Guards against ``pyproject.toml`` regressions (see Client.Python-018) that
|
||||
break ``pip wheel`` / ``pip install -e`` while leaving the in-tree
|
||||
``pytest`` suite green via ``[tool.pytest.ini_options] pythonpath = ["src"]``.
|
||||
|
||||
The test invokes ``python -m pip wheel . --no-deps`` against the package
|
||||
root and asserts a wheel file is produced. Any future PEP 639 / SPDX
|
||||
violation (or any other ``setuptools.build_meta`` configuration error)
|
||||
will be caught here at test time rather than at first install on a clean
|
||||
machine.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
_PACKAGE_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def test_pip_wheel_build_succeeds(tmp_path: pathlib.Path) -> None:
|
||||
"""``pip wheel .`` against the package root produces a wheel.
|
||||
|
||||
This exercises ``setuptools.build_meta`` end-to-end — the same path
|
||||
used by ``pip install -e .`` — and would have caught
|
||||
Client.Python-018 at commit time.
|
||||
"""
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"wheel",
|
||||
".",
|
||||
"--no-deps",
|
||||
"--wheel-dir",
|
||||
str(tmp_path),
|
||||
],
|
||||
cwd=str(_PACKAGE_ROOT),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0, (
|
||||
f"pip wheel failed (exit {result.returncode}):\n"
|
||||
f"--- stdout ---\n{result.stdout}\n"
|
||||
f"--- stderr ---\n{result.stderr}"
|
||||
)
|
||||
wheels = list(tmp_path.glob("mxaccess_gateway_client-*.whl"))
|
||||
assert wheels, (
|
||||
"expected a mxaccess_gateway_client wheel in "
|
||||
f"{tmp_path}; got {list(tmp_path.iterdir())}"
|
||||
)
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Regression tests for Client.Python-003: stream timeout-kwarg fallback.
|
||||
|
||||
`stream_events_raw` and `stream_alarms` must tolerate a fake/older stub
|
||||
that does not accept a ``timeout`` keyword argument, matching the fallback
|
||||
already present in `galaxy.watch_deploy_events` and the unary `_unary` helper.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from mxgateway import ClientOptions, GatewayClient
|
||||
from mxgateway.generated import mxaccess_gateway_pb2 as pb
|
||||
|
||||
|
||||
class _NoTimeoutStream:
|
||||
"""Sync-callable unary-stream fake that rejects a ``timeout`` kwarg."""
|
||||
|
||||
def __init__(self, replies: list[Any]) -> None:
|
||||
self._replies = list(replies)
|
||||
self.requests: list[Any] = []
|
||||
self.metadata: tuple[tuple[str, str], ...] | None = None
|
||||
self.cancelled = False
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
request: Any,
|
||||
*,
|
||||
metadata: tuple[tuple[str, str], ...],
|
||||
) -> "_NoTimeoutStream":
|
||||
self.requests.append(request)
|
||||
self.metadata = metadata
|
||||
return self
|
||||
|
||||
def __aiter__(self) -> "_NoTimeoutStream":
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> Any:
|
||||
if not self._replies:
|
||||
raise StopAsyncIteration
|
||||
return self._replies.pop(0)
|
||||
|
||||
def cancel(self) -> None:
|
||||
self.cancelled = True
|
||||
|
||||
|
||||
class _NoTimeoutStubStreamEvents:
|
||||
def __init__(self, stream: _NoTimeoutStream) -> None:
|
||||
self.StreamEvents = stream
|
||||
|
||||
|
||||
class _NoTimeoutStubStreamAlarms:
|
||||
def __init__(self, stream: _NoTimeoutStream) -> None:
|
||||
self.StreamAlarms = stream
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_events_raw_falls_back_when_stub_rejects_timeout() -> None:
|
||||
stream = _NoTimeoutStream(
|
||||
[pb.MxEvent(session_id="session-1", worker_sequence=1)],
|
||||
)
|
||||
client = await GatewayClient.connect(
|
||||
ClientOptions(endpoint="fake", plaintext=True, stream_timeout=5.0),
|
||||
stub=_NoTimeoutStubStreamEvents(stream),
|
||||
)
|
||||
|
||||
received = [
|
||||
event
|
||||
async for event in client.stream_events_raw(
|
||||
pb.StreamEventsRequest(session_id="session-1"),
|
||||
)
|
||||
]
|
||||
|
||||
assert len(received) == 1
|
||||
assert received[0].worker_sequence == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_alarms_falls_back_when_stub_rejects_timeout() -> None:
|
||||
stream = _NoTimeoutStream(
|
||||
[
|
||||
pb.AlarmFeedMessage(
|
||||
active_alarm=pb.ActiveAlarmSnapshot(
|
||||
alarm_full_reference="Tank01.Level.HiHi",
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
client = await GatewayClient.connect(
|
||||
ClientOptions(endpoint="fake", plaintext=True, stream_timeout=5.0),
|
||||
stub=_NoTimeoutStubStreamAlarms(stream),
|
||||
)
|
||||
|
||||
received = [
|
||||
message
|
||||
async for message in client.stream_alarms(
|
||||
pb.StreamAlarmsRequest(),
|
||||
)
|
||||
]
|
||||
|
||||
assert len(received) == 1
|
||||
assert received[0].active_alarm.alarm_full_reference == "Tank01.Level.HiHi"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_events_raw_still_passes_timeout_to_capable_stub() -> None:
|
||||
"""A stub that accepts ``timeout`` must still receive the configured value."""
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
class _CapableStream(_NoTimeoutStream):
|
||||
def __call__( # type: ignore[override]
|
||||
self,
|
||||
request: Any,
|
||||
*,
|
||||
metadata: tuple[tuple[str, str], ...],
|
||||
timeout: float | None = None,
|
||||
) -> "_CapableStream":
|
||||
captured["timeout"] = timeout
|
||||
return super().__call__(request, metadata=metadata)
|
||||
|
||||
stream = _CapableStream([pb.MxEvent(session_id="session-1", worker_sequence=9)])
|
||||
client = await GatewayClient.connect(
|
||||
ClientOptions(endpoint="fake", plaintext=True, stream_timeout=7.5),
|
||||
stub=_NoTimeoutStubStreamEvents(stream),
|
||||
)
|
||||
|
||||
received = [
|
||||
event
|
||||
async for event in client.stream_events_raw(
|
||||
pb.StreamEventsRequest(session_id="session-1"),
|
||||
)
|
||||
]
|
||||
|
||||
assert len(received) == 1
|
||||
assert captured["timeout"] == 7.5
|
||||
@@ -99,6 +99,16 @@ preserving the raw message for parity diagnostics. Command replies whose
|
||||
protocol status is not `PROTOCOL_STATUS_CODE_OK` become `Error::Command` and
|
||||
retain the raw `MxCommandReply`.
|
||||
|
||||
The session also exposes the full bulk family —
|
||||
`add_item_bulk`, `advise_item_bulk`, `remove_item_bulk`, `un_advise_item_bulk`,
|
||||
`subscribe_bulk`, `unsubscribe_bulk`, `write_bulk`, `write2_bulk`,
|
||||
`write_secured_bulk`, `write_secured2_bulk`, and `read_bulk`. Each carries a
|
||||
`Vec` of entries in one round-trip and returns one result per entry; per-entry
|
||||
MXAccess failures populate `was_successful = false` and never raise. `read_bulk`
|
||||
takes a per-tag timeout (`u32` milliseconds, `0` = worker default) and returns
|
||||
the cached `OnDataChange` value when the tag is already advised (`was_cached =
|
||||
true`) without touching the existing subscription.
|
||||
|
||||
## Galaxy Repository browse
|
||||
|
||||
The Galaxy Repository service exposes a read-only browse over the AVEVA System
|
||||
|
||||
@@ -11,28 +11,34 @@ generated contract inputs.
|
||||
|
||||
## Crate Layout
|
||||
|
||||
Recommended layout:
|
||||
Actual layout — the `mxgateway-client` library crate is the workspace root,
|
||||
with the `mxgw` test CLI as a workspace member:
|
||||
|
||||
```text
|
||||
clients/rust/
|
||||
clients/rust/ # `mxgateway-client` library crate (workspace root)
|
||||
Cargo.toml
|
||||
build.rs
|
||||
src/
|
||||
lib.rs
|
||||
client.rs
|
||||
session.rs
|
||||
galaxy.rs
|
||||
options.rs
|
||||
auth.rs
|
||||
value.rs
|
||||
version.rs
|
||||
error.rs
|
||||
generated.rs
|
||||
crates/
|
||||
mxgateway-client/
|
||||
src/lib.rs
|
||||
src/client.rs
|
||||
src/session.rs
|
||||
src/options.rs
|
||||
src/auth.rs
|
||||
src/value.rs
|
||||
src/error.rs
|
||||
src/generated/
|
||||
mxgw-cli/
|
||||
mxgw-cli/ # `mxgw` test CLI (workspace member)
|
||||
Cargo.toml
|
||||
src/main.rs
|
||||
tests/
|
||||
client_behavior.rs
|
||||
proto_fixtures.rs
|
||||
```
|
||||
|
||||
Expected dependencies:
|
||||
Dependencies:
|
||||
|
||||
- `tonic`
|
||||
- `prost`
|
||||
@@ -43,7 +49,6 @@ Expected dependencies:
|
||||
- `clap`
|
||||
- `serde`
|
||||
- `serde_json`
|
||||
- `tracing`
|
||||
|
||||
## Library API
|
||||
|
||||
@@ -88,11 +93,39 @@ impl Session {
|
||||
pub async fn subscribe_bulk(&self, server_handle: i32, tag_addresses: Vec<String>) -> Result<Vec<SubscribeResult>, Error>;
|
||||
pub async fn unsubscribe_bulk(&self, server_handle: i32, item_handles: Vec<i32>) -> Result<Vec<SubscribeResult>, Error>;
|
||||
pub async fn write(&self, server_handle: i32, item_handle: i32, value: MxValue, user_id: i32) -> Result<(), Error>;
|
||||
pub async fn write_bulk(&self, server_handle: i32, entries: Vec<WriteBulkEntry>) -> Result<Vec<BulkWriteResult>, Error>;
|
||||
pub async fn write2_bulk(&self, server_handle: i32, entries: Vec<Write2BulkEntry>) -> Result<Vec<BulkWriteResult>, Error>;
|
||||
pub async fn write_secured_bulk(&self, server_handle: i32, entries: Vec<WriteSecuredBulkEntry>) -> Result<Vec<BulkWriteResult>, Error>;
|
||||
pub async fn write_secured2_bulk(&self, server_handle: i32, entries: Vec<WriteSecured2BulkEntry>) -> Result<Vec<BulkWriteResult>, Error>;
|
||||
pub async fn read_bulk<S: AsRef<str>>(&self, server_handle: i32, tag_addresses: &[S], timeout_ms: u32) -> Result<Vec<BulkReadResult>, Error>;
|
||||
pub async fn events(&self) -> Result<impl Stream<Item = Result<MxEvent, Error>>, Error>;
|
||||
pub async fn close(&self) -> Result<(), Error>;
|
||||
}
|
||||
```
|
||||
|
||||
The four bulk-write helpers (`write_bulk`, `write2_bulk`, `write_secured_bulk`,
|
||||
`write_secured2_bulk`) and `read_bulk` mirror the worker's bulk command shapes
|
||||
in `mxaccess_gateway.proto` and use the same correlation-id discipline as the
|
||||
unary helpers — `next_correlation_id` is part of the public SDK surface,
|
||||
re-exported at the crate root (`mxgateway_client::next_correlation_id`), so
|
||||
that consumers constructing raw `MxCommandRequest`/`CloseSessionRequest`
|
||||
payloads outside the `Session` helpers (notably the `mxgw` test CLI's `ping`
|
||||
and `close-session` subcommands) share the same id generation. The returned
|
||||
id is documented as an opaque token with three guaranteed properties
|
||||
(embeds the caller's label, unique within a process, carries no secret);
|
||||
its textual format is intentionally *not* part of the contract.
|
||||
|
||||
The per-entry fields that the matching MXAccess COM calls accept once per
|
||||
batch — `user_id` (`WriteBulkEntry`/`Write2BulkEntry`), `timestamp_value`
|
||||
(`Write2BulkEntry`/`WriteSecured2BulkEntry`), and `current_user_id` /
|
||||
`verifier_user_id` (`WriteSecuredBulkEntry`/`WriteSecured2BulkEntry`) — live
|
||||
on the entry structs themselves rather than as trailing positional arguments
|
||||
on the helper, matching the protobuf shapes in
|
||||
`mxaccess_gateway.proto` (`WriteBulkCommand` / `Write2BulkCommand` /
|
||||
`WriteSecuredBulkCommand` / `WriteSecured2BulkCommand`). `read_bulk` is
|
||||
generic over `AsRef<str>` so callers can pass `&[String]` or `&[&str]`
|
||||
without cloning at the call site.
|
||||
|
||||
## Authentication
|
||||
|
||||
Use a `tonic` interceptor or request extension layer to add:
|
||||
@@ -127,19 +160,29 @@ Use `thiserror`:
|
||||
|
||||
```rust
|
||||
pub enum Error {
|
||||
InvalidEndpoint { endpoint: String, detail: String },
|
||||
InvalidArgument { name: String, detail: String },
|
||||
Transport(tonic::transport::Error),
|
||||
Status(tonic::Status),
|
||||
Authentication(String),
|
||||
Authorization(String),
|
||||
Session(SessionError),
|
||||
Worker(WorkerError),
|
||||
Command(CommandError),
|
||||
MxAccess(MxAccessError),
|
||||
Timeout,
|
||||
Cancelled,
|
||||
Authentication { message: String, status: Box<tonic::Status> },
|
||||
Authorization { message: String, status: Box<tonic::Status> },
|
||||
Timeout { message: String, status: Box<tonic::Status> },
|
||||
Cancelled { message: String, status: Box<tonic::Status> },
|
||||
Unavailable { message: String, status: Box<tonic::Status> },
|
||||
Status(Box<tonic::Status>),
|
||||
Command(Box<CommandError>),
|
||||
ProtocolStatus { operation: &'static str, code: ProtocolStatusCode, message: String },
|
||||
MalformedReply { detail: String },
|
||||
}
|
||||
```
|
||||
|
||||
`Unavailable` classifies the transient `Code::Unavailable` /
|
||||
`Code::ResourceExhausted` statuses so callers can decide whether to retry
|
||||
without unwrapping the raw status. `MalformedReply` surfaces OK replies
|
||||
whose payload does not carry the data the command contract requires (for
|
||||
example, an `AddItem` reply missing the item handle, or a `WriteBulk` reply
|
||||
carrying the wrong payload arm). `InvalidEndpoint` is returned when the
|
||||
endpoint URL fails to parse or its TLS material cannot be loaded.
|
||||
|
||||
Preserve raw command replies in `CommandError` where applicable.
|
||||
|
||||
## Test CLI
|
||||
@@ -148,13 +191,32 @@ Binary: `mxgw`.
|
||||
|
||||
Use `clap` derive.
|
||||
|
||||
Commands:
|
||||
Commands (see `clients/rust/README.md` for full argument lists):
|
||||
|
||||
```text
|
||||
mxgw version
|
||||
mxgw smoke --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --item TestChildObject.TestInt
|
||||
mxgw ping
|
||||
mxgw open-session
|
||||
mxgw close-session --session-id <id>
|
||||
mxgw register --session-id <id> --client-name <name>
|
||||
mxgw add-item --session-id <id> --server-handle <h> --item <tag>
|
||||
mxgw advise --session-id <id> --server-handle <h> --item-handle <h>
|
||||
mxgw subscribe-bulk --session-id <id> --server-handle <h> --items <a,b,c>
|
||||
mxgw unsubscribe-bulk --session-id <id> --server-handle <h> --item-handles <1,2,3>
|
||||
mxgw read-bulk --session-id <id> --server-handle <h> --items <a,b,c> --timeout-ms 1500
|
||||
mxgw write --session-id <id> --server-handle 1 --item-handle 1 --value-type int32 --value 123
|
||||
mxgw write2 --session-id <id> --server-handle 1 --item-handle 1 --value-type int32 --value 123 --timestamp <rfc3339>
|
||||
mxgw write-bulk --session-id <id> --server-handle <h> --item-handles <1,2> --value-type int32 --values <1,2>
|
||||
mxgw write2-bulk --session-id <id> --server-handle <h> --item-handles <1,2> --value-type int32 --values <1,2> --timestamp <rfc3339>
|
||||
mxgw write-secured-bulk --session-id <id> --server-handle <h> --item-handles <1,2> --value-type int32 --values <1,2>
|
||||
mxgw write-secured2-bulk --session-id <id> --server-handle <h> --item-handles <1,2> --value-type int32 --values <1,2> --timestamp <rfc3339>
|
||||
mxgw stream-events --session-id <id> --json
|
||||
mxgw write --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123
|
||||
mxgw bench-read-bulk --duration-seconds 30 --bulk-size 6 --json
|
||||
mxgw smoke --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --item TestChildObject.TestInt
|
||||
mxgw galaxy test-connection
|
||||
mxgw galaxy last-deploy-time
|
||||
mxgw galaxy discover-hierarchy
|
||||
mxgw galaxy watch [--last-seen-deploy-time <rfc3339>] [--max-events N]
|
||||
```
|
||||
|
||||
JSON output should use `serde_json`.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,8 +16,9 @@ use crate::auth::AuthInterceptor;
|
||||
use crate::error::{ensure_command_success, ensure_protocol_success, Error};
|
||||
use crate::generated::mxaccess_gateway::v1::mx_access_gateway_client::MxAccessGatewayClient;
|
||||
use crate::generated::mxaccess_gateway::v1::{
|
||||
CloseSessionReply, CloseSessionRequest, MxCommandReply, MxCommandRequest, MxEvent,
|
||||
OpenSessionReply, OpenSessionRequest, StreamEventsRequest,
|
||||
AcknowledgeAlarmReply, AcknowledgeAlarmRequest, AlarmFeedMessage, CloseSessionReply,
|
||||
CloseSessionRequest, MxCommandReply, MxCommandRequest, MxEvent, OpenSessionReply,
|
||||
OpenSessionRequest, StreamAlarmsRequest, StreamEventsRequest,
|
||||
};
|
||||
use crate::options::ClientOptions;
|
||||
use crate::session::Session;
|
||||
@@ -32,6 +33,13 @@ pub type RawGatewayClient = MxAccessGatewayClient<InterceptedService<Channel, Au
|
||||
pub type EventStream =
|
||||
std::pin::Pin<Box<dyn futures_core::Stream<Item = Result<MxEvent, Error>> + Send + 'static>>;
|
||||
|
||||
/// Pinned, boxed [`AlarmFeedMessage`] stream returned by
|
||||
/// [`GatewayClient::stream_alarms`]. Errors are pre-mapped from
|
||||
/// `tonic::Status` to [`Error`]; dropping the stream cancels the call.
|
||||
pub type AlarmFeedStream = std::pin::Pin<
|
||||
Box<dyn futures_core::Stream<Item = Result<AlarmFeedMessage, Error>> + Send + 'static>,
|
||||
>;
|
||||
|
||||
/// Thin async wrapper around the generated gateway client.
|
||||
///
|
||||
/// The wrapper is `Clone`: every clone shares the underlying tonic channel
|
||||
@@ -194,6 +202,59 @@ impl GatewayClient {
|
||||
Ok(Box::pin(stream))
|
||||
}
|
||||
|
||||
/// Acknowledge an active MXAccess alarm condition through the gateway.
|
||||
///
|
||||
/// The gateway authenticates the request against the API key's
|
||||
/// `invoke:alarm-ack` scope and forwards the acknowledge to the worker's
|
||||
/// MXAccess session; the resulting native MxStatus is returned in the
|
||||
/// reply. Acks are idempotent at the MxAccess layer.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::ProtocolStatus`] when the gateway accepts the call but
|
||||
/// reports a non-OK protocol status, plus any of the [`Error`] variants
|
||||
/// produced by transport failures.
|
||||
pub async fn acknowledge_alarm(
|
||||
&self,
|
||||
request: AcknowledgeAlarmRequest,
|
||||
) -> Result<AcknowledgeAlarmReply, Error> {
|
||||
let mut client = self.inner.clone();
|
||||
let response = client
|
||||
.acknowledge_alarm(self.unary_request(request))
|
||||
.await?;
|
||||
let reply = response.into_inner();
|
||||
ensure_protocol_success("acknowledge alarm", reply.protocol_status.as_ref())?;
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
/// Attach to the gateway's central `StreamAlarms` feed.
|
||||
///
|
||||
/// The returned [`AlarmFeedStream`] opens with one [`AlarmFeedMessage`]
|
||||
/// per currently-active alarm (the ConditionRefresh snapshot), then a
|
||||
/// single `snapshot_complete`, then a `transition` for every subsequent
|
||||
/// raise / acknowledge / clear. It is served by the gateway's always-on
|
||||
/// alarm monitor — no worker session is opened — so any number of clients
|
||||
/// may attach. Dropping the stream cancels the gRPC call cooperatively.
|
||||
/// Optional alarm-reference prefix scoping (`request.alarm_filter_prefix`)
|
||||
/// limits the stream to a sub-tree.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns the `tonic::Status` mapped through [`Error::from`] if the
|
||||
/// server rejects the request.
|
||||
pub async fn stream_alarms(
|
||||
&self,
|
||||
request: StreamAlarmsRequest,
|
||||
) -> Result<AlarmFeedStream, Error> {
|
||||
let mut client = self.inner.clone();
|
||||
let response = client.stream_alarms(self.stream_request(request)).await?;
|
||||
let stream = futures_util::StreamExt::map(response.into_inner(), |result| {
|
||||
result.map_err(Error::from)
|
||||
});
|
||||
|
||||
Ok(Box::pin(stream))
|
||||
}
|
||||
|
||||
fn unary_request<T>(&self, message: T) -> Request<T> {
|
||||
let mut request = Request::new(message);
|
||||
request.set_timeout(self.call_timeout);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! Error types surfaced by the Rust client.
|
||||
//!
|
||||
//! [`Error`] is the umbrella enum returned by every async wrapper. It
|
||||
//! classifies `tonic::Status` codes (auth, timeout, cancellation) and folds
|
||||
//! gateway protocol failures and command-level rejections into structured
|
||||
//! variants. Credentials embedded in status messages are scrubbed before the
|
||||
//! message reaches a caller.
|
||||
//! classifies `tonic::Status` codes (auth, timeout, cancellation, transient
|
||||
//! unavailability) and folds gateway protocol failures and command-level
|
||||
//! rejections into structured variants. Credentials embedded in status
|
||||
//! messages are scrubbed before the message reaches a caller.
|
||||
|
||||
use thiserror::Error as ThisError;
|
||||
use tonic::Code;
|
||||
@@ -85,6 +85,17 @@ pub enum Error {
|
||||
status: Box<tonic::Status>,
|
||||
},
|
||||
|
||||
/// Server returned `Unavailable` or `ResourceExhausted` — a transient
|
||||
/// failure (gateway restart, overload) that a caller may reasonably retry.
|
||||
#[error("gateway temporarily unavailable: {message}")]
|
||||
Unavailable {
|
||||
/// Redacted server-supplied detail message.
|
||||
message: String,
|
||||
/// Original `tonic::Status`.
|
||||
#[source]
|
||||
status: Box<tonic::Status>,
|
||||
},
|
||||
|
||||
/// Any other `tonic::Status` that did not match a more specific variant.
|
||||
#[error("gateway status error: {0}")]
|
||||
Status(Box<tonic::Status>),
|
||||
@@ -106,6 +117,15 @@ pub enum Error {
|
||||
/// Detail message from the server.
|
||||
message: String,
|
||||
},
|
||||
|
||||
/// The gateway returned an OK reply whose payload did not carry the data
|
||||
/// the command contract requires (for example, an `AddItem` reply with no
|
||||
/// item handle and no `return_value`).
|
||||
#[error("malformed gateway reply: {detail}")]
|
||||
MalformedReply {
|
||||
/// Human-readable description of what the reply was missing.
|
||||
detail: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Wrapper around an [`MxCommandReply`] whose `protocol_status` reported a
|
||||
@@ -174,6 +194,10 @@ impl From<tonic::Status> for Error {
|
||||
message,
|
||||
status: Box::new(status),
|
||||
},
|
||||
Code::Unavailable | Code::ResourceExhausted => Self::Unavailable {
|
||||
message,
|
||||
status: Box::new(status),
|
||||
},
|
||||
_ => Self::Status(Box::new(status)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,7 +279,7 @@ mod tests {
|
||||
_request: Request<GetLastDeployTimeRequest>,
|
||||
) -> Result<Response<GetLastDeployTimeReply>, Status> {
|
||||
let present = *self.state.present.lock().unwrap();
|
||||
let time = self.state.last_deploy.lock().unwrap().clone();
|
||||
let time = *self.state.last_deploy.lock().unwrap();
|
||||
Ok(Response::new(GetLastDeployTimeReply {
|
||||
present,
|
||||
time_of_last_deploy: time,
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod mxaccess_gateway {
|
||||
/// gateway to language clients.
|
||||
pub mod v1 {
|
||||
#![allow(clippy::large_enum_variant)]
|
||||
#![allow(clippy::doc_lazy_continuation)]
|
||||
|
||||
tonic::include_proto!("mxaccess_gateway.v1");
|
||||
}
|
||||
@@ -25,6 +26,7 @@ pub mod mxaccess_worker {
|
||||
/// the named-pipe transport between gateway and worker.
|
||||
pub mod v1 {
|
||||
#![allow(clippy::large_enum_variant)]
|
||||
#![allow(clippy::doc_lazy_continuation)]
|
||||
|
||||
tonic::include_proto!("mxaccess_worker.v1");
|
||||
}
|
||||
@@ -36,6 +38,7 @@ pub mod galaxy_repository {
|
||||
/// discovery and deploy-event watch RPCs.
|
||||
pub mod v1 {
|
||||
#![allow(clippy::large_enum_variant)]
|
||||
#![allow(clippy::doc_lazy_continuation)]
|
||||
|
||||
tonic::include_proto!("galaxy_repository.v1");
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ pub use galaxy::{DeployEventStream, GalaxyClient};
|
||||
#[doc(inline)]
|
||||
pub use options::ClientOptions;
|
||||
#[doc(inline)]
|
||||
pub use session::Session;
|
||||
pub use session::{next_correlation_id, Session};
|
||||
#[doc(inline)]
|
||||
pub use value::{MxArrayProjection, MxArrayValue, MxStatus, MxValue, MxValueProjection};
|
||||
#[doc(inline)]
|
||||
|
||||
@@ -95,6 +95,8 @@ impl ClientOptions {
|
||||
self
|
||||
}
|
||||
|
||||
/// Maximum encoded/decoded gRPC message size, in bytes, the transport
|
||||
/// will accept. Defaults to 16 MiB.
|
||||
pub fn with_max_grpc_message_bytes(mut self, max_grpc_message_bytes: usize) -> Self {
|
||||
self.max_grpc_message_bytes = max_grpc_message_bytes;
|
||||
self
|
||||
@@ -140,6 +142,7 @@ impl ClientOptions {
|
||||
self.stream_timeout
|
||||
}
|
||||
|
||||
/// Configured maximum gRPC message size in bytes.
|
||||
pub fn max_grpc_message_bytes(&self) -> usize {
|
||||
self.max_grpc_message_bytes
|
||||
}
|
||||
|
||||
+278
-47
@@ -8,21 +8,55 @@
|
||||
//! Bulk commands enforce a 1000-item cap before contacting the worker, in
|
||||
//! line with the gateway's documented `MAX_BULK_ITEMS`.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use crate::client::{EventStream, GatewayClient};
|
||||
use crate::error::{ensure_protocol_success, Error};
|
||||
use crate::generated::mxaccess_gateway::v1::mx_command::Payload;
|
||||
use crate::generated::mxaccess_gateway::v1::mx_command_reply;
|
||||
use crate::generated::mxaccess_gateway::v1::{
|
||||
AddItem2Command, AddItemBulkCommand, AddItemCommand, AdviseCommand, AdviseItemBulkCommand,
|
||||
CloseSessionRequest, MxCommand, MxCommandKind, MxCommandReply, MxCommandRequest,
|
||||
MxValue as ProtoMxValue, OpenSessionRequest, RegisterCommand, RemoveItemBulkCommand,
|
||||
RemoveItemCommand, StreamEventsRequest, SubscribeBulkCommand, SubscribeResult, UnAdviseCommand,
|
||||
UnAdviseItemBulkCommand, UnsubscribeBulkCommand, Write2Command, WriteCommand,
|
||||
BulkReadResult, BulkWriteResult, CloseSessionRequest, MxCommand, MxCommandKind, MxCommandReply,
|
||||
MxCommandRequest, MxValue as ProtoMxValue, OpenSessionRequest, ReadBulkCommand,
|
||||
RegisterCommand, RemoveItemBulkCommand, RemoveItemCommand, StreamEventsRequest,
|
||||
SubscribeBulkCommand, SubscribeResult, UnAdviseCommand, UnAdviseItemBulkCommand,
|
||||
UnsubscribeBulkCommand, Write2BulkCommand, Write2BulkEntry, Write2Command, WriteBulkCommand,
|
||||
WriteBulkEntry, WriteCommand, WriteSecured2BulkCommand, WriteSecured2BulkEntry,
|
||||
WriteSecuredBulkCommand, WriteSecuredBulkEntry,
|
||||
};
|
||||
use crate::value::MxValue;
|
||||
|
||||
const MAX_BULK_ITEMS: usize = 1_000;
|
||||
|
||||
/// Process-wide monotonic counter that keeps client correlation ids unique.
|
||||
static CORRELATION_SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Build a unique `client_correlation_id` for a request so concurrent or
|
||||
/// repeated calls of the same command kind can be told apart in gateway logs.
|
||||
///
|
||||
/// Exposed so consumers that construct raw [`MxCommandRequest`] /
|
||||
/// [`CloseSessionRequest`] payloads outside the `Session` helpers — notably
|
||||
/// the `mxgw` test CLI — share the same correlation-id discipline as the
|
||||
/// library. Also re-exported at the crate root as
|
||||
/// [`mxgateway_client::next_correlation_id`](crate::next_correlation_id).
|
||||
///
|
||||
/// The returned id has the following guaranteed properties:
|
||||
///
|
||||
/// - it embeds the supplied `label` verbatim so log readers can pick out
|
||||
/// which call site emitted it;
|
||||
/// - it is unique within the lifetime of a single process (driven by an
|
||||
/// internal monotonically-increasing atomic sequence);
|
||||
/// - it carries no embedded secret or user-supplied payload beyond `label`.
|
||||
///
|
||||
/// The exact textual format (currently `rust-client-{label}-{N}`) is *not*
|
||||
/// part of the public contract and may change between releases — callers
|
||||
/// must not parse it. Treat the returned `String` as an opaque token.
|
||||
#[must_use]
|
||||
pub fn next_correlation_id(label: &str) -> String {
|
||||
let sequence = CORRELATION_SEQUENCE.fetch_add(1, Ordering::Relaxed);
|
||||
format!("rust-client-{label}-{sequence}")
|
||||
}
|
||||
|
||||
/// Handle to an opened gateway session.
|
||||
///
|
||||
/// `Session` carries the gateway-issued session id and a cloned
|
||||
@@ -76,7 +110,7 @@ impl Session {
|
||||
.client
|
||||
.close_session_raw(CloseSessionRequest {
|
||||
session_id: self.id.clone(),
|
||||
client_correlation_id: "rust-client-close-session".to_owned(),
|
||||
client_correlation_id: next_correlation_id("close-session"),
|
||||
})
|
||||
.await?;
|
||||
ensure_protocol_success("close session", reply.protocol_status.as_ref())?;
|
||||
@@ -99,7 +133,7 @@ impl Session {
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(register_server_handle(&reply))
|
||||
register_server_handle(&reply)
|
||||
}
|
||||
|
||||
/// Run MXAccess `AddItem` against `server_handle` and return the
|
||||
@@ -120,7 +154,7 @@ impl Session {
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(add_item_handle(&reply))
|
||||
add_item_handle(&reply)
|
||||
}
|
||||
|
||||
/// Run MXAccess `AddItem2` (item with a caller-supplied context string)
|
||||
@@ -146,7 +180,7 @@ impl Session {
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(add_item2_handle(&reply))
|
||||
add_item2_handle(&reply)
|
||||
}
|
||||
|
||||
/// Run MXAccess `RemoveItem` for the given handle pair.
|
||||
@@ -226,7 +260,7 @@ impl Session {
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(bulk_results(reply, BulkReplyKind::AddItemBulk))
|
||||
bulk_results(reply, BulkReplyKind::AddItem)
|
||||
}
|
||||
|
||||
/// Bulk variant of [`Session::advise`].
|
||||
@@ -250,7 +284,7 @@ impl Session {
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(bulk_results(reply, BulkReplyKind::AdviseItemBulk))
|
||||
bulk_results(reply, BulkReplyKind::AdviseItem)
|
||||
}
|
||||
|
||||
/// Bulk variant of [`Session::remove_item`].
|
||||
@@ -274,7 +308,7 @@ impl Session {
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(bulk_results(reply, BulkReplyKind::RemoveItemBulk))
|
||||
bulk_results(reply, BulkReplyKind::RemoveItem)
|
||||
}
|
||||
|
||||
/// Bulk variant of [`Session::un_advise`].
|
||||
@@ -298,7 +332,7 @@ impl Session {
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(bulk_results(reply, BulkReplyKind::UnAdviseItemBulk))
|
||||
bulk_results(reply, BulkReplyKind::UnAdviseItem)
|
||||
}
|
||||
|
||||
/// Bulk `Subscribe` (atomic add-and-advise) for a list of tag addresses.
|
||||
@@ -322,7 +356,7 @@ impl Session {
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(bulk_results(reply, BulkReplyKind::SubscribeBulk))
|
||||
bulk_results(reply, BulkReplyKind::Subscribe)
|
||||
}
|
||||
|
||||
/// Bulk `Unsubscribe` (atomic un-advise-and-remove) for a list of
|
||||
@@ -347,7 +381,160 @@ impl Session {
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(bulk_results(reply, BulkReplyKind::UnsubscribeBulk))
|
||||
bulk_results(reply, BulkReplyKind::Unsubscribe)
|
||||
}
|
||||
|
||||
/// Bulk `Write` (sequential MXAccess Write per entry, on the worker's STA).
|
||||
///
|
||||
/// Per-entry MXAccess failures are reported as `BulkWriteResult` entries
|
||||
/// with `was_successful = false`; the call never errors on per-entry
|
||||
/// failure. Protocol-level failures still surface as [`Error::Command`].
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Same conditions as [`Session::add_item_bulk`], plus the usual
|
||||
/// transport/status errors.
|
||||
pub async fn write_bulk(
|
||||
&self,
|
||||
server_handle: i32,
|
||||
entries: Vec<WriteBulkEntry>,
|
||||
) -> Result<Vec<BulkWriteResult>, Error> {
|
||||
ensure_bulk_size("entries", entries.len())?;
|
||||
let reply = self
|
||||
.invoke(
|
||||
MxCommandKind::WriteBulk,
|
||||
Payload::WriteBulk(WriteBulkCommand {
|
||||
server_handle,
|
||||
entries,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
bulk_write_results(reply, BulkWriteReplyKind::Write)
|
||||
}
|
||||
|
||||
/// Bulk `Write2` (timestamped) — see [`Session::write_bulk`].
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Same conditions as [`Session::write_bulk`].
|
||||
pub async fn write2_bulk(
|
||||
&self,
|
||||
server_handle: i32,
|
||||
entries: Vec<Write2BulkEntry>,
|
||||
) -> Result<Vec<BulkWriteResult>, Error> {
|
||||
ensure_bulk_size("entries", entries.len())?;
|
||||
let reply = self
|
||||
.invoke(
|
||||
MxCommandKind::Write2Bulk,
|
||||
Payload::Write2Bulk(Write2BulkCommand {
|
||||
server_handle,
|
||||
entries,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
bulk_write_results(reply, BulkWriteReplyKind::Write2)
|
||||
}
|
||||
|
||||
/// Bulk `WriteSecured` — credential-sensitive values follow the same
|
||||
/// redaction contract as the single-item `write_secured` path.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Same conditions as [`Session::write_bulk`].
|
||||
pub async fn write_secured_bulk(
|
||||
&self,
|
||||
server_handle: i32,
|
||||
entries: Vec<WriteSecuredBulkEntry>,
|
||||
) -> Result<Vec<BulkWriteResult>, Error> {
|
||||
ensure_bulk_size("entries", entries.len())?;
|
||||
let reply = self
|
||||
.invoke(
|
||||
MxCommandKind::WriteSecuredBulk,
|
||||
Payload::WriteSecuredBulk(WriteSecuredBulkCommand {
|
||||
server_handle,
|
||||
entries,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
bulk_write_results(reply, BulkWriteReplyKind::WriteSecured)
|
||||
}
|
||||
|
||||
/// Bulk `WriteSecured2` (timestamped) — see [`Session::write_secured_bulk`].
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Same conditions as [`Session::write_bulk`].
|
||||
pub async fn write_secured2_bulk(
|
||||
&self,
|
||||
server_handle: i32,
|
||||
entries: Vec<WriteSecured2BulkEntry>,
|
||||
) -> Result<Vec<BulkWriteResult>, Error> {
|
||||
ensure_bulk_size("entries", entries.len())?;
|
||||
let reply = self
|
||||
.invoke(
|
||||
MxCommandKind::WriteSecured2Bulk,
|
||||
Payload::WriteSecured2Bulk(WriteSecured2BulkCommand {
|
||||
server_handle,
|
||||
entries,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
bulk_write_results(reply, BulkWriteReplyKind::WriteSecured2)
|
||||
}
|
||||
|
||||
/// Bulk `Read` — snapshot the current value for each requested tag.
|
||||
///
|
||||
/// MXAccess COM has no synchronous `Read`; the worker satisfies this by
|
||||
/// returning the most recent cached `OnDataChange` value when the tag is
|
||||
/// already advised (`was_cached = true`), or by taking a full AddItem +
|
||||
/// Advise + wait + UnAdvise + RemoveItem snapshot lifecycle otherwise.
|
||||
/// `timeout_ms == 0` lets the worker pick its default (1000 ms).
|
||||
/// Per-tag failures appear as `BulkReadResult` entries with
|
||||
/// `was_successful = false`; the call never errors on per-tag failure.
|
||||
///
|
||||
/// `tag_addresses` is taken by borrowed slice so callers can re-issue the
|
||||
/// same call repeatedly (typical for the bench loop and for any caller
|
||||
/// polling a fixed snapshot set) without owning or cloning the list at the
|
||||
/// call site. The method still has to materialise an owned `Vec<String>`
|
||||
/// internally because prost's `ReadBulkCommand` field requires it, so this
|
||||
/// is a clarity-of-ownership change rather than an allocation-reducing
|
||||
/// one: total heap traffic per call is unchanged.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Same conditions as [`Session::add_item_bulk`].
|
||||
pub async fn read_bulk<S: AsRef<str>>(
|
||||
&self,
|
||||
server_handle: i32,
|
||||
tag_addresses: &[S],
|
||||
timeout_ms: u32,
|
||||
) -> Result<Vec<BulkReadResult>, Error> {
|
||||
ensure_bulk_size("tag_addresses", tag_addresses.len())?;
|
||||
let tag_addresses: Vec<String> = tag_addresses
|
||||
.iter()
|
||||
.map(|s| s.as_ref().to_owned())
|
||||
.collect();
|
||||
let reply = self
|
||||
.invoke(
|
||||
MxCommandKind::ReadBulk,
|
||||
Payload::ReadBulk(ReadBulkCommand {
|
||||
server_handle,
|
||||
tag_addresses,
|
||||
timeout_ms,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
match reply.payload {
|
||||
Some(mx_command_reply::Payload::ReadBulk(reply)) => Ok(reply.results),
|
||||
_ => Err(Error::MalformedReply {
|
||||
detail: "ReadBulk reply did not carry a BulkReadReply payload".to_owned(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run MXAccess `Write` (single-value, no caller-supplied timestamp).
|
||||
@@ -466,7 +653,7 @@ impl Session {
|
||||
fn command_request(&self, kind: MxCommandKind, payload: Payload) -> MxCommandRequest {
|
||||
MxCommandRequest {
|
||||
session_id: self.id.clone(),
|
||||
client_correlation_id: format!("rust-client-{}", kind.as_str_name()),
|
||||
client_correlation_id: next_correlation_id(kind.as_str_name()),
|
||||
command: Some(MxCommand {
|
||||
kind: kind as i32,
|
||||
payload: Some(payload),
|
||||
@@ -486,71 +673,115 @@ fn ensure_bulk_size(name: &'static str, len: usize) -> Result<(), Error> {
|
||||
}
|
||||
}
|
||||
|
||||
fn register_server_handle(reply: &MxCommandReply) -> i32 {
|
||||
fn register_server_handle(reply: &MxCommandReply) -> Result<i32, Error> {
|
||||
match reply.payload.as_ref() {
|
||||
Some(mx_command_reply::Payload::Register(register)) => register.server_handle,
|
||||
Some(mx_command_reply::Payload::Register(register)) => Ok(register.server_handle),
|
||||
_ => reply
|
||||
.return_value
|
||||
.as_ref()
|
||||
.and_then(int32_reply_value)
|
||||
.unwrap_or_default(),
|
||||
.ok_or_else(|| Error::MalformedReply {
|
||||
detail: "Register reply carried neither a register payload nor an \
|
||||
int32 return value"
|
||||
.to_owned(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn add_item_handle(reply: &MxCommandReply) -> i32 {
|
||||
fn add_item_handle(reply: &MxCommandReply) -> Result<i32, Error> {
|
||||
match reply.payload.as_ref() {
|
||||
Some(mx_command_reply::Payload::AddItem(add_item)) => add_item.item_handle,
|
||||
Some(mx_command_reply::Payload::AddItem(add_item)) => Ok(add_item.item_handle),
|
||||
_ => reply
|
||||
.return_value
|
||||
.as_ref()
|
||||
.and_then(int32_reply_value)
|
||||
.unwrap_or_default(),
|
||||
.ok_or_else(|| Error::MalformedReply {
|
||||
detail: "AddItem reply carried neither an add_item payload nor an \
|
||||
int32 return value"
|
||||
.to_owned(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn add_item2_handle(reply: &MxCommandReply) -> i32 {
|
||||
fn add_item2_handle(reply: &MxCommandReply) -> Result<i32, Error> {
|
||||
match reply.payload.as_ref() {
|
||||
Some(mx_command_reply::Payload::AddItem2(add_item)) => add_item.item_handle,
|
||||
Some(mx_command_reply::Payload::AddItem2(add_item)) => Ok(add_item.item_handle),
|
||||
_ => reply
|
||||
.return_value
|
||||
.as_ref()
|
||||
.and_then(int32_reply_value)
|
||||
.unwrap_or_default(),
|
||||
.ok_or_else(|| Error::MalformedReply {
|
||||
detail: "AddItem2 reply carried neither an add_item2 payload nor an \
|
||||
int32 return value"
|
||||
.to_owned(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
enum BulkReplyKind {
|
||||
AddItemBulk,
|
||||
AdviseItemBulk,
|
||||
RemoveItemBulk,
|
||||
UnAdviseItemBulk,
|
||||
SubscribeBulk,
|
||||
UnsubscribeBulk,
|
||||
AddItem,
|
||||
AdviseItem,
|
||||
RemoveItem,
|
||||
UnAdviseItem,
|
||||
Subscribe,
|
||||
Unsubscribe,
|
||||
}
|
||||
|
||||
fn bulk_results(reply: MxCommandReply, kind: BulkReplyKind) -> Vec<SubscribeResult> {
|
||||
fn bulk_results(reply: MxCommandReply, kind: BulkReplyKind) -> Result<Vec<SubscribeResult>, Error> {
|
||||
match (reply.payload, kind) {
|
||||
(Some(mx_command_reply::Payload::AddItemBulk(reply)), BulkReplyKind::AddItemBulk) => {
|
||||
reply.results
|
||||
(Some(mx_command_reply::Payload::AddItemBulk(reply)), BulkReplyKind::AddItem) => {
|
||||
Ok(reply.results)
|
||||
}
|
||||
(Some(mx_command_reply::Payload::AdviseItemBulk(reply)), BulkReplyKind::AdviseItemBulk) => {
|
||||
reply.results
|
||||
(Some(mx_command_reply::Payload::AdviseItemBulk(reply)), BulkReplyKind::AdviseItem) => {
|
||||
Ok(reply.results)
|
||||
}
|
||||
(Some(mx_command_reply::Payload::RemoveItemBulk(reply)), BulkReplyKind::RemoveItemBulk) => {
|
||||
reply.results
|
||||
(Some(mx_command_reply::Payload::RemoveItemBulk(reply)), BulkReplyKind::RemoveItem) => {
|
||||
Ok(reply.results)
|
||||
}
|
||||
(Some(mx_command_reply::Payload::UnAdviseItemBulk(reply)), BulkReplyKind::UnAdviseItem) => {
|
||||
Ok(reply.results)
|
||||
}
|
||||
(Some(mx_command_reply::Payload::SubscribeBulk(reply)), BulkReplyKind::Subscribe) => {
|
||||
Ok(reply.results)
|
||||
}
|
||||
(Some(mx_command_reply::Payload::UnsubscribeBulk(reply)), BulkReplyKind::Unsubscribe) => {
|
||||
Ok(reply.results)
|
||||
}
|
||||
_ => Err(Error::MalformedReply {
|
||||
detail: "bulk command reply did not carry the expected bulk result payload".to_owned(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
enum BulkWriteReplyKind {
|
||||
Write,
|
||||
Write2,
|
||||
WriteSecured,
|
||||
WriteSecured2,
|
||||
}
|
||||
|
||||
fn bulk_write_results(
|
||||
reply: MxCommandReply,
|
||||
kind: BulkWriteReplyKind,
|
||||
) -> Result<Vec<BulkWriteResult>, Error> {
|
||||
match (reply.payload, kind) {
|
||||
(Some(mx_command_reply::Payload::WriteBulk(reply)), BulkWriteReplyKind::Write) => {
|
||||
Ok(reply.results)
|
||||
}
|
||||
(Some(mx_command_reply::Payload::Write2Bulk(reply)), BulkWriteReplyKind::Write2) => {
|
||||
Ok(reply.results)
|
||||
}
|
||||
(
|
||||
Some(mx_command_reply::Payload::UnAdviseItemBulk(reply)),
|
||||
BulkReplyKind::UnAdviseItemBulk,
|
||||
) => reply.results,
|
||||
(Some(mx_command_reply::Payload::SubscribeBulk(reply)), BulkReplyKind::SubscribeBulk) => {
|
||||
reply.results
|
||||
}
|
||||
Some(mx_command_reply::Payload::WriteSecuredBulk(reply)),
|
||||
BulkWriteReplyKind::WriteSecured,
|
||||
) => Ok(reply.results),
|
||||
(
|
||||
Some(mx_command_reply::Payload::UnsubscribeBulk(reply)),
|
||||
BulkReplyKind::UnsubscribeBulk,
|
||||
) => reply.results,
|
||||
_ => Vec::new(),
|
||||
Some(mx_command_reply::Payload::WriteSecured2Bulk(reply)),
|
||||
BulkWriteReplyKind::WriteSecured2,
|
||||
) => Ok(reply.results),
|
||||
_ => Err(Error::MalformedReply {
|
||||
detail: "bulk write reply did not carry the expected BulkWriteReply payload".to_owned(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+17
-16
@@ -25,15 +25,13 @@ use crate::generated::mxaccess_gateway::v1::{
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MxValue {
|
||||
raw: ProtoMxValue,
|
||||
projection: MxValueProjection,
|
||||
}
|
||||
|
||||
impl MxValue {
|
||||
/// Wrap a protobuf [`ProtoMxValue`] and compute its
|
||||
/// [`MxValueProjection`].
|
||||
/// Wrap a protobuf [`ProtoMxValue`]. The typed [`MxValueProjection`] is
|
||||
/// computed on demand by [`MxValue::projection`].
|
||||
pub fn from_proto(raw: ProtoMxValue) -> Self {
|
||||
let projection = MxValueProjection::from_proto(&raw);
|
||||
Self { raw, projection }
|
||||
Self { raw }
|
||||
}
|
||||
|
||||
/// Build a boolean `MxValue` (`MxDataType::Boolean`, `VT_BOOL`).
|
||||
@@ -102,9 +100,13 @@ impl MxValue {
|
||||
&self.raw
|
||||
}
|
||||
|
||||
/// Borrow the typed projection.
|
||||
pub fn projection(&self) -> &MxValueProjection {
|
||||
&self.projection
|
||||
/// Compute the typed projection of this value.
|
||||
///
|
||||
/// The projection is derived from the raw message on each call rather than
|
||||
/// cached, so a value built only to be sent over the wire never pays the
|
||||
/// projection's allocation cost.
|
||||
pub fn projection(&self) -> MxValueProjection {
|
||||
MxValueProjection::from_proto(&self.raw)
|
||||
}
|
||||
|
||||
/// Consume the wrapper and return the underlying protobuf message.
|
||||
@@ -183,15 +185,13 @@ impl MxValueProjection {
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MxArrayValue {
|
||||
raw: MxArray,
|
||||
projection: MxArrayProjection,
|
||||
}
|
||||
|
||||
impl MxArrayValue {
|
||||
/// Wrap a protobuf [`MxArray`] and compute its
|
||||
/// [`MxArrayProjection`].
|
||||
/// Wrap a protobuf [`MxArray`]. The typed [`MxArrayProjection`] is
|
||||
/// computed on demand by [`MxArrayValue::projection`].
|
||||
pub fn from_proto(raw: MxArray) -> Self {
|
||||
let projection = MxArrayProjection::from_proto(&raw);
|
||||
Self { raw, projection }
|
||||
Self { raw }
|
||||
}
|
||||
|
||||
/// Build a one-dimensional string array (`VT_ARRAY|VT_BSTR`).
|
||||
@@ -210,9 +210,10 @@ impl MxArrayValue {
|
||||
&self.raw
|
||||
}
|
||||
|
||||
/// Borrow the typed projection of the array's elements.
|
||||
pub fn projection(&self) -> &MxArrayProjection {
|
||||
&self.projection
|
||||
/// Compute the typed projection of the array's elements, derived from the
|
||||
/// raw message on each call rather than cached.
|
||||
pub fn projection(&self) -> MxArrayProjection {
|
||||
MxArrayProjection::from_proto(&self.raw)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
//! The protocol versions track the values the gateway and worker negotiate on
|
||||
//! `OpenSession` and let test harnesses cross-check the wire contract.
|
||||
|
||||
/// Semantic version of this Rust client crate. Mirrors `Cargo.toml`.
|
||||
pub const CLIENT_VERSION: &str = "0.1.0-dev";
|
||||
/// Semantic version of this Rust client crate, taken from `Cargo.toml` at
|
||||
/// compile time so the two cannot drift.
|
||||
pub const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// Public gateway gRPC protocol version this client targets.
|
||||
pub const GATEWAY_PROTOCOL_VERSION: u32 = 2;
|
||||
pub const GATEWAY_PROTOCOL_VERSION: u32 = 3;
|
||||
|
||||
/// Internal worker IPC protocol version this client expects sessions to use.
|
||||
pub const WORKER_PROTOCOL_VERSION: u32 = 1;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user