Compare commits
93 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 |
@@ -45,7 +45,6 @@ build/
|
|||||||
out/
|
out/
|
||||||
tmp/
|
tmp/
|
||||||
temp/
|
temp/
|
||||||
install/
|
|
||||||
|
|
||||||
# .NET
|
# .NET
|
||||||
**/bin/
|
**/bin/
|
||||||
@@ -147,8 +146,3 @@ generated-scratch/
|
|||||||
|
|
||||||
# Keep empty directories with .gitkeep files when needed
|
# Keep empty directories with .gitkeep files when needed
|
||||||
!.gitkeep
|
!.gitkeep
|
||||||
|
|
||||||
# Documentation review artifacts (CommentChecker output)
|
|
||||||
*-docs-issues.md
|
|
||||||
*-docs-fixed.md
|
|
||||||
*-docs-final.md
|
|
||||||
|
|||||||
@@ -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
|
dotnet run --project src/MxGateway.Server/MxGateway.Server.csproj
|
||||||
|
|
||||||
# API-key admin CLI (same exe, "apikey" subcommand)
|
# 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`):
|
Single test by name (xUnit `--filter`):
|
||||||
@@ -100,7 +100,7 @@ When source code changes, build and test the affected component before reporting
|
|||||||
## Design Sources To Consult Before Non-Trivial Changes
|
## Design Sources To Consult Before Non-Trivial Changes
|
||||||
|
|
||||||
- `gateway.md` — top-level architecture, command/event surface, IPC envelope, STA thread model, fault handling.
|
- `gateway.md` — top-level architecture, command/event surface, IPC envelope, STA thread model, fault handling.
|
||||||
- `glauth.md` — local LDAP server (GLAuth on `localhost:3893`, base DN `dc=zb,dc=local`) used for dev authn. Pre-provisioned users (`admin/admin123`, `readonly/readonly123`, etc.) and the role→capability mapping live there.
|
- `glauth.md` — local LDAP server (GLAuth on `localhost:3893`, base DN `dc=lmxopcua,dc=local`) used for dev authn. Pre-provisioned users (`admin/admin123`, `readonly/readonly123`, etc.) and the role→capability mapping live there.
|
||||||
- `docs/DesignDecisions.md` — v1 choices (MXAccess COM target `LMXProxyServerClass` from `C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll`, API-key-in-SQLite auth, fail-fast event backpressure, etc.).
|
- `docs/DesignDecisions.md` — v1 choices (MXAccess COM target `LMXProxyServerClass` from `C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll`, API-key-in-SQLite auth, fail-fast event backpressure, etc.).
|
||||||
- `docs/GatewayProcessDesign.md`, `docs/MxAccessWorkerInstanceDesign.md`, `docs/WorkerFrameProtocol.md`, `docs/WorkerProcessLauncher.md` — detailed component designs.
|
- `docs/GatewayProcessDesign.md`, `docs/MxAccessWorkerInstanceDesign.md`, `docs/WorkerFrameProtocol.md`, `docs/WorkerProcessLauncher.md` — detailed component designs.
|
||||||
- `docs/GatewayConfiguration.md` — full `MxGateway:*` options bound by `GatewayOptions` and validated at startup by `GatewayOptionsValidator`.
|
- `docs/GatewayConfiguration.md` — full `MxGateway:*` options bound by `GatewayOptions` and validated at startup by `GatewayOptionsValidator`.
|
||||||
@@ -114,9 +114,9 @@ External analysis sources referenced by design docs:
|
|||||||
|
|
||||||
## Authentication
|
## 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 is LDAP-backed (separate from the gRPC API-key model). `/login` binds against `MxGateway:Ldap` and maps the user's LDAP groups to `Admin` or `Viewer` via `MxGateway:Dashboard:GroupToRole`, then issues an HTTP-only secure `__Host-MxGatewayDashboard` cookie. SignalR hubs at `/hubs/{snapshot,alarms,events}` accept either the cookie or a 30-minute bearer minted at `/hubs/token`. `Dashboard:AllowAnonymousLocalhost` bypasses auth on loopback when enabled.
|
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.
|
||||||
|
|
||||||
## Process / Platform Notes
|
## Process / Platform Notes
|
||||||
|
|
||||||
|
|||||||
+5
-5
@@ -3,15 +3,15 @@
|
|||||||
This document describes how to perform a comprehensive, per-module code review of
|
This document describes how to perform a comprehensive, per-module code review of
|
||||||
the `mxaccessgw` codebase and how to track findings to resolution.
|
the `mxaccessgw` codebase and how to track findings to resolution.
|
||||||
|
|
||||||
A **module** is one buildable project under `src/` (e.g. `src/ZB.MOM.WW.MxGateway.Worker`)
|
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
|
or one language client under `clients/` (e.g. `clients/rust`). Each module has
|
||||||
its own folder under `code-reviews/` containing a single `findings.md`.
|
its own folder under `code-reviews/` containing a single `findings.md`.
|
||||||
|
|
||||||
## 1. Before you start
|
## 1. Before you start
|
||||||
|
|
||||||
1. Pick the module to review. Its folder is `code-reviews/<Module>/`:
|
1. Pick the module to review. Its folder is `code-reviews/<Module>/`:
|
||||||
- For a `src/` project, `<Module>` is the project name with the `ZB.MOM.WW.MxGateway.`
|
- For a `src/` project, `<Module>` is the project name with the `MxGateway.`
|
||||||
prefix stripped — `src/ZB.MOM.WW.MxGateway.Server` is reviewed in `code-reviews/Server/`.
|
prefix stripped — `src/MxGateway.Server` is reviewed in `code-reviews/Server/`.
|
||||||
- For a language client, `<Module>` is `Client.<Lang>` — `clients/rust` is
|
- For a language client, `<Module>` is `Client.<Lang>` — `clients/rust` is
|
||||||
reviewed in `code-reviews/Client.Rust/`.
|
reviewed in `code-reviews/Client.Rust/`.
|
||||||
2. Identify the design context for the module:
|
2. Identify the design context for the module:
|
||||||
@@ -65,8 +65,8 @@ means the checklist is completed even where it produces no findings — record
|
|||||||
8. **Code organization & conventions** — namespace hierarchy, project layout, the
|
8. **Code organization & conventions** — namespace hierarchy, project layout, the
|
||||||
Options pattern, separation of concerns, additive-only contract evolution.
|
Options pattern, separation of concerns, additive-only contract evolution.
|
||||||
9. **Testing coverage** — are the module's behaviours covered by tests
|
9. **Testing coverage** — are the module's behaviours covered by tests
|
||||||
(`src/ZB.MOM.WW.MxGateway.Tests`, `src/ZB.MOM.WW.MxGateway.Worker.Tests`,
|
(`src/MxGateway.Tests`, `src/MxGateway.Worker.Tests`,
|
||||||
`src/ZB.MOM.WW.MxGateway.IntegrationTests`)? Note untested critical paths and missing
|
`src/MxGateway.IntegrationTests`)? Note untested critical paths and missing
|
||||||
edge-case tests.
|
edge-case tests.
|
||||||
10. **Documentation & comments** — XML doc accuracy, misleading or stale comments,
|
10. **Documentation & comments** — XML doc accuracy, misleading or stale comments,
|
||||||
undocumented non-obvious behaviour.
|
undocumented non-obvious behaviour.
|
||||||
|
|||||||
@@ -1,21 +1,17 @@
|
|||||||
<Project>
|
<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>
|
<PropertyGroup>
|
||||||
<!-- Shared package metadata for clients/dotnet/. Individual projects opt in via <IsPackable>true</IsPackable>. -->
|
<LangVersion>latest</LangVersion>
|
||||||
<Authors>Joseph Doherty</Authors>
|
<Nullable>enable</Nullable>
|
||||||
<Company>ZB MOM WW</Company>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Copyright>Copyright (c) ZB MOM WW. All rights reserved.</Copyright>
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
<Product>MxAccessGateway Client</Product>
|
<AnalysisLevel>latest</AnalysisLevel>
|
||||||
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/mxaccessgw</RepositoryUrl>
|
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||||
<RepositoryType>git</RepositoryType>
|
<Deterministic>true</Deterministic>
|
||||||
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/mxaccessgw</PackageProjectUrl>
|
|
||||||
<PackageTags>mxaccess;mxgateway;grpc;client;archestra</PackageTags>
|
|
||||||
<PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance>
|
|
||||||
<!-- Versioning: bump per release. Symbols ship as snupkg. -->
|
|
||||||
<Version>0.1.0</Version>
|
|
||||||
<IncludeSymbols>true</IncludeSymbols>
|
|
||||||
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
|
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
|
||||||
<!-- Default: do NOT pack. Each project opts in. -->
|
|
||||||
<IsPackable>false</IsPackable>
|
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ Recommended layout:
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
clients/dotnet/
|
clients/dotnet/
|
||||||
ZB.MOM.WW.MxGateway.Client.slnx
|
MxGateway.Client.sln
|
||||||
ZB.MOM.WW.MxGateway.Client/
|
MxGateway.Client/
|
||||||
ZB.MOM.WW.MxGateway.Client.csproj
|
MxGateway.Client.csproj
|
||||||
GatewayClient.cs
|
GatewayClient.cs
|
||||||
MxGatewaySession.cs
|
MxGatewaySession.cs
|
||||||
MxGatewayClientOptions.cs
|
MxGatewayClientOptions.cs
|
||||||
@@ -26,14 +26,14 @@ clients/dotnet/
|
|||||||
Conversion/
|
Conversion/
|
||||||
Errors/
|
Errors/
|
||||||
Generated/
|
Generated/
|
||||||
ZB.MOM.WW.MxGateway.Client.Cli/
|
MxGateway.Client.Cli/
|
||||||
ZB.MOM.WW.MxGateway.Client.Cli.csproj
|
MxGateway.Client.Cli.csproj
|
||||||
Program.cs
|
Program.cs
|
||||||
Commands/
|
Commands/
|
||||||
ZB.MOM.WW.MxGateway.Client.Tests/
|
MxGateway.Client.Tests/
|
||||||
ZB.MOM.WW.MxGateway.Client.Tests.csproj
|
MxGateway.Client.Tests.csproj
|
||||||
ZB.MOM.WW.MxGateway.Client.IntegrationTests/
|
MxGateway.Client.IntegrationTests/
|
||||||
ZB.MOM.WW.MxGateway.Client.IntegrationTests.csproj
|
MxGateway.Client.IntegrationTests.csproj
|
||||||
```
|
```
|
||||||
|
|
||||||
Target framework:
|
Target framework:
|
||||||
@@ -43,7 +43,7 @@ Target framework:
|
|||||||
```
|
```
|
||||||
|
|
||||||
The scaffold uses a project reference to
|
The scaffold uses a project reference to
|
||||||
`src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj` for generated protobuf and
|
`src/MxGateway.Contracts/MxGateway.Contracts.csproj` for generated protobuf and
|
||||||
gRPC types. `clients/dotnet/generated` remains reserved for client-local
|
gRPC types. `clients/dotnet/generated` remains reserved for client-local
|
||||||
generator output if the .NET client later needs to decouple from the contracts
|
generator output if the .NET client later needs to decouple from the contracts
|
||||||
project.
|
project.
|
||||||
@@ -107,7 +107,6 @@ public sealed class MxGatewayClientOptions
|
|||||||
public required string ApiKey { get; init; }
|
public required string ApiKey { get; init; }
|
||||||
public bool UseTls { get; init; }
|
public bool UseTls { get; init; }
|
||||||
public string? CaCertificatePath { get; init; }
|
public string? CaCertificatePath { get; init; }
|
||||||
public bool RequireCertificateValidation { get; init; }
|
|
||||||
public string? ServerNameOverride { get; init; }
|
public string? ServerNameOverride { get; init; }
|
||||||
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(10);
|
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(10);
|
||||||
public TimeSpan DefaultCallTimeout { get; init; } = TimeSpan.FromSeconds(30);
|
public TimeSpan DefaultCallTimeout { get; init; } = TimeSpan.FromSeconds(30);
|
||||||
@@ -125,24 +124,6 @@ or subscription changes because those calls can partially succeed in MXAccess.
|
|||||||
API key may be loaded from `MXGATEWAY_API_KEY` by the CLI, not implicitly by the
|
API key may be loaded from `MXGATEWAY_API_KEY` by the CLI, not implicitly by the
|
||||||
library constructor unless a helper explicitly says it does that.
|
library constructor unless a helper explicitly says it does that.
|
||||||
|
|
||||||
### TLS trust posture
|
|
||||||
|
|
||||||
The gateway can serve a self-signed certificate it generates itself (it has no
|
|
||||||
PKI). To make that usable, TLS is **lenient by default**: when `UseTls` is set
|
|
||||||
and `CaCertificatePath` is empty, `CreateHttpHandler` installs a
|
|
||||||
`RemoteCertificateValidationCallback` that returns `true`, so the gateway's
|
|
||||||
self-signed certificate is accepted without verification.
|
|
||||||
|
|
||||||
To verify the gateway instead:
|
|
||||||
|
|
||||||
- set `CaCertificatePath` to pin a CA — validated via a `CustomRootTrust`
|
|
||||||
`X509Chain` against that root, and the callback additionally rejects a
|
|
||||||
hostname/SAN mismatch (`RemoteCertificateNameMismatch`); or
|
|
||||||
- set `RequireCertificateValidation` to `true` to keep the default OS/system-trust
|
|
||||||
verification on a connection with no pinned CA.
|
|
||||||
|
|
||||||
Pinning a CA always wins over the lenient default.
|
|
||||||
|
|
||||||
## Auth Interceptor
|
## Auth Interceptor
|
||||||
|
|
||||||
Use a gRPC call credentials/interceptor layer to attach:
|
Use a gRPC call credentials/interceptor layer to attach:
|
||||||
@@ -185,7 +166,7 @@ reply.EnsureMxAccessSuccess();
|
|||||||
|
|
||||||
## Test CLI
|
## Test CLI
|
||||||
|
|
||||||
Project: `ZB.MOM.WW.MxGateway.Client.Cli`.
|
Project: `MxGateway.Client.Cli`.
|
||||||
|
|
||||||
Command examples:
|
Command examples:
|
||||||
|
|
||||||
|
|||||||
+1
-8
@@ -1,6 +1,6 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Client.Cli;
|
namespace MxGateway.Client.Cli;
|
||||||
|
|
||||||
/// <summary>Parses command-line arguments into flags and named values.</summary>
|
/// <summary>Parses command-line arguments into flags and named values.</summary>
|
||||||
internal sealed class CliArguments
|
internal sealed class CliArguments
|
||||||
@@ -44,7 +44,6 @@ internal sealed class CliArguments
|
|||||||
|
|
||||||
/// <summary>Returns whether the named flag was present in the arguments.</summary>
|
/// <summary>Returns whether the named flag was present in the arguments.</summary>
|
||||||
/// <param name="name">The flag name (without '--' prefix).</param>
|
/// <param name="name">The flag name (without '--' prefix).</param>
|
||||||
/// <returns>True if the flag was present; otherwise false.</returns>
|
|
||||||
public bool HasFlag(string name)
|
public bool HasFlag(string name)
|
||||||
{
|
{
|
||||||
return _flags.Contains(name);
|
return _flags.Contains(name);
|
||||||
@@ -52,7 +51,6 @@ internal sealed class CliArguments
|
|||||||
|
|
||||||
/// <summary>Returns the value for a named argument, or <c>null</c> if absent.</summary>
|
/// <summary>Returns the value for a named argument, or <c>null</c> if absent.</summary>
|
||||||
/// <param name="name">The argument name (without '--' prefix).</param>
|
/// <param name="name">The argument name (without '--' prefix).</param>
|
||||||
/// <returns>The argument value, or null if the argument was not provided.</returns>
|
|
||||||
public string? GetOptional(string name)
|
public string? GetOptional(string name)
|
||||||
{
|
{
|
||||||
return _values.TryGetValue(name, out string? value)
|
return _values.TryGetValue(name, out string? value)
|
||||||
@@ -62,7 +60,6 @@ internal sealed class CliArguments
|
|||||||
|
|
||||||
/// <summary>Returns the value for a required named argument, or throws if absent.</summary>
|
/// <summary>Returns the value for a required named argument, or throws if absent.</summary>
|
||||||
/// <param name="name">The argument name (without '--' prefix).</param>
|
/// <param name="name">The argument name (without '--' prefix).</param>
|
||||||
/// <returns>The argument value.</returns>
|
|
||||||
public string GetRequired(string name)
|
public string GetRequired(string name)
|
||||||
{
|
{
|
||||||
string? value = GetOptional(name);
|
string? value = GetOptional(name);
|
||||||
@@ -77,7 +74,6 @@ internal sealed class CliArguments
|
|||||||
/// <summary>Parses and returns an int32 argument, or the default value if absent.</summary>
|
/// <summary>Parses and returns an int32 argument, or the default value if absent.</summary>
|
||||||
/// <param name="name">The argument name (without '--' prefix).</param>
|
/// <param name="name">The argument name (without '--' prefix).</param>
|
||||||
/// <param name="defaultValue">The default value if the argument is absent; if <c>null</c>, the argument is required.</param>
|
/// <param name="defaultValue">The default value if the argument is absent; if <c>null</c>, the argument is required.</param>
|
||||||
/// <returns>The parsed int32 value, or the default if absent.</returns>
|
|
||||||
public int GetInt32(string name, int? defaultValue = null)
|
public int GetInt32(string name, int? defaultValue = null)
|
||||||
{
|
{
|
||||||
string? value = GetOptional(name);
|
string? value = GetOptional(name);
|
||||||
@@ -97,7 +93,6 @@ internal sealed class CliArguments
|
|||||||
/// <summary>Parses and returns a uint32 argument, or the default value if absent.</summary>
|
/// <summary>Parses and returns a uint32 argument, or the default value if absent.</summary>
|
||||||
/// <param name="name">The argument name (without '--' prefix).</param>
|
/// <param name="name">The argument name (without '--' prefix).</param>
|
||||||
/// <param name="defaultValue">The default value if the argument is absent.</param>
|
/// <param name="defaultValue">The default value if the argument is absent.</param>
|
||||||
/// <returns>The parsed uint32 value, or the default if absent.</returns>
|
|
||||||
public uint GetUInt32(string name, uint defaultValue)
|
public uint GetUInt32(string name, uint defaultValue)
|
||||||
{
|
{
|
||||||
string? value = GetOptional(name);
|
string? value = GetOptional(name);
|
||||||
@@ -109,7 +104,6 @@ internal sealed class CliArguments
|
|||||||
/// <summary>Parses and returns a uint64 argument, or the default value if absent.</summary>
|
/// <summary>Parses and returns a uint64 argument, or the default value if absent.</summary>
|
||||||
/// <param name="name">The argument name (without '--' prefix).</param>
|
/// <param name="name">The argument name (without '--' prefix).</param>
|
||||||
/// <param name="defaultValue">The default value if the argument is absent.</param>
|
/// <param name="defaultValue">The default value if the argument is absent.</param>
|
||||||
/// <returns>The parsed uint64 value, or the default if absent.</returns>
|
|
||||||
public ulong GetUInt64(string name, ulong defaultValue)
|
public ulong GetUInt64(string name, ulong defaultValue)
|
||||||
{
|
{
|
||||||
string? value = GetOptional(name);
|
string? value = GetOptional(name);
|
||||||
@@ -121,7 +115,6 @@ internal sealed class CliArguments
|
|||||||
/// <summary>Parses and returns a TimeSpan argument, or the default value if absent. Supports "ms", "s", and standard TimeSpan format.</summary>
|
/// <summary>Parses and returns a TimeSpan argument, or the default value if absent. Supports "ms", "s", and standard TimeSpan format.</summary>
|
||||||
/// <param name="name">The argument name (without '--' prefix).</param>
|
/// <param name="name">The argument name (without '--' prefix).</param>
|
||||||
/// <param name="defaultValue">The default value if the argument is absent.</param>
|
/// <param name="defaultValue">The default value if the argument is absent.</param>
|
||||||
/// <returns>The parsed TimeSpan value, or the default if absent.</returns>
|
|
||||||
public TimeSpan GetDuration(string name, TimeSpan defaultValue)
|
public TimeSpan GetDuration(string name, TimeSpan defaultValue)
|
||||||
{
|
{
|
||||||
string? value = GetOptional(name);
|
string? value = GetOptional(name);
|
||||||
+9
-3
@@ -1,8 +1,14 @@
|
|||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
using MxGateway.Contracts.Proto.Galaxy;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Client.Cli;
|
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
|
public interface IMxGatewayCliClient : IAsyncDisposable
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\ZB.MOM.WW.MxGateway.Client\ZB.MOM.WW.MxGateway.Client.csproj" />
|
<ProjectReference Include="..\MxGateway.Client\MxGateway.Client.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
+5
-6
@@ -1,8 +1,8 @@
|
|||||||
using ZB.MOM.WW.MxGateway.Client;
|
using MxGateway.Client;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
using MxGateway.Contracts.Proto.Galaxy;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Client.Cli;
|
namespace MxGateway.Client.Cli;
|
||||||
|
|
||||||
internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient
|
internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient
|
||||||
{
|
{
|
||||||
@@ -100,8 +100,7 @@ internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient
|
|||||||
return _galaxyClient.Value.WatchDeployEventsRawAsync(request, cancellationToken);
|
return _galaxyClient.Value.WatchDeployEventsRawAsync(request, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Disposes the galaxy client (if created) and the underlying gateway client.</summary>
|
/// <inheritdoc />
|
||||||
/// <returns>A value task that completes when both clients are disposed.</returns>
|
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
if (_galaxyClient.IsValueCreated)
|
if (_galaxyClient.IsValueCreated)
|
||||||
+1
-2
@@ -1,4 +1,4 @@
|
|||||||
namespace ZB.MOM.WW.MxGateway.Client.Cli;
|
namespace MxGateway.Client.Cli;
|
||||||
|
|
||||||
/// <summary>Utility to redact API keys from error messages for safe output.</summary>
|
/// <summary>Utility to redact API keys from error messages for safe output.</summary>
|
||||||
internal static class MxGatewayCliSecretRedactor
|
internal static class MxGatewayCliSecretRedactor
|
||||||
@@ -6,7 +6,6 @@ internal static class MxGatewayCliSecretRedactor
|
|||||||
/// <summary>Replaces occurrences of the API key in the value with a redacted placeholder.</summary>
|
/// <summary>Replaces occurrences of the API key in the value with a redacted placeholder.</summary>
|
||||||
/// <param name="value">The message text to redact.</param>
|
/// <param name="value">The message text to redact.</param>
|
||||||
/// <param name="apiKey">The API key to remove; no redaction if null or empty.</param>
|
/// <param name="apiKey">The API key to remove; no redaction if null or empty.</param>
|
||||||
/// <returns>The message text with any API key occurrence replaced by <c>[redacted]</c>.</returns>
|
|
||||||
public static string Redact(string value, string? apiKey)
|
public static string Redact(string value, string? apiKey)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(value) || string.IsNullOrEmpty(apiKey))
|
if (string.IsNullOrEmpty(value) || string.IsNullOrEmpty(apiKey))
|
||||||
+470
-171
@@ -1,11 +1,11 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Google.Protobuf;
|
using Google.Protobuf;
|
||||||
using ZB.MOM.WW.MxGateway.Client;
|
using MxGateway.Client;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
using MxGateway.Contracts.Proto.Galaxy;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Client.Cli;
|
namespace MxGateway.Client.Cli;
|
||||||
|
|
||||||
/// <summary>Command-line interface for the MXAccess Gateway client, supporting session and command operations.</summary>
|
/// <summary>Command-line interface for the MXAccess Gateway client, supporting session and command operations.</summary>
|
||||||
public static class MxGatewayClientCli
|
public static class MxGatewayClientCli
|
||||||
@@ -16,13 +16,10 @@ public static class MxGatewayClientCli
|
|||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||||
|
|
||||||
private const string BatchEndOfRecord = "__MXGW_BATCH_EOR__";
|
|
||||||
|
|
||||||
/// <summary>Runs the CLI synchronously with the given arguments, writing output and errors.</summary>
|
/// <summary>Runs the CLI synchronously with the given arguments, writing output and errors.</summary>
|
||||||
/// <param name="args">Command-line arguments (command name followed by options).</param>
|
/// <param name="args">Command-line arguments (command name followed by options).</param>
|
||||||
/// <param name="standardOutput">TextWriter for command output.</param>
|
/// <param name="standardOutput">TextWriter for command output.</param>
|
||||||
/// <param name="standardError">TextWriter for error messages.</param>
|
/// <param name="standardError">TextWriter for error messages.</param>
|
||||||
/// <returns>The process exit code (0 for success, 1 for error).</returns>
|
|
||||||
public static int Run(
|
public static int Run(
|
||||||
string[] args,
|
string[] args,
|
||||||
TextWriter standardOutput,
|
TextWriter standardOutput,
|
||||||
@@ -39,7 +36,6 @@ public static class MxGatewayClientCli
|
|||||||
/// <param name="standardError">TextWriter for error messages.</param>
|
/// <param name="standardError">TextWriter for error messages.</param>
|
||||||
/// <param name="clientFactory">Optional factory to create the gateway client; defaults to MxGatewayClient.Create.</param>
|
/// <param name="clientFactory">Optional factory to create the gateway client; defaults to MxGatewayClient.Create.</param>
|
||||||
/// <param name="standardInput">Optional TextReader for batch-mode stdin; defaults to <see cref="Console.In"/>.</param>
|
/// <param name="standardInput">Optional TextReader for batch-mode stdin; defaults to <see cref="Console.In"/>.</param>
|
||||||
/// <returns>A task that resolves to the process exit code (0 for success, 1 for error).</returns>
|
|
||||||
public static Task<int> RunAsync(
|
public static Task<int> RunAsync(
|
||||||
string[] args,
|
string[] args,
|
||||||
TextWriter standardOutput,
|
TextWriter standardOutput,
|
||||||
@@ -59,6 +55,8 @@ public static class MxGatewayClientCli
|
|||||||
standardInput ?? Console.In);
|
standardInput ?? Console.In);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const string BatchEndOfRecord = "__MXGW_BATCH_EOR__";
|
||||||
|
|
||||||
private static async Task<int> RunCoreAsync(
|
private static async Task<int> RunCoreAsync(
|
||||||
string[] args,
|
string[] args,
|
||||||
TextWriter standardOutput,
|
TextWriter standardOutput,
|
||||||
@@ -128,6 +126,8 @@ public static class MxGatewayClientCli
|
|||||||
.ConfigureAwait(false),
|
.ConfigureAwait(false),
|
||||||
"bench-read-bulk" => await BenchReadBulkAsync(arguments, client, standardOutput, cancellation.Token)
|
"bench-read-bulk" => await BenchReadBulkAsync(arguments, client, standardOutput, cancellation.Token)
|
||||||
.ConfigureAwait(false),
|
.ConfigureAwait(false),
|
||||||
|
"bench-stream-events" => await BenchStreamEventsAsync(arguments, client, standardOutput, cancellation.Token)
|
||||||
|
.ConfigureAwait(false),
|
||||||
"stream-events" => await StreamEventsAsync(arguments, client, standardOutput, cancellation.Token)
|
"stream-events" => await StreamEventsAsync(arguments, client, standardOutput, cancellation.Token)
|
||||||
.ConfigureAwait(false),
|
.ConfigureAwait(false),
|
||||||
"stream-alarms" => await StreamAlarmsAsync(arguments, client, standardOutput, cancellation.Token)
|
"stream-alarms" => await StreamAlarmsAsync(arguments, client, standardOutput, cancellation.Token)
|
||||||
@@ -153,7 +153,10 @@ public static class MxGatewayClientCli
|
|||||||
}
|
}
|
||||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||||
{
|
{
|
||||||
string? apiKey = arguments.GetOptional("api-key");
|
// Redact the effective API key — whether it came from --api-key or from
|
||||||
|
// the (documented default) --api-key-env environment variable — so a
|
||||||
|
// transport error message that echoes the bearer token is never printed.
|
||||||
|
string? apiKey = TryResolveApiKey(arguments);
|
||||||
string message = MxGatewayCliSecretRedactor.Redact(exception.Message, apiKey);
|
string message = MxGatewayCliSecretRedactor.Redact(exception.Message, apiKey);
|
||||||
|
|
||||||
if (forceJsonErrors || arguments.HasFlag("json"))
|
if (forceJsonErrors || arguments.HasFlag("json"))
|
||||||
@@ -171,6 +174,88 @@ public static class MxGatewayClientCli
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IMxGatewayCliClient CreateDefaultClient(MxGatewayClientOptions options)
|
||||||
|
{
|
||||||
|
return new MxGatewayCliClientAdapter(MxGatewayClient.Create(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MxGatewayClientOptions CreateOptions(CliArguments arguments)
|
||||||
|
{
|
||||||
|
string endpoint = arguments.GetOptional("endpoint")
|
||||||
|
?? Environment.GetEnvironmentVariable("MXGATEWAY_ENDPOINT")
|
||||||
|
?? "http://localhost:5000";
|
||||||
|
|
||||||
|
string apiKey = ResolveApiKey(arguments);
|
||||||
|
|
||||||
|
return new MxGatewayClientOptions
|
||||||
|
{
|
||||||
|
Endpoint = new Uri(endpoint, UriKind.Absolute),
|
||||||
|
ApiKey = apiKey,
|
||||||
|
UseTls = arguments.HasFlag("tls")
|
||||||
|
|| endpoint.StartsWith("https://", StringComparison.OrdinalIgnoreCase),
|
||||||
|
DefaultCallTimeout = arguments.GetDuration("timeout", TimeSpan.FromSeconds(30)),
|
||||||
|
ConnectTimeout = arguments.GetDuration("connect-timeout", TimeSpan.FromSeconds(10)),
|
||||||
|
CaCertificatePath = arguments.GetOptional("ca-file"),
|
||||||
|
ServerNameOverride = arguments.GetOptional("server-name"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveApiKey(CliArguments arguments)
|
||||||
|
{
|
||||||
|
string? apiKey = TryResolveApiKey(arguments);
|
||||||
|
if (!string.IsNullOrWhiteSpace(apiKey))
|
||||||
|
{
|
||||||
|
return apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
string apiKeyEnvironmentName = arguments.GetOptional("api-key-env")
|
||||||
|
?? "MXGATEWAY_API_KEY";
|
||||||
|
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"Gateway API key is required. Pass --api-key or set {apiKeyEnvironmentName}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the effective API key from <c>--api-key</c> or, failing that, the
|
||||||
|
/// environment variable named by <c>--api-key-env</c> (default
|
||||||
|
/// <c>MXGATEWAY_API_KEY</c>). Returns <see langword="null"/> when no key is
|
||||||
|
/// configured; used for redaction where a missing key must not throw.
|
||||||
|
/// </summary>
|
||||||
|
private static string? TryResolveApiKey(CliArguments arguments)
|
||||||
|
{
|
||||||
|
string? apiKey = arguments.GetOptional("api-key");
|
||||||
|
if (!string.IsNullOrWhiteSpace(apiKey))
|
||||||
|
{
|
||||||
|
return apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
string apiKeyEnvironmentName = arguments.GetOptional("api-key-env")
|
||||||
|
?? "MXGATEWAY_API_KEY";
|
||||||
|
|
||||||
|
return Environment.GetEnvironmentVariable(apiKeyEnvironmentName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CancellationTokenSource CreateCancellation(CliArguments arguments, string command)
|
||||||
|
{
|
||||||
|
var cancellation = new CancellationTokenSource();
|
||||||
|
// Long-running streaming / bench commands run until they finish (or Ctrl+C)
|
||||||
|
// by default; a caller-supplied --timeout still applies if present. The
|
||||||
|
// bench commands default to --duration-seconds=30 --warmup-seconds=3 plus
|
||||||
|
// a per-session stagger, which already exceeds the default 30 s wall-clock
|
||||||
|
// budget, so applying that budget would cancel them mid-window and emit a
|
||||||
|
// zero-throughput JSON payload (see Client.Dotnet-015).
|
||||||
|
bool isLongRunning = command is "galaxy-watch" or "bench-read-bulk" or "bench-stream-events";
|
||||||
|
string? rawTimeout = arguments.GetOptional("timeout");
|
||||||
|
if (isLongRunning && string.IsNullOrWhiteSpace(rawTimeout))
|
||||||
|
{
|
||||||
|
return cancellation;
|
||||||
|
}
|
||||||
|
|
||||||
|
TimeSpan timeout = arguments.GetDuration("timeout", TimeSpan.FromSeconds(30));
|
||||||
|
cancellation.CancelAfter(timeout);
|
||||||
|
return cancellation;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Runs the CLI in batch mode: reads one command line at a time from
|
/// Runs the CLI in batch mode: reads one command line at a time from
|
||||||
/// <paramref name="standardInput"/>, dispatches it through the normal
|
/// <paramref name="standardInput"/>, dispatches it through the normal
|
||||||
@@ -218,13 +303,9 @@ public static class MxGatewayClientCli
|
|||||||
forceJsonErrors: true)
|
forceJsonErrors: true)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||||
{
|
{
|
||||||
// Unexpected exception that escaped RunCoreAsync (shouldn't happen, but be safe).
|
// Unexpected exception that escaped RunCoreAsync (shouldn't happen, but be safe).
|
||||||
// OperationCanceledException from long-running streaming commands
|
|
||||||
// (e.g. galaxy-watch hit by --timeout) is caught here too — the
|
|
||||||
// batch process must continue with the next command rather than
|
|
||||||
// unwinding.
|
|
||||||
commandError.WriteLine(JsonSerializer.Serialize(
|
commandError.WriteLine(JsonSerializer.Serialize(
|
||||||
new { error = exception.Message, type = exception.GetType().Name },
|
new { error = exception.Message, type = exception.GetType().Name },
|
||||||
JsonOptions));
|
JsonOptions));
|
||||||
@@ -251,70 +332,6 @@ public static class MxGatewayClientCli
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IMxGatewayCliClient CreateDefaultClient(MxGatewayClientOptions options)
|
|
||||||
{
|
|
||||||
return new MxGatewayCliClientAdapter(MxGatewayClient.Create(options));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static MxGatewayClientOptions CreateOptions(CliArguments arguments)
|
|
||||||
{
|
|
||||||
string endpoint = arguments.GetOptional("endpoint")
|
|
||||||
?? Environment.GetEnvironmentVariable("MXGATEWAY_ENDPOINT")
|
|
||||||
?? "http://localhost:5000";
|
|
||||||
|
|
||||||
string apiKey = ResolveApiKey(arguments);
|
|
||||||
|
|
||||||
return new MxGatewayClientOptions
|
|
||||||
{
|
|
||||||
Endpoint = new Uri(endpoint, UriKind.Absolute),
|
|
||||||
ApiKey = apiKey,
|
|
||||||
UseTls = arguments.HasFlag("tls")
|
|
||||||
|| endpoint.StartsWith("https://", StringComparison.OrdinalIgnoreCase),
|
|
||||||
DefaultCallTimeout = arguments.GetDuration("timeout", TimeSpan.FromSeconds(30)),
|
|
||||||
ConnectTimeout = arguments.GetDuration("connect-timeout", TimeSpan.FromSeconds(10)),
|
|
||||||
CaCertificatePath = arguments.GetOptional("ca-file"),
|
|
||||||
ServerNameOverride = arguments.GetOptional("server-name"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string ResolveApiKey(CliArguments arguments)
|
|
||||||
{
|
|
||||||
string? apiKey = arguments.GetOptional("api-key");
|
|
||||||
if (!string.IsNullOrWhiteSpace(apiKey))
|
|
||||||
{
|
|
||||||
return apiKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
string apiKeyEnvironmentName = arguments.GetOptional("api-key-env")
|
|
||||||
?? "MXGATEWAY_API_KEY";
|
|
||||||
|
|
||||||
apiKey = Environment.GetEnvironmentVariable(apiKeyEnvironmentName);
|
|
||||||
if (!string.IsNullOrWhiteSpace(apiKey))
|
|
||||||
{
|
|
||||||
return apiKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new ArgumentException(
|
|
||||||
$"Gateway API key is required. Pass --api-key or set {apiKeyEnvironmentName}.");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static CancellationTokenSource CreateCancellation(CliArguments arguments, string command)
|
|
||||||
{
|
|
||||||
var cancellation = new CancellationTokenSource();
|
|
||||||
// Long-running streaming commands run until Ctrl+C / cancellation by default;
|
|
||||||
// a caller-supplied --timeout still applies if present.
|
|
||||||
bool isLongRunning = command is "galaxy-watch";
|
|
||||||
string? rawTimeout = arguments.GetOptional("timeout");
|
|
||||||
if (isLongRunning && string.IsNullOrWhiteSpace(rawTimeout))
|
|
||||||
{
|
|
||||||
return cancellation;
|
|
||||||
}
|
|
||||||
|
|
||||||
TimeSpan timeout = arguments.GetDuration("timeout", TimeSpan.FromSeconds(30));
|
|
||||||
cancellation.CancelAfter(timeout);
|
|
||||||
return cancellation;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Task<int> OpenSessionAsync(
|
private static Task<int> OpenSessionAsync(
|
||||||
CliArguments arguments,
|
CliArguments arguments,
|
||||||
IMxGatewayCliClient client,
|
IMxGatewayCliClient client,
|
||||||
@@ -489,7 +506,7 @@ public static class MxGatewayClientCli
|
|||||||
ReadBulkCommand command = new()
|
ReadBulkCommand command = new()
|
||||||
{
|
{
|
||||||
ServerHandle = arguments.GetInt32("server-handle"),
|
ServerHandle = arguments.GetInt32("server-handle"),
|
||||||
TimeoutMs = ParseTimeoutMs(arguments, defaultValue: 0),
|
TimeoutMs = (uint)arguments.GetInt32("timeout-ms", 0),
|
||||||
};
|
};
|
||||||
command.TagAddresses.Add(ParseStringList(arguments.GetRequired("items")));
|
command.TagAddresses.Add(ParseStringList(arguments.GetRequired("items")));
|
||||||
|
|
||||||
@@ -665,78 +682,6 @@ public static class MxGatewayClientCli
|
|||||||
cancellationToken);
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Parses the bulk-write CLI's <c>--values</c> list. All entries share
|
|
||||||
/// the single <c>--type</c> argument; the comma-separated values are
|
|
||||||
/// each parsed via <see cref="ParseValue(string, string)"/> on a per-entry basis.
|
|
||||||
/// This keeps the CLI simple for e2e use (one type, N values) — callers
|
|
||||||
/// that need heterogeneous types per entry should drive the library
|
|
||||||
/// directly.
|
|
||||||
/// </summary>
|
|
||||||
private static IReadOnlyList<MxValue> ParseValuesList(CliArguments arguments)
|
|
||||||
{
|
|
||||||
string type = arguments.GetRequired("type");
|
|
||||||
string[] values = ParseStringList(arguments.GetRequired("values")).ToArray();
|
|
||||||
MxValue[] result = new MxValue[values.Length];
|
|
||||||
for (int i = 0; i < values.Length; i++)
|
|
||||||
{
|
|
||||||
result[i] = ParseValue(type, values[i]);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void EnsureSameLength(int handles, int values)
|
|
||||||
{
|
|
||||||
if (handles != values)
|
|
||||||
{
|
|
||||||
throw new ArgumentException(
|
|
||||||
$"Bulk write requires the same number of --item-handles ({handles}) and --values ({values}).");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Parses the optional <c>--timeout-ms</c> argument as a non-negative
|
|
||||||
/// unsigned millisecond count. Mirrors the SDK-side <c>(uint)Math.Min</c>
|
|
||||||
/// guard on <c>MxGatewaySession.ReadBulkAsync</c>: a negative value
|
|
||||||
/// (e.g. <c>-1</c>, an easy copy-paste mistake for "unbounded") is
|
|
||||||
/// rejected loudly rather than silently wrapped to <c>~49.7 days</c>,
|
|
||||||
/// which would park one worker thread per pending tag for hours.
|
|
||||||
/// Resolves Client.Dotnet-021.
|
|
||||||
/// </summary>
|
|
||||||
private static uint ParseTimeoutMs(CliArguments arguments, int defaultValue)
|
|
||||||
{
|
|
||||||
int raw = arguments.GetInt32("timeout-ms", defaultValue);
|
|
||||||
if (raw < 0)
|
|
||||||
{
|
|
||||||
throw new ArgumentException(
|
|
||||||
"--timeout-ms must be a non-negative integer (use 0 for the gateway default).");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (uint)raw;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Extracts the <c>ServerHandle</c> from a Register reply, throwing a
|
|
||||||
/// descriptive <see cref="MxGatewayException"/> when the typed
|
|
||||||
/// <c>Register</c> payload is absent on an otherwise-successful reply.
|
|
||||||
/// The typed sub-message is the contract for the Register command, so
|
|
||||||
/// its absence must not silently fall through to
|
|
||||||
/// <c>ReturnValue.Int32Value</c> (which would be <c>0</c> for an empty
|
|
||||||
/// reply, driving the rest of the bench against an invalid handle).
|
|
||||||
/// Resolves Client.Dotnet-019.
|
|
||||||
/// </summary>
|
|
||||||
private static int RequireRegisterServerHandle(MxCommandReply reply, string sessionId)
|
|
||||||
{
|
|
||||||
if (reply.Register is null)
|
|
||||||
{
|
|
||||||
throw new MxGatewayException(
|
|
||||||
$"Gateway reply for Register on session '{sessionId}' (correlation '{reply.CorrelationId}') "
|
|
||||||
+ "succeeded but is missing the typed 'register' payload required to read ServerHandle.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return reply.Register.ServerHandle;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Cross-language stress benchmark for ReadBulk. Opens its own session,
|
/// Cross-language stress benchmark for ReadBulk. Opens its own session,
|
||||||
/// subscribes to N tags so the worker's MxAccessValueCache populates from
|
/// subscribes to N tags so the worker's MxAccessValueCache populates from
|
||||||
@@ -757,7 +702,7 @@ public static class MxGatewayClientCli
|
|||||||
int tagStart = arguments.GetInt32("tag-start", 1);
|
int tagStart = arguments.GetInt32("tag-start", 1);
|
||||||
string tagPrefix = arguments.GetOptional("tag-prefix") ?? "TestMachine_";
|
string tagPrefix = arguments.GetOptional("tag-prefix") ?? "TestMachine_";
|
||||||
string tagAttribute = arguments.GetOptional("tag-attribute") ?? "TestChangingInt";
|
string tagAttribute = arguments.GetOptional("tag-attribute") ?? "TestChangingInt";
|
||||||
uint timeoutMs = ParseTimeoutMs(arguments, defaultValue: 1500);
|
uint timeoutMs = (uint)arguments.GetInt32("timeout-ms", 1500);
|
||||||
string clientName = arguments.GetOptional("client-name") ?? "mxgw-dotnet-bench";
|
string clientName = arguments.GetOptional("client-name") ?? "mxgw-dotnet-bench";
|
||||||
|
|
||||||
string[] tags = new string[bulkSize];
|
string[] tags = new string[bulkSize];
|
||||||
@@ -787,7 +732,7 @@ public static class MxGatewayClientCli
|
|||||||
}),
|
}),
|
||||||
cancellationToken)
|
cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
int serverHandle = RequireRegisterServerHandle(registerReply, sessionId);
|
int serverHandle = RequireRegisterServerHandle(registerReply);
|
||||||
|
|
||||||
SubscribeBulkCommand subscribe = new() { ServerHandle = serverHandle };
|
SubscribeBulkCommand subscribe = new() { ServerHandle = serverHandle };
|
||||||
subscribe.TagAddresses.Add(tags);
|
subscribe.TagAddresses.Add(tags);
|
||||||
@@ -846,13 +791,8 @@ public static class MxGatewayClientCli
|
|||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
sw.Stop();
|
sw.Stop();
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
catch
|
||||||
{
|
{
|
||||||
// Client.Dotnet-020: never swallow OperationCanceledException
|
|
||||||
// here. A bare `catch` would let Ctrl+C / parent CTS /
|
|
||||||
// wall-clock timeouts keep spinning until --duration-seconds
|
|
||||||
// elapsed, burning CPU and skewing the p99/max latency numbers
|
|
||||||
// with hundreds of immediate-OCE iterations.
|
|
||||||
sw.Stop();
|
sw.Stop();
|
||||||
failedCalls++;
|
failedCalls++;
|
||||||
latencyMillis.Add(sw.Elapsed.TotalMilliseconds);
|
latencyMillis.Add(sw.Elapsed.TotalMilliseconds);
|
||||||
@@ -946,6 +886,295 @@ public static class MxGatewayClientCli
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Single-client event-rate stress benchmark. Opens its own session,
|
||||||
|
/// subscribes to <c>--bulk-size</c> tags spanning the dev galaxy's
|
||||||
|
/// TestMachine_NNN.* set, then drains the gateway's StreamEvents
|
||||||
|
/// server-stream as fast as it can for <c>--duration-seconds</c>.
|
||||||
|
/// Tracks events received per second, end-to-end latency from
|
||||||
|
/// <c>event.worker_timestamp</c> to client receive time, and any
|
||||||
|
/// worker faults emitted by the gateway over the same window.
|
||||||
|
/// <para>
|
||||||
|
/// The companion <c>--all-attributes</c> flag (default <c>true</c>)
|
||||||
|
/// subscribes to all six TestMachine attributes per machine number
|
||||||
|
/// so the bench can drive event volume past one-attribute-per-machine
|
||||||
|
/// when the dev galaxy's TestChangingInt is the only churning tag.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<int> BenchStreamEventsAsync(
|
||||||
|
CliArguments arguments,
|
||||||
|
IMxGatewayCliClient client,
|
||||||
|
TextWriter output,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
int durationSeconds = arguments.GetInt32("duration-seconds", 30);
|
||||||
|
int warmupSeconds = arguments.GetInt32("warmup-seconds", 3);
|
||||||
|
int bulkSize = arguments.GetInt32("bulk-size", 60);
|
||||||
|
int tagStart = arguments.GetInt32("tag-start", 1);
|
||||||
|
int sessionCount = arguments.GetInt32("session-count", 1);
|
||||||
|
// Concurrent OpenSession calls all the way up the stack force the
|
||||||
|
// gateway to spawn N x86 workers simultaneously; that hits the
|
||||||
|
// 30-second worker startup timeout around 6-8 concurrent opens on
|
||||||
|
// a dev rig. The stagger spreads the opens out so per-session
|
||||||
|
// OpenSession+Register completes inside that budget before the
|
||||||
|
// next session starts spawning. The steady-state event window
|
||||||
|
// begins only once all sessions are open and subscribed.
|
||||||
|
int sessionStartStaggerMs = arguments.GetInt32("session-start-stagger-ms", sessionCount > 1 ? 750 : 0);
|
||||||
|
string tagPrefix = arguments.GetOptional("tag-prefix") ?? "TestMachine_";
|
||||||
|
bool allAttributes = !arguments.HasFlag("single-attribute");
|
||||||
|
string singleAttribute = arguments.GetOptional("tag-attribute") ?? "TestChangingInt";
|
||||||
|
string clientName = arguments.GetOptional("client-name") ?? "mxgw-dotnet-bench-events";
|
||||||
|
|
||||||
|
if (sessionCount < 1)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("--session-count must be >= 1.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the tag set. With --all-attributes (default) we rotate through
|
||||||
|
// all six TestMachine attributes to drive more event volume from a
|
||||||
|
// smaller machine range; with --single-attribute we use the same
|
||||||
|
// attribute on contiguous machine numbers (the original bench-read-
|
||||||
|
// bulk shape).
|
||||||
|
string[] attributes = allAttributes
|
||||||
|
? new[] { "TestChangingInt", "ProtectedValue", "TestBoolArray[]", "TestIntArray[]", "TestDateTimeArray[]", "TestStringArray[]" }
|
||||||
|
: new[] { singleAttribute };
|
||||||
|
List<string> tags = new(capacity: bulkSize);
|
||||||
|
for (int i = 0; i < bulkSize; i++)
|
||||||
|
{
|
||||||
|
int machineNumber = tagStart + (i / attributes.Length);
|
||||||
|
string attribute = attributes[i % attributes.Length];
|
||||||
|
tags.Add($"{tagPrefix}{machineNumber:D3}.{attribute}");
|
||||||
|
}
|
||||||
|
|
||||||
|
long warmupEvents = 0;
|
||||||
|
long steadyEvents = 0;
|
||||||
|
long steadyDataChangeEvents = 0;
|
||||||
|
List<double> endToEndLatencyMs = new(capacity: 262_144);
|
||||||
|
object latencyLock = new();
|
||||||
|
DateTime? firstSteadyEventUtc = null;
|
||||||
|
DateTime? lastSteadyEventUtc = null;
|
||||||
|
int totalSubscribeFailures = 0;
|
||||||
|
int totalSubscribedTagCount = 0;
|
||||||
|
int totalDrainedFaultCount = 0;
|
||||||
|
DateTime warmupStart = default;
|
||||||
|
DateTime warmupEnd = default;
|
||||||
|
DateTime steadyEnd = default;
|
||||||
|
|
||||||
|
// Phase 1: open + subscribe each session sequentially with a stagger,
|
||||||
|
// so worker spawn-up doesn't exceed the gateway's per-session startup
|
||||||
|
// timeout. Each session stashes its (sessionId, serverHandle, itemHandles)
|
||||||
|
// for phase 2.
|
||||||
|
var openedSessions = new List<(string SessionId, int ServerHandle, int[] ItemHandles, string ClientName)>(sessionCount);
|
||||||
|
for (int sessionIndex = 0; sessionIndex < sessionCount; sessionIndex++)
|
||||||
|
{
|
||||||
|
if (sessionIndex > 0 && sessionStartStaggerMs > 0)
|
||||||
|
{
|
||||||
|
await Task.Delay(sessionStartStaggerMs, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
string thisClientName = sessionCount == 1
|
||||||
|
? clientName
|
||||||
|
: $"{clientName}-{sessionIndex:D2}";
|
||||||
|
OpenSessionReply openReply = await client.OpenSessionAsync(
|
||||||
|
new OpenSessionRequest { ClientSessionName = thisClientName, ClientCorrelationId = CreateCorrelationId() },
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
string sessionId = openReply.SessionId;
|
||||||
|
|
||||||
|
MxCommandReply registerReply = await InvokeAndEnsureAsync(
|
||||||
|
client,
|
||||||
|
CreateCommandRequest(sessionId, new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.Register,
|
||||||
|
Register = new RegisterCommand { ClientName = thisClientName },
|
||||||
|
}),
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
int serverHandle = RequireRegisterServerHandle(registerReply);
|
||||||
|
|
||||||
|
SubscribeBulkCommand subscribe = new() { ServerHandle = serverHandle };
|
||||||
|
subscribe.TagAddresses.Add(tags);
|
||||||
|
MxCommandReply subscribeReply = await InvokeAndEnsureAsync(
|
||||||
|
client,
|
||||||
|
CreateCommandRequest(sessionId, new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.SubscribeBulk,
|
||||||
|
SubscribeBulk = subscribe,
|
||||||
|
}),
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
SubscribeResult[] subscribeResults = subscribeReply.SubscribeBulk?.Results.ToArray() ?? [];
|
||||||
|
int[] itemHandles = subscribeResults.Where(r => r.WasSuccessful).Select(r => r.ItemHandle).ToArray();
|
||||||
|
totalSubscribedTagCount += itemHandles.Length;
|
||||||
|
totalSubscribeFailures += subscribeResults.Count(r => !r.WasSuccessful);
|
||||||
|
openedSessions.Add((sessionId, serverHandle, itemHandles, thisClientName));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: now every session is open + advised. Start the measurement
|
||||||
|
// window and run each session's StreamEvents reader in parallel.
|
||||||
|
warmupStart = DateTime.UtcNow;
|
||||||
|
warmupEnd = warmupStart + TimeSpan.FromSeconds(warmupSeconds);
|
||||||
|
steadyEnd = warmupEnd + TimeSpan.FromSeconds(durationSeconds);
|
||||||
|
|
||||||
|
async Task RunStreamAsync((string SessionId, int ServerHandle, int[] ItemHandles, string ClientName) ctx)
|
||||||
|
{
|
||||||
|
using CancellationTokenSource streamCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
|
||||||
|
Task streamTask = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
StreamEventsRequest streamRequest = new() { SessionId = ctx.SessionId };
|
||||||
|
await foreach (MxEvent mxEvent in client.StreamEventsAsync(streamRequest, streamCts.Token)
|
||||||
|
.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
DateTime nowUtc = DateTime.UtcNow;
|
||||||
|
if (nowUtc >= steadyEnd)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nowUtc < warmupEnd)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref warmupEvents);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guarded by latencyLock so parallel sessions can't tear a 64-bit
|
||||||
|
// DateTime? read or stomp an already-set firstSteadyEventUtc with
|
||||||
|
// a later timestamp from a slower-to-start session. The lock is
|
||||||
|
// already held by the latency append a few lines below, so the
|
||||||
|
// extra cost is one uncontended lock acquisition per event.
|
||||||
|
lock (latencyLock)
|
||||||
|
{
|
||||||
|
firstSteadyEventUtc ??= nowUtc;
|
||||||
|
lastSteadyEventUtc = nowUtc;
|
||||||
|
}
|
||||||
|
Interlocked.Increment(ref steadyEvents);
|
||||||
|
if (mxEvent.Family == MxEventFamily.OnDataChange)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref steadyDataChangeEvents);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mxEvent.WorkerTimestamp is { } workerStamp)
|
||||||
|
{
|
||||||
|
double latency = (nowUtc - workerStamp.ToDateTime()).TotalMilliseconds;
|
||||||
|
if (latency >= 0)
|
||||||
|
{
|
||||||
|
lock (latencyLock) { endToEndLatencyMs.Add(latency); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, streamCts.Token);
|
||||||
|
|
||||||
|
// The inner streamTask MUST be observed on every path — including when
|
||||||
|
// the outer cancellationToken cancels during the Task.Delay below — or
|
||||||
|
// its fault surfaces as a TaskScheduler.UnobservedTaskException after
|
||||||
|
// GC. Use try/finally so the cancel + await pair always runs (see
|
||||||
|
// Client.Dotnet-016). RpcException(Cancelled) never reaches here in
|
||||||
|
// production because GrpcMxGatewayClientTransport.StreamEventsAsync
|
||||||
|
// routes through RpcExceptionMapper.Map, which returns OCE for
|
||||||
|
// StatusCode.Cancelled.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(steadyEnd - warmupStart, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
streamCts.Cancel();
|
||||||
|
try { await streamTask.ConfigureAwait(false); }
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
|
catch (MxGatewayException) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
MxCommandReply drainReply = await client.InvokeAsync(
|
||||||
|
CreateCommandRequest(ctx.SessionId, new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.DrainEvents,
|
||||||
|
DrainEvents = new DrainEventsCommand { MaxEvents = 16 },
|
||||||
|
}),
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
Interlocked.Add(ref totalDrainedFaultCount, drainReply.DrainEvents?.Events.Count ?? 0);
|
||||||
|
}
|
||||||
|
catch { /* fault probe is best-effort */ }
|
||||||
|
|
||||||
|
if (ctx.ItemHandles.Length > 0)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
UnsubscribeBulkCommand unsubscribe = new() { ServerHandle = ctx.ServerHandle };
|
||||||
|
unsubscribe.ItemHandles.Add(ctx.ItemHandles);
|
||||||
|
_ = await client.InvokeAsync(
|
||||||
|
CreateCommandRequest(ctx.SessionId, new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.UnsubscribeBulk,
|
||||||
|
UnsubscribeBulk = unsubscribe,
|
||||||
|
}),
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await client.CloseSessionAsync(
|
||||||
|
new CloseSessionRequest { SessionId = ctx.SessionId, ClientCorrelationId = CreateCorrelationId() },
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
Task[] streamTasks = openedSessions.Select(RunStreamAsync).ToArray();
|
||||||
|
await Task.WhenAll(streamTasks).ConfigureAwait(false);
|
||||||
|
|
||||||
|
double steadyElapsedSeconds = (firstSteadyEventUtc.HasValue && lastSteadyEventUtc.HasValue)
|
||||||
|
? (lastSteadyEventUtc.Value - firstSteadyEventUtc.Value).TotalSeconds
|
||||||
|
: 0;
|
||||||
|
double eventsPerSecond = steadyElapsedSeconds > 0 ? steadyEvents / steadyElapsedSeconds : 0;
|
||||||
|
double dataChangeEventsPerSecond = steadyElapsedSeconds > 0
|
||||||
|
? steadyDataChangeEvents / steadyElapsedSeconds
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
double[] latencySnapshot;
|
||||||
|
lock (latencyLock) { latencySnapshot = endToEndLatencyMs.ToArray(); }
|
||||||
|
|
||||||
|
object stats = new
|
||||||
|
{
|
||||||
|
language = "dotnet",
|
||||||
|
command = "bench-stream-events",
|
||||||
|
endpoint = arguments.GetOptional("endpoint") ?? "(default)",
|
||||||
|
clientName,
|
||||||
|
sessionCount,
|
||||||
|
bulkSize,
|
||||||
|
durationSeconds,
|
||||||
|
warmupSeconds,
|
||||||
|
allAttributes,
|
||||||
|
steadyElapsedSeconds = Math.Round(steadyElapsedSeconds, 3),
|
||||||
|
subscribedTagCount = totalSubscribedTagCount,
|
||||||
|
subscribeFailures = totalSubscribeFailures,
|
||||||
|
warmupEvents,
|
||||||
|
steadyEvents,
|
||||||
|
steadyDataChangeEvents,
|
||||||
|
eventsPerSecond = Math.Round(eventsPerSecond, 2),
|
||||||
|
dataChangeEventsPerSecond = Math.Round(dataChangeEventsPerSecond, 2),
|
||||||
|
drainedFaultCount = totalDrainedFaultCount,
|
||||||
|
endToEndLatencyMs = new
|
||||||
|
{
|
||||||
|
p50 = Percentile(latencySnapshot, 0.50),
|
||||||
|
p95 = Percentile(latencySnapshot, 0.95),
|
||||||
|
p99 = Percentile(latencySnapshot, 0.99),
|
||||||
|
max = latencySnapshot.Length > 0 ? Math.Round(latencySnapshot.Max(), 3) : 0,
|
||||||
|
mean = latencySnapshot.Length > 0 ? Math.Round(latencySnapshot.Average(), 3) : 0,
|
||||||
|
sampleCount = latencySnapshot.Length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
output.WriteLine(JsonSerializer.Serialize(stats, JsonOptions));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Computes the requested percentile from an unsorted latency sample using
|
/// Computes the requested percentile from an unsorted latency sample using
|
||||||
/// nearest-rank with linear interpolation. Rounds to 3 decimal places to
|
/// nearest-rank with linear interpolation. Rounds to 3 decimal places to
|
||||||
@@ -973,6 +1202,35 @@ public static class MxGatewayClientCli
|
|||||||
return Math.Round(value, 3);
|
return Math.Round(value, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses the bulk-write CLI's <c>--values</c> list. All entries share
|
||||||
|
/// the single <c>--type</c> argument; the comma-separated values are
|
||||||
|
/// each parsed via <see cref="ParseValue"/> on a per-entry basis. This
|
||||||
|
/// keeps the CLI simple for e2e use (one type, N values) — callers
|
||||||
|
/// that need heterogeneous types per entry should drive the library
|
||||||
|
/// directly.
|
||||||
|
/// </summary>
|
||||||
|
private static IReadOnlyList<MxValue> ParseValuesList(CliArguments arguments)
|
||||||
|
{
|
||||||
|
string type = arguments.GetRequired("type");
|
||||||
|
string[] values = ParseStringList(arguments.GetRequired("values")).ToArray();
|
||||||
|
MxValue[] result = new MxValue[values.Length];
|
||||||
|
for (int i = 0; i < values.Length; i++)
|
||||||
|
{
|
||||||
|
result[i] = ParseValue(type, values[i]);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureSameLength(int handles, int values)
|
||||||
|
{
|
||||||
|
if (handles != values)
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"Bulk write requires the same number of --item-handles ({handles}) and --values ({values}).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static Task<int> WriteAsync(
|
private static Task<int> WriteAsync(
|
||||||
CliArguments arguments,
|
CliArguments arguments,
|
||||||
IMxGatewayCliClient client,
|
IMxGatewayCliClient client,
|
||||||
@@ -1079,8 +1337,14 @@ public static class MxGatewayClientCli
|
|||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
// Client.Dotnet-017: graceful end-of-window completion mode for a
|
// Client.Dotnet-017: the supplied cancellation token covers both the
|
||||||
// finite-window event collector. Emit aggregate JSON below and exit 0.
|
// user's --timeout wall-clock budget (via CreateCancellation's
|
||||||
|
// CancelAfter) and external Ctrl+C / parent CTS cancellation. All
|
||||||
|
// three are graceful completion modes for a finite-window event
|
||||||
|
// collector: emit the events that arrived before the window closed
|
||||||
|
// and exit 0. The events list is well-formed at this point; the
|
||||||
|
// aggregate JSON below still runs. This matches how the Go, Rust,
|
||||||
|
// Python, and Java CLIs treat their equivalent timeouts.
|
||||||
}
|
}
|
||||||
|
|
||||||
if (json && !jsonLines)
|
if (json && !jsonLines)
|
||||||
@@ -1242,7 +1506,7 @@ public static class MxGatewayClientCli
|
|||||||
Kind = MxCommandKind.Register,
|
Kind = MxCommandKind.Register,
|
||||||
Register = new RegisterCommand { ClientName = arguments.GetOptional("client-name") ?? "mxgw-dotnet-smoke" },
|
Register = new RegisterCommand { ClientName = arguments.GetOptional("client-name") ?? "mxgw-dotnet-smoke" },
|
||||||
},
|
},
|
||||||
reply => reply.Register?.ServerHandle ?? reply.ReturnValue.Int32Value,
|
RequireRegisterServerHandle,
|
||||||
commandReplies,
|
commandReplies,
|
||||||
cancellationToken)
|
cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
@@ -1260,7 +1524,7 @@ public static class MxGatewayClientCli
|
|||||||
ItemDefinition = arguments.GetRequired("item"),
|
ItemDefinition = arguments.GetRequired("item"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
reply => reply.AddItem?.ItemHandle ?? reply.ReturnValue.Int32Value,
|
RequireAddItemItemHandle,
|
||||||
commandReplies,
|
commandReplies,
|
||||||
cancellationToken)
|
cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
@@ -1392,6 +1656,41 @@ public static class MxGatewayClientCli
|
|||||||
return reply;
|
return reply;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the server handle from a successful <c>register</c> reply, or throws
|
||||||
|
/// <see cref="MxGatewayException"/> when the typed <see cref="MxCommandReply.Register"/>
|
||||||
|
/// payload is absent. Mirrors the SDK-level <see cref="MxGatewaySession.RegisterAsync"/>
|
||||||
|
/// contract: a successful reply without the typed payload is a gateway protocol
|
||||||
|
/// error, not a license to fall through to <c>ReturnValue.Int32Value</c> (which is 0
|
||||||
|
/// when the reply carries no return value).
|
||||||
|
/// </summary>
|
||||||
|
private static int RequireRegisterServerHandle(MxCommandReply reply)
|
||||||
|
{
|
||||||
|
return reply.Register?.ServerHandle
|
||||||
|
?? throw CreateMissingPayloadException(reply, "register");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the item handle from a successful <c>add_item</c> reply, or throws
|
||||||
|
/// <see cref="MxGatewayException"/> when the typed <see cref="MxCommandReply.AddItem"/>
|
||||||
|
/// payload is absent. See <see cref="RequireRegisterServerHandle"/> for the rationale.
|
||||||
|
/// </summary>
|
||||||
|
private static int RequireAddItemItemHandle(MxCommandReply reply)
|
||||||
|
{
|
||||||
|
return reply.AddItem?.ItemHandle
|
||||||
|
?? throw CreateMissingPayloadException(reply, "add_item");
|
||||||
|
}
|
||||||
|
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
|
||||||
private static MxCommandRequest CreateCommandRequest(
|
private static MxCommandRequest CreateCommandRequest(
|
||||||
string sessionId,
|
string sessionId,
|
||||||
MxCommand command)
|
MxCommand command)
|
||||||
@@ -1488,12 +1787,12 @@ public static class MxGatewayClientCli
|
|||||||
return ParseValue(arguments.GetRequired("type"), arguments.GetRequired("value"));
|
return ParseValue(arguments.GetRequired("type"), arguments.GetRequired("value"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static MxValue ParseValue(string type, string value)
|
private static MxValue ParseValue(string typeName, string value)
|
||||||
{
|
{
|
||||||
string normalisedType = type.ToLowerInvariant();
|
string type = typeName.ToLowerInvariant();
|
||||||
string[] values = value.Split(',', StringSplitOptions.TrimEntries);
|
string[] values = value.Split(',', StringSplitOptions.TrimEntries);
|
||||||
|
|
||||||
return normalisedType switch
|
return type switch
|
||||||
{
|
{
|
||||||
"bool" or "boolean" => bool.Parse(value).ToMxValue(),
|
"bool" or "boolean" => bool.Parse(value).ToMxValue(),
|
||||||
"bool-array" or "boolean-array" => values.Select(bool.Parse).ToArray().ToMxValue(),
|
"bool-array" or "boolean-array" => values.Select(bool.Parse).ToArray().ToMxValue(),
|
||||||
@@ -1512,7 +1811,7 @@ public static class MxGatewayClientCli
|
|||||||
.Select(item => DateTimeOffset.Parse(item, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal))
|
.Select(item => DateTimeOffset.Parse(item, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal))
|
||||||
.ToArray()
|
.ToArray()
|
||||||
.ToMxValue(),
|
.ToMxValue(),
|
||||||
_ => throw new ArgumentException($"Unsupported MX value type '{normalisedType}'."),
|
_ => throw new ArgumentException($"Unsupported MX value type '{type}'."),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1729,6 +2028,7 @@ public static class MxGatewayClientCli
|
|||||||
or "write-secured-bulk"
|
or "write-secured-bulk"
|
||||||
or "write-secured2-bulk"
|
or "write-secured2-bulk"
|
||||||
or "bench-read-bulk"
|
or "bench-read-bulk"
|
||||||
|
or "bench-stream-events"
|
||||||
or "stream-events"
|
or "stream-events"
|
||||||
or "stream-alarms"
|
or "stream-alarms"
|
||||||
or "acknowledge-alarm"
|
or "acknowledge-alarm"
|
||||||
@@ -1782,14 +2082,13 @@ public static class MxGatewayClientCli
|
|||||||
writer.WriteLine("mxgw-dotnet register --session-id <id> --client-name <name> [--json]");
|
writer.WriteLine("mxgw-dotnet register --session-id <id> --client-name <name> [--json]");
|
||||||
writer.WriteLine("mxgw-dotnet add-item --session-id <id> --server-handle <n> --item <ref> [--json]");
|
writer.WriteLine("mxgw-dotnet add-item --session-id <id> --server-handle <n> --item <ref> [--json]");
|
||||||
writer.WriteLine("mxgw-dotnet advise --session-id <id> --server-handle <n> --item-handle <n> [--json]");
|
writer.WriteLine("mxgw-dotnet advise --session-id <id> --server-handle <n> --item-handle <n> [--json]");
|
||||||
writer.WriteLine("mxgw-dotnet subscribe-bulk --session-id <id> --server-handle <n> --items <ref,ref> [--json]");
|
|
||||||
writer.WriteLine("mxgw-dotnet unsubscribe-bulk --session-id <id> --server-handle <n> --item-handles <n,n> [--json]");
|
|
||||||
writer.WriteLine("mxgw-dotnet read-bulk --session-id <id> --server-handle <n> --items <ref,ref> [--timeout-ms <n>] [--json]");
|
writer.WriteLine("mxgw-dotnet read-bulk --session-id <id> --server-handle <n> --items <ref,ref> [--timeout-ms <n>] [--json]");
|
||||||
writer.WriteLine("mxgw-dotnet write-bulk --session-id <id> --server-handle <n> --item-handles <n,n> --type <type> --values <v,v> [--user-id <n>] [--json]");
|
writer.WriteLine("mxgw-dotnet write-bulk --session-id <id> --server-handle <n> --item-handles <n,n> --type <type> --values <v,v> [--user-id <n>] [--json]");
|
||||||
writer.WriteLine("mxgw-dotnet write2-bulk --session-id <id> --server-handle <n> --item-handles <n,n> --type <type> --values <v,v> [--timestamp <iso>] [--user-id <n>] [--json]");
|
writer.WriteLine("mxgw-dotnet write2-bulk --session-id <id> --server-handle <n> --item-handles <n,n> --type <type> --values <v,v> [--timestamp <iso>] [--user-id <n>] [--json]");
|
||||||
writer.WriteLine("mxgw-dotnet write-secured-bulk --session-id <id> --server-handle <n> --item-handles <n,n> --type <type> --values <v,v> --current-user-id <n> [--verifier-user-id <n>] [--json]");
|
writer.WriteLine("mxgw-dotnet write-secured-bulk --session-id <id> --server-handle <n> --item-handles <n,n> --type <type> --values <v,v> --current-user-id <n> [--verifier-user-id <n>] [--json]");
|
||||||
writer.WriteLine("mxgw-dotnet write-secured2-bulk --session-id <id> --server-handle <n> --item-handles <n,n> --type <type> --values <v,v> --current-user-id <n> [--verifier-user-id <n>] [--timestamp <iso>] [--json]");
|
writer.WriteLine("mxgw-dotnet write-secured2-bulk --session-id <id> --server-handle <n> --item-handles <n,n> --type <type> --values <v,v> [--timestamp <iso>] --current-user-id <n> [--verifier-user-id <n>] [--json]");
|
||||||
writer.WriteLine("mxgw-dotnet bench-read-bulk [--duration-seconds <n>] [--warmup-seconds <n>] [--bulk-size <n>] [--tag-start <n>] [--tag-prefix <s>] [--tag-attribute <s>] [--timeout-ms <n>] [--client-name <name>]");
|
writer.WriteLine("mxgw-dotnet subscribe-bulk --session-id <id> --server-handle <n> --items <ref,ref> [--json]");
|
||||||
|
writer.WriteLine("mxgw-dotnet unsubscribe-bulk --session-id <id> --server-handle <n> --item-handles <n,n> [--json]");
|
||||||
writer.WriteLine("mxgw-dotnet stream-events --session-id <id> [--max-events <n>] [--json]");
|
writer.WriteLine("mxgw-dotnet stream-events --session-id <id> [--max-events <n>] [--json]");
|
||||||
writer.WriteLine("mxgw-dotnet stream-alarms [--filter-prefix <ref>] [--max-events <n>] [--json] [--jsonl]");
|
writer.WriteLine("mxgw-dotnet stream-alarms [--filter-prefix <ref>] [--max-events <n>] [--json] [--jsonl]");
|
||||||
writer.WriteLine("mxgw-dotnet acknowledge-alarm --reference <ref> [--comment <text>] [--operator <user>] [--json]");
|
writer.WriteLine("mxgw-dotnet acknowledge-alarm --reference <ref> [--comment <text>] [--operator <user>] [--json]");
|
||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
using ZB.MOM.WW.MxGateway.Client.Cli;
|
using MxGateway.Client.Cli;
|
||||||
|
|
||||||
return await MxGatewayClientCli.RunAsync(args, Console.Out, Console.Error);
|
return await MxGatewayClientCli.RunAsync(args, Console.Out, Console.Error);
|
||||||
+28
-38
@@ -1,17 +1,21 @@
|
|||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
using MxGateway.Contracts.Proto.Galaxy;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
namespace MxGateway.Client.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fake Galaxy Repository client transport for testing.
|
/// Fake Galaxy Repository client transport for testing.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions options) : IGalaxyRepositoryClientTransport
|
internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions options) : IGalaxyRepositoryClientTransport
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <summary>
|
||||||
|
/// Gets the gateway client options.
|
||||||
|
/// </summary>
|
||||||
public MxGatewayClientOptions Options { get; } = options;
|
public MxGatewayClientOptions Options { get; } = options;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>
|
||||||
|
/// Gets the raw gRPC client; always null for the fake.
|
||||||
|
/// </summary>
|
||||||
public GalaxyRepository.GalaxyRepositoryClient? RawClient => null;
|
public GalaxyRepository.GalaxyRepositoryClient? RawClient => null;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -44,7 +48,6 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public DiscoverHierarchyReply DiscoverHierarchyReply { get; set; } = new();
|
public DiscoverHierarchyReply DiscoverHierarchyReply { get; set; } = new();
|
||||||
|
|
||||||
/// <summary>Gets the queue of discover hierarchy replies; dequeued in FIFO order.</summary>
|
|
||||||
public Queue<DiscoverHierarchyReply> DiscoverHierarchyReplies { get; } = new();
|
public Queue<DiscoverHierarchyReply> DiscoverHierarchyReplies { get; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -62,7 +65,11 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public Queue<Exception> DiscoverHierarchyExceptions { get; } = new();
|
public Queue<Exception> DiscoverHierarchyExceptions { get; } = new();
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>
|
||||||
|
/// Records the request and either throws a queued exception or returns the configured reply.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The TestConnectionRequest to process.</param>
|
||||||
|
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
||||||
public Task<TestConnectionReply> TestConnectionAsync(
|
public Task<TestConnectionReply> TestConnectionAsync(
|
||||||
TestConnectionRequest request,
|
TestConnectionRequest request,
|
||||||
CallOptions callOptions)
|
CallOptions callOptions)
|
||||||
@@ -76,7 +83,11 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
|
|||||||
return Task.FromResult(TestConnectionReply);
|
return Task.FromResult(TestConnectionReply);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>
|
||||||
|
/// Records the request and either throws a queued exception or returns the configured reply.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The GetLastDeployTimeRequest to process.</param>
|
||||||
|
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
||||||
public Task<GetLastDeployTimeReply> GetLastDeployTimeAsync(
|
public Task<GetLastDeployTimeReply> GetLastDeployTimeAsync(
|
||||||
GetLastDeployTimeRequest request,
|
GetLastDeployTimeRequest request,
|
||||||
CallOptions callOptions)
|
CallOptions callOptions)
|
||||||
@@ -90,7 +101,11 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
|
|||||||
return Task.FromResult(GetLastDeployTimeReply);
|
return Task.FromResult(GetLastDeployTimeReply);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>
|
||||||
|
/// Records the request and either throws a queued exception or returns the configured reply.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The DiscoverHierarchyRequest to process.</param>
|
||||||
|
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
||||||
public Task<DiscoverHierarchyReply> DiscoverHierarchyAsync(
|
public Task<DiscoverHierarchyReply> DiscoverHierarchyAsync(
|
||||||
DiscoverHierarchyRequest request,
|
DiscoverHierarchyRequest request,
|
||||||
CallOptions callOptions)
|
CallOptions callOptions)
|
||||||
@@ -107,35 +122,6 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
|
|||||||
: DiscoverHierarchyReply);
|
: DiscoverHierarchyReply);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Records BrowseChildren RPC calls made by the client.</summary>
|
|
||||||
public List<(BrowseChildrenRequest Request, CallOptions CallOptions)> BrowseChildrenCalls { get; } = [];
|
|
||||||
|
|
||||||
/// <summary>Default reply returned from BrowseChildren when the queue is empty.</summary>
|
|
||||||
public BrowseChildrenReply BrowseChildrenReply { get; set; } = new();
|
|
||||||
|
|
||||||
/// <summary>Queue of replies returned from BrowseChildren; dequeued in FIFO order.</summary>
|
|
||||||
public Queue<BrowseChildrenReply> BrowseChildrenReplies { get; } = new();
|
|
||||||
|
|
||||||
/// <summary>Queue of exceptions to throw from BrowseChildren; dequeued in FIFO order.</summary>
|
|
||||||
public Queue<Exception> BrowseChildrenExceptions { get; } = new();
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task<BrowseChildrenReply> BrowseChildrenAsync(
|
|
||||||
BrowseChildrenRequest request,
|
|
||||||
CallOptions callOptions)
|
|
||||||
{
|
|
||||||
BrowseChildrenCalls.Add((request, callOptions));
|
|
||||||
if (BrowseChildrenExceptions.TryDequeue(out Exception? exception))
|
|
||||||
{
|
|
||||||
return Task.FromException<BrowseChildrenReply>(exception);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Task.FromResult(
|
|
||||||
BrowseChildrenReplies.TryDequeue(out BrowseChildrenReply? reply)
|
|
||||||
? reply
|
|
||||||
: BrowseChildrenReply);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the list of WatchDeployEvents RPC calls made by the client.
|
/// Gets the list of WatchDeployEvents RPC calls made by the client.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -157,7 +143,11 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public Func<CancellationToken, Task>? WatchDeployEventsBeforeYield { get; set; }
|
public Func<CancellationToken, Task>? WatchDeployEventsBeforeYield { get; set; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>
|
||||||
|
/// Records the request and streams events, checking for queued exceptions and calling WatchDeployEventsBeforeYield before each event.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The WatchDeployEventsRequest to process.</param>
|
||||||
|
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
||||||
public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
|
public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
|
||||||
WatchDeployEventsRequest request,
|
WatchDeployEventsRequest request,
|
||||||
CallOptions callOptions)
|
CallOptions callOptions)
|
||||||
+88
-58
@@ -1,7 +1,7 @@
|
|||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
namespace MxGateway.Client.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fake implementation of IMxGatewayClientTransport for testing.
|
/// Fake implementation of IMxGatewayClientTransport for testing.
|
||||||
@@ -11,10 +11,14 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
|||||||
private readonly Queue<MxCommandReply> _invokeReplies = new();
|
private readonly Queue<MxCommandReply> _invokeReplies = new();
|
||||||
private readonly List<MxEvent> _events = [];
|
private readonly List<MxEvent> _events = [];
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>
|
||||||
|
/// Gets the gateway client options.
|
||||||
|
/// </summary>
|
||||||
public MxGatewayClientOptions Options { get; } = options;
|
public MxGatewayClientOptions Options { get; } = options;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>
|
||||||
|
/// Gets null, since this is a test fake without a real gRPC client.
|
||||||
|
/// </summary>
|
||||||
public MxAccessGateway.MxAccessGatewayClient? RawClient => null;
|
public MxAccessGateway.MxAccessGatewayClient? RawClient => null;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -42,11 +46,6 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public List<(AcknowledgeAlarmRequest Request, CallOptions CallOptions)> AcknowledgeAlarmCalls { get; } = [];
|
public List<(AcknowledgeAlarmRequest Request, CallOptions CallOptions)> AcknowledgeAlarmCalls { get; } = [];
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the list of captured QueryActiveAlarmsAsync calls.
|
|
||||||
/// </summary>
|
|
||||||
public List<(QueryActiveAlarmsRequest Request, CallOptions CallOptions)> QueryActiveAlarmsCalls { get; } = [];
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the list of captured StreamAlarmsAsync calls.
|
/// Gets the list of captured StreamAlarmsAsync calls.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -59,7 +58,6 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
|||||||
|
|
||||||
private readonly Queue<AcknowledgeAlarmReply> _acknowledgeReplies = new();
|
private readonly Queue<AcknowledgeAlarmReply> _acknowledgeReplies = new();
|
||||||
private readonly List<ActiveAlarmSnapshot> _activeAlarmSnapshots = [];
|
private readonly List<ActiveAlarmSnapshot> _activeAlarmSnapshots = [];
|
||||||
private readonly List<AlarmFeedMessage> _alarmFeedMessages = [];
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the reply to return from OpenSessionAsync.
|
/// Gets or sets the reply to return from OpenSessionAsync.
|
||||||
@@ -93,12 +91,29 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public Queue<Exception> CloseSessionExceptions { get; } = new();
|
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>
|
/// <summary>
|
||||||
/// Gets the queue of exceptions to throw from InvokeAsync.
|
/// Gets the queue of exceptions to throw from InvokeAsync.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Queue<Exception> InvokeExceptions { get; } = new();
|
public Queue<Exception> InvokeExceptions { get; } = new();
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>
|
||||||
|
/// Verifies that the OpenSessionAsync call is recorded and returns the configured reply.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The OpenSessionRequest to process.</param>
|
||||||
|
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
||||||
public Task<OpenSessionReply> OpenSessionAsync(
|
public Task<OpenSessionReply> OpenSessionAsync(
|
||||||
OpenSessionRequest request,
|
OpenSessionRequest request,
|
||||||
CallOptions callOptions)
|
CallOptions callOptions)
|
||||||
@@ -106,27 +121,41 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
|||||||
OpenSessionCalls.Add((request, callOptions));
|
OpenSessionCalls.Add((request, callOptions));
|
||||||
if (OpenSessionExceptions.TryDequeue(out Exception? exception))
|
if (OpenSessionExceptions.TryDequeue(out Exception? exception))
|
||||||
{
|
{
|
||||||
throw exception;
|
throw Translate(exception, callOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Task.FromResult(OpenSessionReply);
|
return Task.FromResult(OpenSessionReply);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>
|
||||||
public Task<CloseSessionReply> CloseSessionAsync(
|
/// Verifies that the CloseSessionAsync call is recorded and returns the configured reply.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The CloseSessionRequest to process.</param>
|
||||||
|
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
||||||
|
public async Task<CloseSessionReply> CloseSessionAsync(
|
||||||
CloseSessionRequest request,
|
CloseSessionRequest request,
|
||||||
CallOptions callOptions)
|
CallOptions callOptions)
|
||||||
{
|
{
|
||||||
CloseSessionCalls.Add((request, callOptions));
|
CloseSessionCalls.Add((request, callOptions));
|
||||||
|
|
||||||
|
if (CloseSessionHook is not null)
|
||||||
|
{
|
||||||
|
await CloseSessionHook().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
if (CloseSessionExceptions.TryDequeue(out Exception? exception))
|
if (CloseSessionExceptions.TryDequeue(out Exception? exception))
|
||||||
{
|
{
|
||||||
throw exception;
|
throw Translate(exception, callOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Task.FromResult(CloseSessionReply);
|
return CloseSessionReply;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>
|
||||||
|
/// Verifies that the InvokeAsync call is recorded and returns the next enqueued reply.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The MxCommandRequest to process.</param>
|
||||||
|
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
||||||
public Task<MxCommandReply> InvokeAsync(
|
public Task<MxCommandReply> InvokeAsync(
|
||||||
MxCommandRequest request,
|
MxCommandRequest request,
|
||||||
CallOptions callOptions)
|
CallOptions callOptions)
|
||||||
@@ -134,13 +163,17 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
|||||||
InvokeCalls.Add((request, callOptions));
|
InvokeCalls.Add((request, callOptions));
|
||||||
if (InvokeExceptions.TryDequeue(out Exception? exception))
|
if (InvokeExceptions.TryDequeue(out Exception? exception))
|
||||||
{
|
{
|
||||||
throw exception;
|
throw Translate(exception, callOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Task.FromResult(_invokeReplies.Dequeue());
|
return Task.FromResult(_invokeReplies.Dequeue());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>
|
||||||
|
/// Verifies that the StreamEventsAsync call is recorded and yields all enqueued events.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The StreamEventsRequest to process.</param>
|
||||||
|
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
||||||
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||||||
StreamEventsRequest request,
|
StreamEventsRequest request,
|
||||||
CallOptions callOptions)
|
CallOptions callOptions)
|
||||||
@@ -173,7 +206,9 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
|||||||
_events.Add(gatewayEvent);
|
_events.Add(gatewayEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>
|
||||||
|
/// Records the acknowledge call and returns the next enqueued reply (or default).
|
||||||
|
/// </summary>
|
||||||
public Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
|
public Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
|
||||||
AcknowledgeAlarmRequest request,
|
AcknowledgeAlarmRequest request,
|
||||||
CallOptions callOptions)
|
CallOptions callOptions)
|
||||||
@@ -181,7 +216,7 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
|||||||
AcknowledgeAlarmCalls.Add((request, callOptions));
|
AcknowledgeAlarmCalls.Add((request, callOptions));
|
||||||
if (AcknowledgeAlarmExceptions.TryDequeue(out Exception? exception))
|
if (AcknowledgeAlarmExceptions.TryDequeue(out Exception? exception))
|
||||||
{
|
{
|
||||||
throw exception;
|
throw Translate(exception, callOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Task.FromResult(_acknowledgeReplies.Count > 0
|
return Task.FromResult(_acknowledgeReplies.Count > 0
|
||||||
@@ -194,54 +229,49 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>
|
||||||
public async IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
|
/// Records the call and yields each enqueued snapshot as an active-alarm
|
||||||
QueryActiveAlarmsRequest request,
|
/// feed message, then a snapshot-complete sentinel.
|
||||||
CallOptions callOptions)
|
/// </summary>
|
||||||
{
|
|
||||||
QueryActiveAlarmsCalls.Add((request, callOptions));
|
|
||||||
|
|
||||||
foreach (ActiveAlarmSnapshot snapshot in _activeAlarmSnapshots)
|
|
||||||
{
|
|
||||||
callOptions.CancellationToken.ThrowIfCancellationRequested();
|
|
||||||
await Task.Yield();
|
|
||||||
yield return snapshot;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Enqueues an acknowledge reply.</summary>
|
|
||||||
/// <param name="reply">The acknowledge reply to enqueue.</param>
|
|
||||||
public void AddAcknowledgeReply(AcknowledgeAlarmReply reply)
|
|
||||||
{
|
|
||||||
_acknowledgeReplies.Enqueue(reply);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Enqueues a snapshot to be yielded from QueryActiveAlarmsAsync.</summary>
|
|
||||||
/// <param name="snapshot">The snapshot to enqueue.</param>
|
|
||||||
public void AddActiveAlarmSnapshot(ActiveAlarmSnapshot snapshot)
|
|
||||||
{
|
|
||||||
_activeAlarmSnapshots.Add(snapshot);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
||||||
StreamAlarmsRequest request,
|
StreamAlarmsRequest request,
|
||||||
CallOptions callOptions)
|
CallOptions callOptions)
|
||||||
{
|
{
|
||||||
StreamAlarmsCalls.Add((request, callOptions));
|
StreamAlarmsCalls.Add((request, callOptions));
|
||||||
|
|
||||||
foreach (AlarmFeedMessage message in _alarmFeedMessages)
|
foreach (ActiveAlarmSnapshot snapshot in _activeAlarmSnapshots)
|
||||||
{
|
{
|
||||||
callOptions.CancellationToken.ThrowIfCancellationRequested();
|
callOptions.CancellationToken.ThrowIfCancellationRequested();
|
||||||
await Task.Yield();
|
await Task.Yield();
|
||||||
yield return message;
|
yield return new AlarmFeedMessage { ActiveAlarm = snapshot };
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Enqueues an alarm feed message to be yielded from StreamAlarmsAsync.</summary>
|
yield return new AlarmFeedMessage { SnapshotComplete = true };
|
||||||
/// <param name="message">The alarm feed message to enqueue.</param>
|
}
|
||||||
public void AddAlarmFeedMessage(AlarmFeedMessage message)
|
|
||||||
|
/// <summary>Enqueues an acknowledge reply.</summary>
|
||||||
|
public void AddAcknowledgeReply(AcknowledgeAlarmReply reply)
|
||||||
{
|
{
|
||||||
_alarmFeedMessages.Add(message);
|
_acknowledgeReplies.Enqueue(reply);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+2
-23
@@ -1,15 +1,14 @@
|
|||||||
using Google.Protobuf.WellKnownTypes;
|
using Google.Protobuf.WellKnownTypes;
|
||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
using MxGateway.Contracts.Proto.Galaxy;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
namespace MxGateway.Client.Tests;
|
||||||
|
|
||||||
public sealed class GalaxyRepositoryClientTests
|
public sealed class GalaxyRepositoryClientTests
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that TestConnectionAsync attaches the API key in request metadata and returns the Ok flag.
|
/// Verifies that TestConnectionAsync attaches the API key in request metadata and returns the Ok flag.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TestConnectionAsync_AttachesApiKeyMetadataAndReturnsOkFlag()
|
public async Task TestConnectionAsync_AttachesApiKeyMetadataAndReturnsOkFlag()
|
||||||
{
|
{
|
||||||
@@ -28,7 +27,6 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that TestConnectionAsync returns false when the server reports NotOk.
|
/// Verifies that TestConnectionAsync returns false when the server reports NotOk.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TestConnectionAsync_ReturnsFalseWhenServerReportsNotOk()
|
public async Task TestConnectionAsync_ReturnsFalseWhenServerReportsNotOk()
|
||||||
{
|
{
|
||||||
@@ -44,7 +42,6 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that GetLastDeployTimeAsync returns null when the server reports not present.
|
/// Verifies that GetLastDeployTimeAsync returns null when the server reports not present.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetLastDeployTimeAsync_ReturnsNullWhenNotPresent()
|
public async Task GetLastDeployTimeAsync_ReturnsNullWhenNotPresent()
|
||||||
{
|
{
|
||||||
@@ -61,7 +58,6 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that GetLastDeployTimeAsync returns the timestamp when the server reports it present.
|
/// Verifies that GetLastDeployTimeAsync returns the timestamp when the server reports it present.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetLastDeployTimeAsync_ReturnsTimestampWhenPresent()
|
public async Task GetLastDeployTimeAsync_ReturnsTimestampWhenPresent()
|
||||||
{
|
{
|
||||||
@@ -83,7 +79,6 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that DiscoverHierarchyAsync returns the objects from the server reply.
|
/// Verifies that DiscoverHierarchyAsync returns the objects from the server reply.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DiscoverHierarchyAsync_ReturnsObjectsFromReply()
|
public async Task DiscoverHierarchyAsync_ReturnsObjectsFromReply()
|
||||||
{
|
{
|
||||||
@@ -146,7 +141,6 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that DiscoverHierarchyAsync propagates cancellation tokens to the transport.
|
/// Verifies that DiscoverHierarchyAsync propagates cancellation tokens to the transport.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DiscoverHierarchyAsync_PropagatesCancellationToTransport()
|
public async Task DiscoverHierarchyAsync_PropagatesCancellationToTransport()
|
||||||
{
|
{
|
||||||
@@ -167,7 +161,6 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that TestConnectionAsync retries on transient gRPC failures.
|
/// Verifies that TestConnectionAsync retries on transient gRPC failures.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DiscoverHierarchyAsync_WithRepeatedPageToken_ThrowsProtocolError()
|
public async Task DiscoverHierarchyAsync_WithRepeatedPageToken_ThrowsProtocolError()
|
||||||
{
|
{
|
||||||
@@ -188,10 +181,6 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
Assert.Contains("repeated page token", exception.Message, StringComparison.Ordinal);
|
Assert.Contains("repeated page token", exception.Message, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Verifies that DiscoverHierarchyAsync maps typed filter options correctly to the request.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DiscoverHierarchyAsync_WithOptions_MapsTypedFilters()
|
public async Task DiscoverHierarchyAsync_WithOptions_MapsTypedFilters()
|
||||||
{
|
{
|
||||||
@@ -223,10 +212,6 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
Assert.True(request.HistorizedOnly);
|
Assert.True(request.HistorizedOnly);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Verifies that TestConnectionAsync retries on transient gRPC failures.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TestConnectionAsync_RetriesOnTransientGrpcFailure()
|
public async Task TestConnectionAsync_RetriesOnTransientGrpcFailure()
|
||||||
{
|
{
|
||||||
@@ -244,7 +229,6 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that DiscoverHierarchyAsync retries on transient gRPC failures.
|
/// Verifies that DiscoverHierarchyAsync retries on transient gRPC failures.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DiscoverHierarchyAsync_RetriesOnTransientGrpcFailure()
|
public async Task DiscoverHierarchyAsync_RetriesOnTransientGrpcFailure()
|
||||||
{
|
{
|
||||||
@@ -261,7 +245,6 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that WatchDeployEventsAsync delivers the bootstrap event.
|
/// Verifies that WatchDeployEventsAsync delivers the bootstrap event.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WatchDeployEventsAsync_DeliversBootstrapEvent()
|
public async Task WatchDeployEventsAsync_DeliversBootstrapEvent()
|
||||||
{
|
{
|
||||||
@@ -298,7 +281,6 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that WatchDeployEventsAsync delivers multiple events in order.
|
/// Verifies that WatchDeployEventsAsync delivers multiple events in order.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WatchDeployEventsAsync_DeliversMultipleEventsInOrder()
|
public async Task WatchDeployEventsAsync_DeliversMultipleEventsInOrder()
|
||||||
{
|
{
|
||||||
@@ -337,7 +319,6 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that WatchDeployEventsAsync stops iteration cleanly when cancelled.
|
/// Verifies that WatchDeployEventsAsync stops iteration cleanly when cancelled.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WatchDeployEventsAsync_CancellationStopsIterationCleanly()
|
public async Task WatchDeployEventsAsync_CancellationStopsIterationCleanly()
|
||||||
{
|
{
|
||||||
@@ -382,7 +363,6 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that WatchDeployEventsAsync throws ObjectDisposedException after the client is disposed.
|
/// Verifies that WatchDeployEventsAsync throws ObjectDisposedException after the client is disposed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WatchDeployEventsAsync_ThrowsAfterDisposal()
|
public async Task WatchDeployEventsAsync_ThrowsAfterDisposal()
|
||||||
{
|
{
|
||||||
@@ -398,7 +378,6 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that TestConnectionAsync throws ObjectDisposedException after the client is disposed.
|
/// Verifies that TestConnectionAsync throws ObjectDisposedException after the client is disposed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TestConnectionAsync_ThrowsAfterDisposal()
|
public async Task TestConnectionAsync_ThrowsAfterDisposal()
|
||||||
{
|
{
|
||||||
+3
-3
@@ -1,8 +1,8 @@
|
|||||||
using Google.Protobuf;
|
using Google.Protobuf;
|
||||||
using ZB.MOM.WW.MxGateway.Client;
|
using MxGateway.Client;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
namespace MxGateway.Client.Tests;
|
||||||
|
|
||||||
public sealed class MxCommandReplyExtensionsTests
|
public sealed class MxCommandReplyExtensionsTests
|
||||||
{
|
{
|
||||||
+2
-2
@@ -19,8 +19,8 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\ZB.MOM.WW.MxGateway.Client\ZB.MOM.WW.MxGateway.Client.csproj" />
|
<ProjectReference Include="..\MxGateway.Client\MxGateway.Client.csproj" />
|
||||||
<ProjectReference Include="..\ZB.MOM.WW.MxGateway.Client.Cli\ZB.MOM.WW.MxGateway.Client.Cli.csproj" />
|
<ProjectReference Include="..\MxGateway.Client.Cli\MxGateway.Client.Cli.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
+51
-43
@@ -1,18 +1,16 @@
|
|||||||
using Google.Protobuf.WellKnownTypes;
|
using Google.Protobuf.WellKnownTypes;
|
||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
namespace MxGateway.Client.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <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.AcknowledgeAlarmAsync"/> and
|
||||||
/// <see cref="MxGatewayClient.QueryActiveAlarmsAsync"/>.
|
/// <see cref="MxGatewayClient.StreamAlarmsAsync"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class MxGatewayClientAlarmsTests
|
public sealed class MxGatewayClientAlarmsTests
|
||||||
{
|
{
|
||||||
/// <summary>AcknowledgeAlarmAsync records request and returns reply.</summary>
|
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AcknowledgeAlarmAsync_RecordsRequestShapeAndReturnsReply()
|
public async Task AcknowledgeAlarmAsync_RecordsRequestShapeAndReturnsReply()
|
||||||
{
|
{
|
||||||
@@ -48,8 +46,6 @@ public sealed class MxGatewayClientAlarmsTests
|
|||||||
Assert.Equal("Bearer test-api-key", call.CallOptions.Headers?.GetValue("authorization"));
|
Assert.Equal("Bearer test-api-key", call.CallOptions.Headers?.GetValue("authorization"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>AcknowledgeAlarmAsync honors cancellation.</summary>
|
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AcknowledgeAlarmAsync_HonorsCancellation()
|
public async Task AcknowledgeAlarmAsync_HonorsCancellation()
|
||||||
{
|
{
|
||||||
@@ -73,22 +69,18 @@ public sealed class MxGatewayClientAlarmsTests
|
|||||||
cancellation.Token));
|
cancellation.Token));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>AcknowledgeAlarmAsync maps unauthenticated RPC exception to typed exception.</summary>
|
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[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();
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
transport.AcknowledgeAlarmExceptions.Enqueue(
|
transport.AcknowledgeAlarmExceptions.Enqueue(
|
||||||
new RpcException(new Status(StatusCode.Unauthenticated, "expired key")));
|
new RpcException(new Status(StatusCode.Unauthenticated, "expired key")));
|
||||||
await using MxGatewayClient client = CreateClient(transport);
|
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>(
|
var ex = await Assert.ThrowsAsync<RpcException>(
|
||||||
() => client.AcknowledgeAlarmAsync(new AcknowledgeAlarmRequest
|
() => client.AcknowledgeAlarmAsync(new AcknowledgeAlarmRequest
|
||||||
{
|
{
|
||||||
@@ -99,57 +91,73 @@ public sealed class MxGatewayClientAlarmsTests
|
|||||||
Assert.Equal(StatusCode.Unauthenticated, ex.StatusCode);
|
Assert.Equal(StatusCode.Unauthenticated, ex.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>QueryActiveAlarmsAsync streams enqueued snapshots.</summary>
|
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[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();
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
transport.AddActiveAlarmSnapshot(MakeSnapshot("Tank01.Level.HiHi", AlarmConditionState.Active));
|
transport.AddActiveAlarmSnapshot(MakeSnapshot("Tank01.Level.HiHi", AlarmConditionState.Active));
|
||||||
transport.AddActiveAlarmSnapshot(MakeSnapshot("Tank02.Level.HiHi", AlarmConditionState.ActiveAcked));
|
transport.AddActiveAlarmSnapshot(MakeSnapshot("Tank02.Level.HiHi", AlarmConditionState.ActiveAcked));
|
||||||
await using MxGatewayClient client = CreateClient(transport);
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
|
||||||
List<ActiveAlarmSnapshot> snapshots = [];
|
List<AlarmFeedMessage> messages = [];
|
||||||
await foreach (ActiveAlarmSnapshot snapshot in client.QueryActiveAlarmsAsync(new QueryActiveAlarmsRequest
|
await foreach (AlarmFeedMessage message in client.StreamAlarmsAsync(new StreamAlarmsRequest()))
|
||||||
{
|
{
|
||||||
SessionId = "session-fixture",
|
messages.Add(message);
|
||||||
}))
|
|
||||||
{
|
|
||||||
snapshots.Add(snapshot);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Assert.Equal(2, snapshots.Count);
|
Assert.Equal(3, messages.Count);
|
||||||
Assert.Equal("Tank01.Level.HiHi", snapshots[0].AlarmFullReference);
|
Assert.Equal("Tank01.Level.HiHi", messages[0].ActiveAlarm.AlarmFullReference);
|
||||||
Assert.Equal(AlarmConditionState.Active, snapshots[0].CurrentState);
|
Assert.Equal(AlarmConditionState.Active, messages[0].ActiveAlarm.CurrentState);
|
||||||
Assert.Equal(AlarmConditionState.ActiveAcked, snapshots[1].CurrentState);
|
Assert.Equal(AlarmConditionState.ActiveAcked, messages[1].ActiveAlarm.CurrentState);
|
||||||
Assert.Single(transport.QueryActiveAlarmsCalls);
|
Assert.True(messages[2].SnapshotComplete);
|
||||||
|
Assert.Single(transport.StreamAlarmsCalls);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>QueryActiveAlarmsAsync passes filter prefix.</summary>
|
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task QueryActiveAlarmsAsync_PassesFilterPrefix()
|
public async Task StreamAlarmsAsync_PassesFilterPrefix()
|
||||||
{
|
{
|
||||||
FakeGatewayTransport transport = CreateTransport();
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
await using MxGatewayClient client = CreateClient(transport);
|
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.",
|
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);
|
Assert.Equal("Tank01.", call.Request.AlarmFilterPrefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>QueryActiveAlarmsAsync honors cancellation during enumeration.</summary>
|
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task QueryActiveAlarmsAsync_HonorsCancellationDuringEnumeration()
|
public async Task StreamAlarmsAsync_HonorsCancellationDuringEnumeration()
|
||||||
{
|
{
|
||||||
FakeGatewayTransport transport = CreateTransport();
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
transport.AddActiveAlarmSnapshot(MakeSnapshot("Tank01.Level.HiHi", AlarmConditionState.Active));
|
transport.AddActiveAlarmSnapshot(MakeSnapshot("Tank01.Level.HiHi", AlarmConditionState.Active));
|
||||||
@@ -159,8 +167,8 @@ public sealed class MxGatewayClientAlarmsTests
|
|||||||
using CancellationTokenSource cancellation = new();
|
using CancellationTokenSource cancellation = new();
|
||||||
await Assert.ThrowsAsync<OperationCanceledException>(async () =>
|
await Assert.ThrowsAsync<OperationCanceledException>(async () =>
|
||||||
{
|
{
|
||||||
await foreach (ActiveAlarmSnapshot _ in client.QueryActiveAlarmsAsync(
|
await foreach (AlarmFeedMessage _ in client.StreamAlarmsAsync(
|
||||||
new QueryActiveAlarmsRequest { SessionId = "session-fixture" },
|
new StreamAlarmsRequest(),
|
||||||
cancellation.Token))
|
cancellation.Token))
|
||||||
{
|
{
|
||||||
cancellation.Cancel();
|
cancellation.Cancel();
|
||||||
+190
-379
@@ -1,9 +1,9 @@
|
|||||||
using Google.Protobuf.WellKnownTypes;
|
using Google.Protobuf.WellKnownTypes;
|
||||||
using ZB.MOM.WW.MxGateway.Client.Cli;
|
using MxGateway.Client.Cli;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
using MxGateway.Contracts.Proto.Galaxy;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
namespace MxGateway.Client.Tests;
|
||||||
|
|
||||||
/// <summary>Tests for the CLI command interface.</summary>
|
/// <summary>Tests for the CLI command interface.</summary>
|
||||||
public sealed class MxGatewayClientCliTests
|
public sealed class MxGatewayClientCliTests
|
||||||
@@ -24,7 +24,6 @@ public sealed class MxGatewayClientCliTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that the version command with --json flag prints JSON protocol versions.</summary>
|
/// <summary>Verifies that the version command with --json flag prints JSON protocol versions.</summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_VersionJson_PrintsJsonProtocolVersions()
|
public async Task RunAsync_VersionJson_PrintsJsonProtocolVersions()
|
||||||
{
|
{
|
||||||
@@ -39,7 +38,6 @@ public sealed class MxGatewayClientCliTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that the write command builds a write request and prints JSON reply.</summary>
|
/// <summary>Verifies that the write command builds a write request and prints JSON reply.</summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_Write_BuildsWriteCommandAndPrintsJsonReply()
|
public async Task RunAsync_Write_BuildsWriteCommandAndPrintsJsonReply()
|
||||||
{
|
{
|
||||||
@@ -85,7 +83,6 @@ public sealed class MxGatewayClientCliTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that error output redacts sensitive API key values.</summary>
|
/// <summary>Verifies that error output redacts sensitive API key values.</summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_ErrorOutput_RedactsApiKey()
|
public async Task RunAsync_ErrorOutput_RedactsApiKey()
|
||||||
{
|
{
|
||||||
@@ -109,8 +106,44 @@ public sealed class MxGatewayClientCliTests
|
|||||||
Assert.Contains("[redacted]", error.ToString());
|
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>
|
/// <summary>Verifies that stream-events with max-events limit stops output in non-JSON format.</summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_StreamEvents_WithMaxEventsStopsNonJsonOutput()
|
public async Task RunAsync_StreamEvents_WithMaxEventsStopsNonJsonOutput()
|
||||||
{
|
{
|
||||||
@@ -151,9 +184,71 @@ public sealed class MxGatewayClientCliTests
|
|||||||
Assert.DoesNotContain("ON_WRITE_COMPLETE", output.ToString());
|
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>
|
/// <summary>Verifies that stream-alarms with --max-events stops output and distinguishes payload cases.</summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_StreamAlarms_WithMaxEventsStopsAndDistinguishesPayloadCases()
|
public async Task RunAsync_StreamAlarms_WithMaxEventsStopsAndDistinguishesPayloadCases()
|
||||||
{
|
{
|
||||||
@@ -193,7 +288,6 @@ public sealed class MxGatewayClientCliTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that acknowledge-alarm builds a request and prints the JSON reply.</summary>
|
/// <summary>Verifies that acknowledge-alarm builds a request and prints the JSON reply.</summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_AcknowledgeAlarm_BuildsRequestAndPrintsJsonReply()
|
public async Task RunAsync_AcknowledgeAlarm_BuildsRequestAndPrintsJsonReply()
|
||||||
{
|
{
|
||||||
@@ -236,7 +330,6 @@ public sealed class MxGatewayClientCliTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that smoke command closes opened session when a command fails.</summary>
|
/// <summary>Verifies that smoke command closes opened session when a command fails.</summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_Smoke_WhenCommandFails_ClosesOpenedSession()
|
public async Task RunAsync_Smoke_WhenCommandFails_ClosesOpenedSession()
|
||||||
{
|
{
|
||||||
@@ -268,7 +361,6 @@ public sealed class MxGatewayClientCliTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that galaxy-test-connection command prints JSON reply.</summary>
|
/// <summary>Verifies that galaxy-test-connection command prints JSON reply.</summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_GalaxyTestConnection_PrintsJsonReply()
|
public async Task RunAsync_GalaxyTestConnection_PrintsJsonReply()
|
||||||
{
|
{
|
||||||
@@ -299,7 +391,6 @@ public sealed class MxGatewayClientCliTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that galaxy-discover command prints hierarchy summary.</summary>
|
/// <summary>Verifies that galaxy-discover command prints hierarchy summary.</summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_GalaxyDiscover_PrintsHierarchySummary()
|
public async Task RunAsync_GalaxyDiscover_PrintsHierarchySummary()
|
||||||
{
|
{
|
||||||
@@ -370,7 +461,6 @@ public sealed class MxGatewayClientCliTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that galaxy-watch command prints text output for deploy events.</summary>
|
/// <summary>Verifies that galaxy-watch command prints text output for deploy events.</summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_GalaxyWatch_PrintsTextOutputForEvents()
|
public async Task RunAsync_GalaxyWatch_PrintsTextOutputForEvents()
|
||||||
{
|
{
|
||||||
@@ -425,7 +515,6 @@ public sealed class MxGatewayClientCliTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that galaxy-watch with --json emits one JSON object per event.</summary>
|
/// <summary>Verifies that galaxy-watch with --json emits one JSON object per event.</summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_GalaxyWatch_JsonEmitsOneObjectPerEvent()
|
public async Task RunAsync_GalaxyWatch_JsonEmitsOneObjectPerEvent()
|
||||||
{
|
{
|
||||||
@@ -460,420 +549,139 @@ public sealed class MxGatewayClientCliTests
|
|||||||
Assert.Contains("\"objectCount\": 99", text);
|
Assert.Contains("\"objectCount\": 99", text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that batch mode dispatches a single version command and emits the EOR sentinel.</summary>
|
/// <summary>Verifies that batch mode executes a single no-gateway command and writes the EOR sentinel.</summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_Batch_DispatchesVersionAndWritesEndOfRecord()
|
public async Task RunAsync_Batch_SingleVersionCommand_WritesOutputAndEorSentinel()
|
||||||
{
|
{
|
||||||
using var output = new StringWriter();
|
using var output = new StringWriter();
|
||||||
using var error = new StringWriter();
|
using var error = new StringWriter();
|
||||||
using var input = new StringReader("version --json\n");
|
using var stdin = new StringReader("version --json\n");
|
||||||
|
|
||||||
int exitCode = await MxGatewayClientCli.RunAsync(
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||||
["batch"],
|
["batch"],
|
||||||
output,
|
output,
|
||||||
error,
|
error,
|
||||||
clientFactory: null,
|
clientFactory: null,
|
||||||
standardInput: input);
|
standardInput: stdin);
|
||||||
|
|
||||||
Assert.Equal(0, exitCode);
|
Assert.Equal(0, exitCode);
|
||||||
string text = output.ToString();
|
string text = output.ToString();
|
||||||
Assert.Contains("\"gatewayProtocolVersion\":3", text);
|
Assert.Contains("\"gatewayProtocolVersion\"", text);
|
||||||
Assert.Contains("__MXGW_BATCH_EOR__", text);
|
Assert.Contains("__MXGW_BATCH_EOR__", text);
|
||||||
// The EOR marker must come after the JSON output.
|
// Sentinel must appear after the output, not before.
|
||||||
int jsonIndex = text.IndexOf("\"gatewayProtocolVersion\"", StringComparison.Ordinal);
|
int outputIdx = text.IndexOf("gatewayProtocolVersion", StringComparison.Ordinal);
|
||||||
int eorIndex = text.IndexOf("__MXGW_BATCH_EOR__", StringComparison.Ordinal);
|
int eorIdx = text.IndexOf("__MXGW_BATCH_EOR__", StringComparison.Ordinal);
|
||||||
Assert.True(jsonIndex >= 0 && eorIndex > jsonIndex);
|
Assert.True(outputIdx < eorIdx, "EOR sentinel must follow command output.");
|
||||||
Assert.Equal(string.Empty, error.ToString());
|
Assert.Equal(string.Empty, error.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that batch mode routes per-command errors to stdout as JSON between EOR markers.</summary>
|
/// <summary>Verifies that batch mode processes two commands sequentially and writes two EOR sentinels.</summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_Batch_WritesErrorsToStdoutAsJson()
|
public async Task RunAsync_Batch_TwoVersionCommands_WritesTwoEorSentinels()
|
||||||
{
|
{
|
||||||
using var output = new StringWriter();
|
using var output = new StringWriter();
|
||||||
using var error = new StringWriter();
|
using var error = new StringWriter();
|
||||||
// Unknown command should produce an error on the captured error stream,
|
// Two commands followed by EOF (end of string).
|
||||||
// which batch mode re-emits to stdout inside the same delimited block.
|
using var stdin = new StringReader("version\nversion --json\n");
|
||||||
using var input = new StringReader("nope-not-a-command\nversion\n");
|
|
||||||
|
|
||||||
int exitCode = await MxGatewayClientCli.RunAsync(
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||||
["batch"],
|
["batch"],
|
||||||
output,
|
output,
|
||||||
error,
|
error,
|
||||||
clientFactory: null,
|
clientFactory: null,
|
||||||
standardInput: input);
|
standardInput: stdin);
|
||||||
|
|
||||||
Assert.Equal(0, exitCode);
|
Assert.Equal(0, exitCode);
|
||||||
string text = output.ToString();
|
string text = output.ToString();
|
||||||
// Two records → two EOR markers.
|
|
||||||
int firstEor = text.IndexOf("__MXGW_BATCH_EOR__", StringComparison.Ordinal);
|
int firstEor = text.IndexOf("__MXGW_BATCH_EOR__", StringComparison.Ordinal);
|
||||||
int secondEor = text.IndexOf(
|
int secondEor = text.IndexOf("__MXGW_BATCH_EOR__", firstEor + 1, StringComparison.Ordinal);
|
||||||
"__MXGW_BATCH_EOR__",
|
Assert.True(firstEor >= 0, "First EOR sentinel must be present.");
|
||||||
firstEor + 1,
|
Assert.True(secondEor > firstEor, "Second EOR sentinel must follow first.");
|
||||||
StringComparison.Ordinal);
|
Assert.Equal(string.Empty, error.ToString());
|
||||||
Assert.True(firstEor > 0);
|
|
||||||
Assert.True(secondEor > firstEor);
|
|
||||||
// The unknown-command error message must be on stdout (not on stderr).
|
|
||||||
Assert.Contains("nope-not-a-command", text);
|
|
||||||
Assert.DoesNotContain("nope-not-a-command", error.ToString());
|
|
||||||
// The follow-up `version` line should still succeed.
|
|
||||||
Assert.Contains("gateway-protocol=", text);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Verifies that batch mode on EOF (empty stdin) exits 0 immediately without writing any sentinel.</summary>
|
||||||
/// Client.Dotnet-018: the README CLI examples for the alarm subcommands at
|
|
||||||
/// `clients/dotnet/README.md` must drive cleanly through the production
|
|
||||||
/// CLI argument parser. The previous text used non-existent flags
|
|
||||||
/// (`--session-id`, `--max-messages`, `--alarm-reference`) that would
|
|
||||||
/// fail with "Unknown command" / "Missing required option --reference".
|
|
||||||
/// Each documented example is extracted from the README, parsed via the
|
|
||||||
/// production <see cref="MxGatewayClientCli.RunAsync"/>, and asserted
|
|
||||||
/// against exit code 0.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="command">The alarm subcommand to validate (e.g. "stream-alarms", "acknowledge-alarm").</param>
|
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Theory]
|
|
||||||
[InlineData("stream-alarms")]
|
|
||||||
[InlineData("acknowledge-alarm")]
|
|
||||||
public async Task RunAsync_ReadmeExamples_ForAlarmCommands_ParseSuccessfully(string command)
|
|
||||||
{
|
|
||||||
string readme = LocateClientReadme();
|
|
||||||
string[] commandLine = ExtractReadmeCommandLine(readme, command);
|
|
||||||
// The documented examples do not include --api-key (the README assumes
|
|
||||||
// the env var path documented elsewhere). Inject an API key via the
|
|
||||||
// standard env var so CreateOptions succeeds and the parser fully
|
|
||||||
// exercises the documented flag shape.
|
|
||||||
string? previousKey = Environment.GetEnvironmentVariable("MXGATEWAY_API_KEY");
|
|
||||||
Environment.SetEnvironmentVariable("MXGATEWAY_API_KEY", "test-api-key");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var output = new StringWriter();
|
|
||||||
using var error = new StringWriter();
|
|
||||||
FakeCliClient fakeClient = new();
|
|
||||||
fakeClient.AlarmFeedMessages.Add(new AlarmFeedMessage
|
|
||||||
{
|
|
||||||
ActiveAlarm = new ActiveAlarmSnapshot { AlarmFullReference = "fixture" },
|
|
||||||
});
|
|
||||||
fakeClient.AcknowledgeAlarmReplies.Enqueue(new AcknowledgeAlarmReply
|
|
||||||
{
|
|
||||||
CorrelationId = "ack-fixture",
|
|
||||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
|
||||||
});
|
|
||||||
|
|
||||||
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
||||||
commandLine,
|
|
||||||
output,
|
|
||||||
error,
|
|
||||||
_ => fakeClient);
|
|
||||||
|
|
||||||
Assert.True(
|
|
||||||
exitCode == 0,
|
|
||||||
$"README example for '{command}' exited {exitCode}; stderr=<<{error}>>");
|
|
||||||
Assert.DoesNotContain("Unknown command", error.ToString());
|
|
||||||
Assert.DoesNotContain("Missing required option", error.ToString());
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
Environment.SetEnvironmentVariable("MXGATEWAY_API_KEY", previousKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Client.Dotnet-019: `BenchReadBulkAsync` previously fell back to
|
|
||||||
/// <c>reply.ReturnValue.Int32Value</c> when the register reply had no
|
|
||||||
/// typed <c>Register</c> payload, silently driving the rest of the bench
|
|
||||||
/// against a zero server handle. The fix must fail loudly with a
|
|
||||||
/// descriptive <see cref="MxGatewayException"/>.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_BenchReadBulk_WhenRegisterReplyMissingTypedPayload_FailsLoudly()
|
public async Task RunAsync_Batch_EmptyStdin_ExitsZeroWithNoOutput()
|
||||||
{
|
{
|
||||||
using var output = new StringWriter();
|
using var output = new StringWriter();
|
||||||
using var error = new StringWriter();
|
using var error = new StringWriter();
|
||||||
FakeCliClient fakeClient = new();
|
using var stdin = new StringReader(string.Empty);
|
||||||
// Successful protocol + MX status but no typed `Register` payload.
|
|
||||||
// Before the Client.Dotnet-019 fix this silently became serverHandle=0
|
|
||||||
// and the bench proceeded through SubscribeBulk / warmup / steady-state
|
|
||||||
// against an invalid handle, producing a misleading zero-result summary.
|
|
||||||
fakeClient.InvokeReplies.Enqueue(new MxCommandReply
|
|
||||||
{
|
|
||||||
SessionId = "session-fixture",
|
|
||||||
Kind = MxCommandKind.Register,
|
|
||||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
|
||||||
});
|
|
||||||
|
|
||||||
int exitCode = await MxGatewayClientCli.RunAsync(
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||||
[
|
["batch"],
|
||||||
"bench-read-bulk",
|
|
||||||
"--endpoint",
|
|
||||||
"http://localhost:5000",
|
|
||||||
"--api-key",
|
|
||||||
"test-api-key",
|
|
||||||
"--duration-seconds",
|
|
||||||
"1",
|
|
||||||
"--warmup-seconds",
|
|
||||||
"0",
|
|
||||||
"--bulk-size",
|
|
||||||
"1",
|
|
||||||
],
|
|
||||||
output,
|
output,
|
||||||
error,
|
error,
|
||||||
_ => fakeClient);
|
clientFactory: null,
|
||||||
|
standardInput: stdin);
|
||||||
|
|
||||||
Assert.Equal(1, exitCode);
|
Assert.Equal(0, exitCode);
|
||||||
// Descriptive message that names the missing typed payload.
|
Assert.Equal(string.Empty, output.ToString());
|
||||||
string err = error.ToString();
|
Assert.Equal(string.Empty, error.ToString());
|
||||||
Assert.Contains("Register", err);
|
|
||||||
// The bench must not produce any aggregate stats JSON.
|
|
||||||
Assert.DoesNotContain("bench-read-bulk", output.ToString());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Client.Dotnet-020: the steady-state loop in `BenchReadBulkAsync` had a
|
/// Verifies that batch mode continues after a command failure and writes the error JSON
|
||||||
/// bare `catch { failedCalls++; continue; }` that swallowed
|
/// to stdout (not stderr), followed by the EOR sentinel.
|
||||||
/// <see cref="OperationCanceledException"/>, so token-driven cancellation
|
|
||||||
/// kept spinning until <c>--duration-seconds</c> elapsed. After the fix
|
|
||||||
/// the bench must exit promptly when the supplied token cancels.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_BenchReadBulk_WhenSteadyStateLoopReceivesCancellation_ExitsPromptly()
|
public async Task RunAsync_Batch_CommandFailure_WritesErrorJsonToStdoutAndContinues()
|
||||||
{
|
{
|
||||||
using var output = new StringWriter();
|
using var output = new StringWriter();
|
||||||
using var error = new StringWriter();
|
using var error = new StringWriter();
|
||||||
|
// First line: a gateway command with no API key (will fail).
|
||||||
int invokeCount = 0;
|
// Second line: version (will succeed).
|
||||||
FakeCliClient fakeClient = new()
|
using var stdin = new StringReader("open-session --endpoint http://localhost:5000\nversion --json\n");
|
||||||
{
|
|
||||||
InvokeHandler = (request, ct) =>
|
|
||||||
{
|
|
||||||
int n = Interlocked.Increment(ref invokeCount);
|
|
||||||
|
|
||||||
// Reply 1 = Register (success with typed payload).
|
|
||||||
if (request.Command.Kind == MxCommandKind.Register)
|
|
||||||
{
|
|
||||||
return Task.FromResult(new MxCommandReply
|
|
||||||
{
|
|
||||||
SessionId = "session-fixture",
|
|
||||||
Kind = MxCommandKind.Register,
|
|
||||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
|
||||||
Register = new RegisterReply { ServerHandle = 1 },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reply 2 = SubscribeBulk (success).
|
|
||||||
if (request.Command.Kind == MxCommandKind.SubscribeBulk)
|
|
||||||
{
|
|
||||||
var subscribeReply = new MxCommandReply
|
|
||||||
{
|
|
||||||
SessionId = "session-fixture",
|
|
||||||
Kind = MxCommandKind.SubscribeBulk,
|
|
||||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
|
||||||
SubscribeBulk = new BulkSubscribeReply(),
|
|
||||||
};
|
|
||||||
return Task.FromResult(subscribeReply);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadBulk reply 1 = success (so the steady-state loop enters
|
|
||||||
// and starts iterating). Reply 2+ = simulated cancellation.
|
|
||||||
if (request.Command.Kind == MxCommandKind.ReadBulk && n <= 3)
|
|
||||||
{
|
|
||||||
return Task.FromResult(new MxCommandReply
|
|
||||||
{
|
|
||||||
SessionId = "session-fixture",
|
|
||||||
Kind = MxCommandKind.ReadBulk,
|
|
||||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
|
||||||
ReadBulk = new BulkReadReply(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// From here on every ReadBulk throws OCE — the steady-state
|
|
||||||
// loop must exit promptly rather than spinning until
|
|
||||||
// --duration-seconds elapses.
|
|
||||||
throw new OperationCanceledException();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
|
||||||
await Assert.ThrowsAsync<OperationCanceledException>(async () =>
|
|
||||||
await MxGatewayClientCli.RunAsync(
|
|
||||||
[
|
|
||||||
"bench-read-bulk",
|
|
||||||
"--endpoint",
|
|
||||||
"http://localhost:5000",
|
|
||||||
"--api-key",
|
|
||||||
"test-api-key",
|
|
||||||
"--duration-seconds",
|
|
||||||
"30",
|
|
||||||
"--warmup-seconds",
|
|
||||||
"0",
|
|
||||||
"--bulk-size",
|
|
||||||
"1",
|
|
||||||
],
|
|
||||||
output,
|
|
||||||
error,
|
|
||||||
_ => fakeClient));
|
|
||||||
sw.Stop();
|
|
||||||
|
|
||||||
// Without the fix the loop swallows OCE and continues until the 30 s
|
|
||||||
// steady-state deadline expires. With the fix it exits as soon as OCE
|
|
||||||
// surfaces. Generous 10 s ceiling to keep the test stable under load.
|
|
||||||
Assert.True(
|
|
||||||
sw.Elapsed < TimeSpan.FromSeconds(10),
|
|
||||||
$"Bench did not exit promptly on cancellation; took {sw.Elapsed}.");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Client.Dotnet-021: both `ReadBulkAsync` and `BenchReadBulkAsync` cast
|
|
||||||
/// the user-supplied <c>--timeout-ms</c> to <see cref="uint"/> without
|
|
||||||
/// bounds checking, so a negative value (e.g. <c>-1</c>) silently wraps
|
|
||||||
/// to ~49.7 days. The fix must reject negatives with a clear error.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="command">The bulk-read subcommand to validate (e.g. "read-bulk", "bench-read-bulk").</param>
|
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Theory]
|
|
||||||
[InlineData("read-bulk")]
|
|
||||||
[InlineData("bench-read-bulk")]
|
|
||||||
public async Task RunAsync_TimeoutMs_NegativeValue_RejectsWithClearError(string command)
|
|
||||||
{
|
|
||||||
using var output = new StringWriter();
|
|
||||||
using var error = new StringWriter();
|
|
||||||
FakeCliClient fakeClient = new();
|
|
||||||
|
|
||||||
string[] args = command is "read-bulk"
|
|
||||||
? [
|
|
||||||
"read-bulk",
|
|
||||||
"--endpoint",
|
|
||||||
"http://localhost:5000",
|
|
||||||
"--api-key",
|
|
||||||
"test-api-key",
|
|
||||||
"--session-id",
|
|
||||||
"session-fixture",
|
|
||||||
"--server-handle",
|
|
||||||
"1",
|
|
||||||
"--items",
|
|
||||||
"Area001.Pump001.Speed",
|
|
||||||
"--timeout-ms",
|
|
||||||
"-1",
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
"bench-read-bulk",
|
|
||||||
"--endpoint",
|
|
||||||
"http://localhost:5000",
|
|
||||||
"--api-key",
|
|
||||||
"test-api-key",
|
|
||||||
"--duration-seconds",
|
|
||||||
"1",
|
|
||||||
"--warmup-seconds",
|
|
||||||
"0",
|
|
||||||
"--bulk-size",
|
|
||||||
"1",
|
|
||||||
"--timeout-ms",
|
|
||||||
"-1",
|
|
||||||
];
|
|
||||||
|
|
||||||
int exitCode = await MxGatewayClientCli.RunAsync(
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||||
args,
|
["batch"],
|
||||||
output,
|
output,
|
||||||
error,
|
error,
|
||||||
_ => fakeClient);
|
clientFactory: _ => throw new InvalidOperationException("injected failure"),
|
||||||
|
standardInput: stdin);
|
||||||
|
|
||||||
Assert.NotEqual(0, exitCode);
|
Assert.Equal(0, exitCode);
|
||||||
string err = error.ToString();
|
string text = output.ToString();
|
||||||
Assert.Contains("timeout-ms", err);
|
|
||||||
Assert.Contains("non-negative", err);
|
// 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>
|
/// <summary>Verifies that batch mode treats an empty (blank) line as EOF and exits 0.</summary>
|
||||||
/// Locates the .NET client README by walking up from the test assembly's
|
[Fact]
|
||||||
/// base directory until <c>clients/dotnet/README.md</c> is found. Keeps
|
public async Task RunAsync_Batch_EmptyLine_ExitsZeroAfterPreviousCommands()
|
||||||
/// the regression test independent of the current working directory.
|
|
||||||
/// </summary>
|
|
||||||
private static string LocateClientReadme()
|
|
||||||
{
|
{
|
||||||
string? directory = AppContext.BaseDirectory;
|
using var output = new StringWriter();
|
||||||
while (!string.IsNullOrEmpty(directory))
|
using var error = new StringWriter();
|
||||||
{
|
// One command, then an empty line (stop signal), then another command that must NOT run.
|
||||||
string candidate = Path.Combine(directory, "clients", "dotnet", "README.md");
|
using var stdin = new StringReader("version --json\n\nversion --json\n");
|
||||||
if (File.Exists(candidate))
|
|
||||||
{
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
|
|
||||||
directory = Path.GetDirectoryName(directory);
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||||
}
|
["batch"],
|
||||||
|
output,
|
||||||
|
error,
|
||||||
|
clientFactory: null,
|
||||||
|
standardInput: stdin);
|
||||||
|
|
||||||
throw new FileNotFoundException("clients/dotnet/README.md not found above test assembly base directory.");
|
Assert.Equal(0, exitCode);
|
||||||
}
|
string text = output.ToString();
|
||||||
|
// Only one EOR sentinel — the second command after the empty line must not execute.
|
||||||
/// <summary>
|
int firstEor = text.IndexOf("__MXGW_BATCH_EOR__", StringComparison.Ordinal);
|
||||||
/// Extracts the documented CLI invocation for the requested subcommand
|
int secondEor = text.IndexOf("__MXGW_BATCH_EOR__", firstEor + 1, StringComparison.Ordinal);
|
||||||
/// from the README, returning only the arguments after the
|
Assert.True(firstEor >= 0, "One EOR sentinel must be present.");
|
||||||
/// <c>mxgw-dotnet</c>-equivalent prefix so they can be passed straight
|
Assert.Equal(-1, secondEor);
|
||||||
/// to <see cref="MxGatewayClientCli.RunAsync"/>.
|
Assert.Equal(string.Empty, error.ToString());
|
||||||
/// </summary>
|
|
||||||
private static string[] ExtractReadmeCommandLine(string readmePath, string command)
|
|
||||||
{
|
|
||||||
string[] lines = File.ReadAllLines(readmePath);
|
|
||||||
// Look for the documented `dotnet run ... -- <command> ...` line.
|
|
||||||
foreach (string line in lines)
|
|
||||||
{
|
|
||||||
int dashes = line.IndexOf("-- " + command, StringComparison.Ordinal);
|
|
||||||
if (dashes < 0)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
string after = line[(dashes + 3)..].Trim();
|
|
||||||
// Tokenize by whitespace, respecting "..." quoted segments.
|
|
||||||
return TokenizeCommandLine(after);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"README at '{readmePath}' has no documented example for subcommand '{command}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Splits a single command-line string into argv tokens, honouring
|
|
||||||
/// double-quoted segments so paths with embedded spaces survive intact.
|
|
||||||
/// </summary>
|
|
||||||
private static string[] TokenizeCommandLine(string input)
|
|
||||||
{
|
|
||||||
var tokens = new List<string>();
|
|
||||||
var current = new System.Text.StringBuilder();
|
|
||||||
bool inQuotes = false;
|
|
||||||
|
|
||||||
foreach (char ch in input)
|
|
||||||
{
|
|
||||||
if (ch == '"')
|
|
||||||
{
|
|
||||||
inQuotes = !inQuotes;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!inQuotes && char.IsWhiteSpace(ch))
|
|
||||||
{
|
|
||||||
if (current.Length > 0)
|
|
||||||
{
|
|
||||||
tokens.Add(current.ToString());
|
|
||||||
current.Clear();
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
current.Append(ch);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current.Length > 0)
|
|
||||||
{
|
|
||||||
tokens.Add(current.ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
return tokens.ToArray();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Fake CLI client for testing.</summary>
|
/// <summary>Fake CLI client for testing.</summary>
|
||||||
@@ -894,11 +702,15 @@ public sealed class MxGatewayClientCliTests
|
|||||||
/// <summary>Exception to throw on invoke, if any.</summary>
|
/// <summary>Exception to throw on invoke, if any.</summary>
|
||||||
public Exception? InvokeFailure { get; init; }
|
public Exception? InvokeFailure { get; init; }
|
||||||
|
|
||||||
/// <summary>Optional per-call handler that overrides queue-based behaviour.</summary>
|
/// <summary>
|
||||||
public Func<MxCommandRequest, CancellationToken, Task<MxCommandReply>>? InvokeHandler { get; init; }
|
/// 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; }
|
||||||
|
|
||||||
/// <summary>Releases resources held by the fake CLI client.</summary>
|
/// <inheritdoc />
|
||||||
/// <returns>A completed value task.</returns>
|
|
||||||
public ValueTask DisposeAsync()
|
public ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
return ValueTask.CompletedTask;
|
return ValueTask.CompletedTask;
|
||||||
@@ -943,11 +755,6 @@ public sealed class MxGatewayClientCliTests
|
|||||||
throw InvokeFailure;
|
throw InvokeFailure;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (InvokeHandler is not null)
|
|
||||||
{
|
|
||||||
return InvokeHandler(request, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Task.FromResult(InvokeReplies.Dequeue());
|
return Task.FromResult(InvokeReplies.Dequeue());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -962,6 +769,11 @@ public sealed class MxGatewayClientCliTests
|
|||||||
await Task.Yield();
|
await Task.Yield();
|
||||||
yield return gatewayEvent;
|
yield return gatewayEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (StreamHangAfterEvents is not null)
|
||||||
|
{
|
||||||
|
await StreamHangAfterEvents(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Queue of acknowledge-alarm replies to return.</summary>
|
/// <summary>Queue of acknowledge-alarm replies to return.</summary>
|
||||||
@@ -1008,7 +820,6 @@ public sealed class MxGatewayClientCliTests
|
|||||||
/// <summary>Galaxy discover hierarchy reply to return.</summary>
|
/// <summary>Galaxy discover hierarchy reply to return.</summary>
|
||||||
public DiscoverHierarchyReply GalaxyDiscoverHierarchyReply { get; set; } = new();
|
public DiscoverHierarchyReply GalaxyDiscoverHierarchyReply { get; set; } = new();
|
||||||
|
|
||||||
/// <summary>Queue of galaxy discover hierarchy replies to return.</summary>
|
|
||||||
public Queue<DiscoverHierarchyReply> GalaxyDiscoverHierarchyReplies { get; } = new();
|
public Queue<DiscoverHierarchyReply> GalaxyDiscoverHierarchyReplies { get; } = new();
|
||||||
|
|
||||||
/// <summary>List of received galaxy test connection requests.</summary>
|
/// <summary>List of received galaxy test connection requests.</summary>
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
using ZB.MOM.WW.MxGateway.Contracts;
|
using MxGateway.Contracts;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
namespace MxGateway.Client.Tests;
|
||||||
|
|
||||||
public sealed class MxGatewayClientContractInfoTests
|
public sealed class MxGatewayClientContractInfoTests
|
||||||
{
|
{
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
namespace MxGateway.Client.Tests;
|
||||||
|
|
||||||
public sealed class MxGatewayClientOptionsTests
|
public sealed class MxGatewayClientOptionsTests
|
||||||
{
|
{
|
||||||
+245
-15
@@ -1,13 +1,12 @@
|
|||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
namespace MxGateway.Client.Tests;
|
||||||
|
|
||||||
/// <summary>Tests for MxGatewaySession and client command behavior.</summary>
|
/// <summary>Tests for MxGatewaySession and client command behavior.</summary>
|
||||||
public sealed class MxGatewayClientSessionTests
|
public sealed class MxGatewayClientSessionTests
|
||||||
{
|
{
|
||||||
/// <summary>Verifies that open session attaches API key metadata and cancellation token.</summary>
|
/// <summary>Verifies that open session attaches API key metadata and cancellation token.</summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task OpenSessionRawAsync_AttachesApiKeyMetadataAndCancellation()
|
public async Task OpenSessionRawAsync_AttachesApiKeyMetadataAndCancellation()
|
||||||
{
|
{
|
||||||
@@ -23,7 +22,6 @@ public sealed class MxGatewayClientSessionTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that open session returns a session with the raw open reply.</summary>
|
/// <summary>Verifies that open session returns a session with the raw open reply.</summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task OpenSessionAsync_ReturnsSessionWithRawOpenReply()
|
public async Task OpenSessionAsync_ReturnsSessionWithRawOpenReply()
|
||||||
{
|
{
|
||||||
@@ -39,7 +37,6 @@ public sealed class MxGatewayClientSessionTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that register builds a register command and returns server handle.</summary>
|
/// <summary>Verifies that register builds a register command and returns server handle.</summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RegisterAsync_BuildsRegisterCommandAndReturnsServerHandle()
|
public async Task RegisterAsync_BuildsRegisterCommandAndReturnsServerHandle()
|
||||||
{
|
{
|
||||||
@@ -65,7 +62,6 @@ public sealed class MxGatewayClientSessionTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that add item 2 builds a command with the specified context.</summary>
|
/// <summary>Verifies that add item 2 builds a command with the specified context.</summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AddItem2Async_BuildsAddItem2CommandWithContext()
|
public async Task AddItem2Async_BuildsAddItem2CommandWithContext()
|
||||||
{
|
{
|
||||||
@@ -91,7 +87,6 @@ public sealed class MxGatewayClientSessionTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that write raw builds a write command with the raw value.</summary>
|
/// <summary>Verifies that write raw builds a write command with the raw value.</summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WriteRawAsync_BuildsWriteCommandWithRawValue()
|
public async Task WriteRawAsync_BuildsWriteCommandWithRawValue()
|
||||||
{
|
{
|
||||||
@@ -123,7 +118,6 @@ public sealed class MxGatewayClientSessionTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that write 2 raw builds a write 2 command with value and timestamp.</summary>
|
/// <summary>Verifies that write 2 raw builds a write 2 command with value and timestamp.</summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Write2RawAsync_BuildsWrite2CommandWithValueAndTimestamp()
|
public async Task Write2RawAsync_BuildsWrite2CommandWithValueAndTimestamp()
|
||||||
{
|
{
|
||||||
@@ -152,7 +146,6 @@ public sealed class MxGatewayClientSessionTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that subscribe bulk builds one command and returns per-item results.</summary>
|
/// <summary>Verifies that subscribe bulk builds one command and returns per-item results.</summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task SubscribeBulkAsync_BuildsOneBulkCommandAndReturnsPerItemResults()
|
public async Task SubscribeBulkAsync_BuildsOneBulkCommandAndReturnsPerItemResults()
|
||||||
{
|
{
|
||||||
@@ -191,8 +184,97 @@ public sealed class MxGatewayClientSessionTests
|
|||||||
Assert.Equal(["Area001.Pump001.Speed"], request.Command.SubscribeBulk.TagAddresses);
|
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>
|
/// <summary>Verifies that stream events yields events in the order received from the gateway.</summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task StreamEventsAsync_YieldsEventsInGatewayOrder()
|
public async Task StreamEventsAsync_YieldsEventsInGatewayOrder()
|
||||||
{
|
{
|
||||||
@@ -224,7 +306,6 @@ public sealed class MxGatewayClientSessionTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that close is explicit and idempotent.</summary>
|
/// <summary>Verifies that close is explicit and idempotent.</summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CloseAsync_IsExplicitAndIdempotent()
|
public async Task CloseAsync_IsExplicitAndIdempotent()
|
||||||
{
|
{
|
||||||
@@ -240,8 +321,53 @@ public sealed class MxGatewayClientSessionTests
|
|||||||
Assert.Equal("session-fixture", call.Request.SessionId);
|
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>
|
/// <summary>Verifies that invoke retries safe diagnostic commands on transient RPC failure.</summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task InvokeAsync_RetriesSafeDiagnosticCommandOnTransientGrpcFailure()
|
public async Task InvokeAsync_RetriesSafeDiagnosticCommandOnTransientGrpcFailure()
|
||||||
{
|
{
|
||||||
@@ -265,8 +391,36 @@ public sealed class MxGatewayClientSessionTests
|
|||||||
Assert.Equal(2, transport.InvokeCalls.Count);
|
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>
|
/// <summary>Verifies that open session does not retry on transient RPC failure.</summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task OpenSessionAsync_DoesNotRetryTransientGrpcFailure()
|
public async Task OpenSessionAsync_DoesNotRetryTransientGrpcFailure()
|
||||||
{
|
{
|
||||||
@@ -280,7 +434,6 @@ public sealed class MxGatewayClientSessionTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that invoke does not retry write commands on transient RPC failure.</summary>
|
/// <summary>Verifies that invoke does not retry write commands on transient RPC failure.</summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task InvokeAsync_DoesNotRetryWriteCommand()
|
public async Task InvokeAsync_DoesNotRetryWriteCommand()
|
||||||
{
|
{
|
||||||
@@ -296,7 +449,6 @@ public sealed class MxGatewayClientSessionTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that invoke helpers pass cancellation token to the transport.</summary>
|
/// <summary>Verifies that invoke helpers pass cancellation token to the transport.</summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task InvokeHelpers_PassCancellationTokenToTransport()
|
public async Task InvokeHelpers_PassCancellationTokenToTransport()
|
||||||
{
|
{
|
||||||
@@ -316,6 +468,84 @@ public sealed class MxGatewayClientSessionTests
|
|||||||
Assert.Equal(cancellation.Token, Assert.Single(transport.InvokeCalls).CallOptions.CancellationToken);
|
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)
|
private static MxGatewayClient CreateClient(FakeGatewayTransport transport)
|
||||||
{
|
{
|
||||||
return new MxGatewayClient(transport.Options, transport);
|
return new MxGatewayClient(transport.Options, transport);
|
||||||
+1
-2
@@ -1,9 +1,8 @@
|
|||||||
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
namespace MxGateway.Client.Tests;
|
||||||
|
|
||||||
public sealed class MxGatewayGeneratedContractTests
|
public sealed class MxGatewayGeneratedContractTests
|
||||||
{
|
{
|
||||||
/// <summary>Verifies that the generated gRPC client can be instantiated from the client factory.</summary>
|
/// <summary>Verifies that the generated gRPC client can be instantiated from the client factory.</summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GeneratedGrpcClient_CanBeConstructedFromClientFactory()
|
public async Task GeneratedGrpcClient_CanBeConstructedFromClientFactory()
|
||||||
{
|
{
|
||||||
+3
-3
@@ -1,9 +1,9 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Google.Protobuf;
|
using Google.Protobuf;
|
||||||
using ZB.MOM.WW.MxGateway.Client;
|
using MxGateway.Client;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
namespace MxGateway.Client.Tests;
|
||||||
|
|
||||||
public sealed class MxStatusProxyExtensionsTests
|
public sealed class MxStatusProxyExtensionsTests
|
||||||
{
|
{
|
||||||
+3
-3
@@ -1,9 +1,9 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Google.Protobuf;
|
using Google.Protobuf;
|
||||||
using ZB.MOM.WW.MxGateway.Client;
|
using MxGateway.Client;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
namespace MxGateway.Client.Tests;
|
||||||
|
|
||||||
public sealed class MxValueExtensionsTests
|
public sealed class MxValueExtensionsTests
|
||||||
{
|
{
|
||||||
@@ -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,76 @@
|
|||||||
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
VisualStudioVersion = 17.0.31903.59
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.Client", "MxGateway.Client\MxGateway.Client.csproj", "{7CF9ED88-1F32-4040-BEB1-D0902E304C70}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.Contracts", "..\..\src\MxGateway.Contracts\MxGateway.Contracts.csproj", "{9AB807A8-0469-40F7-A000-D240F36B6E5D}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.Client.Cli", "MxGateway.Client.Cli\MxGateway.Client.Cli.csproj", "{EB061E77-2475-4322-9257-3F2456DD141C}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.Client.Tests", "MxGateway.Client.Tests\MxGateway.Client.Tests.csproj", "{B77B5A8E-0C53-4419-9BCD-227C9753A074}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Debug|x64 = Debug|x64
|
||||||
|
Debug|x86 = Debug|x86
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
Release|x64 = Release|x64
|
||||||
|
Release|x86 = Release|x86
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
+17
-114
@@ -2,14 +2,14 @@ using Google.Protobuf.WellKnownTypes;
|
|||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
using Grpc.Net.Client;
|
using Grpc.Net.Client;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
using MxGateway.Contracts.Proto.Galaxy;
|
||||||
using Polly;
|
using Polly;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Net.Security;
|
using System.Net.Security;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
using System.Security.Cryptography.X509Certificates;
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Client;
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Provides the .NET client entry point for the public Galaxy Repository gRPC API.
|
/// Provides the .NET client entry point for the public Galaxy Repository gRPC API.
|
||||||
@@ -19,12 +19,11 @@ namespace ZB.MOM.WW.MxGateway.Client;
|
|||||||
public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
||||||
{
|
{
|
||||||
private const int DiscoverHierarchyPageSize = 5000;
|
private const int DiscoverHierarchyPageSize = 5000;
|
||||||
private const int BrowseChildrenPageSize = 500;
|
|
||||||
|
|
||||||
private readonly GrpcChannel? _channel;
|
private readonly GrpcChannel? _channel;
|
||||||
private readonly IGalaxyRepositoryClientTransport _transport;
|
private readonly IGalaxyRepositoryClientTransport _transport;
|
||||||
private readonly ResiliencePipeline _safeUnaryRetryPipeline;
|
private readonly ResiliencePipeline _safeUnaryRetryPipeline;
|
||||||
private bool _disposed;
|
private int _disposed;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a Galaxy Repository client with custom transport and options.
|
/// Initializes a Galaxy Repository client with custom transport and options.
|
||||||
@@ -183,10 +182,17 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
|||||||
return await DiscoverHierarchyAsync(new DiscoverHierarchyOptions(), cancellationToken).ConfigureAwait(false);
|
return await DiscoverHierarchyAsync(new DiscoverHierarchyOptions(), cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Discovers the Galaxy object hierarchy.</summary>
|
/// <summary>
|
||||||
/// <param name="options">Client configuration options.</param>
|
/// Enumerates the deployed Galaxy object hierarchy with caller-supplied
|
||||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
/// server-side filters. Each returned <see cref="GalaxyObject"/> may include
|
||||||
/// <returns>The collection of Galaxy objects in the hierarchy.</returns>
|
/// 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(
|
public async Task<IReadOnlyList<GalaxyObject>> DiscoverHierarchyAsync(
|
||||||
DiscoverHierarchyOptions options,
|
DiscoverHierarchyOptions options,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
@@ -279,92 +285,6 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
|||||||
cancellationToken);
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Returns root-level browse nodes (objects with no parent).</summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
|
||||||
/// <returns>The list of root <see cref="LazyBrowseNode"/> instances.</returns>
|
|
||||||
public Task<IReadOnlyList<LazyBrowseNode>> BrowseAsync(CancellationToken cancellationToken = default)
|
|
||||||
=> BrowseAsync(null, cancellationToken);
|
|
||||||
|
|
||||||
/// <summary>Returns root-level browse nodes filtered by the given options.</summary>
|
|
||||||
/// <param name="options">Browse filter options. Null applies no filter.</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
|
||||||
/// <returns>The list of root <see cref="LazyBrowseNode"/> instances.</returns>
|
|
||||||
public async Task<IReadOnlyList<LazyBrowseNode>> BrowseAsync(
|
|
||||||
BrowseChildrenOptions? options,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
BrowseChildrenOptions effective = options ?? new BrowseChildrenOptions();
|
|
||||||
List<LazyBrowseNode> roots = [];
|
|
||||||
string pageToken = string.Empty;
|
|
||||||
HashSet<string> seenPageTokens = new(StringComparer.Ordinal);
|
|
||||||
do
|
|
||||||
{
|
|
||||||
BrowseChildrenRequest request = BuildBrowseChildrenRequest(effective);
|
|
||||||
request.PageToken = pageToken;
|
|
||||||
BrowseChildrenReply reply = await BrowseChildrenRawAsync(request, cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
for (int i = 0; i < reply.Children.Count; i++)
|
|
||||||
{
|
|
||||||
bool hint = i < reply.ChildHasChildren.Count && reply.ChildHasChildren[i];
|
|
||||||
roots.Add(new LazyBrowseNode(this, reply.Children[i], hint, effective));
|
|
||||||
}
|
|
||||||
|
|
||||||
pageToken = reply.NextPageToken;
|
|
||||||
if (!string.IsNullOrWhiteSpace(pageToken) && !seenPageTokens.Add(pageToken))
|
|
||||||
{
|
|
||||||
throw new MxGatewayException(
|
|
||||||
$"Galaxy BrowseChildren returned a repeated page token '{pageToken}'.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
while (!string.IsNullOrWhiteSpace(pageToken));
|
|
||||||
|
|
||||||
return roots;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Issues a raw BrowseChildren RPC without result wrapping.</summary>
|
|
||||||
/// <param name="request">The browse-children request.</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
|
||||||
/// <returns>The raw server reply.</returns>
|
|
||||||
public Task<BrowseChildrenReply> BrowseChildrenRawAsync(
|
|
||||||
BrowseChildrenRequest request,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(request);
|
|
||||||
ThrowIfDisposed();
|
|
||||||
|
|
||||||
return ExecuteSafeUnaryAsync(
|
|
||||||
token => _transport.BrowseChildrenAsync(request, CreateCallOptions(token)),
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Builds a <see cref="BrowseChildrenRequest"/> from the provided options.</summary>
|
|
||||||
/// <param name="options">Browse children options to convert.</param>
|
|
||||||
/// <returns>The constructed request message.</returns>
|
|
||||||
internal static BrowseChildrenRequest BuildBrowseChildrenRequest(BrowseChildrenOptions options)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(options);
|
|
||||||
|
|
||||||
BrowseChildrenRequest request = new()
|
|
||||||
{
|
|
||||||
PageSize = BrowseChildrenPageSize,
|
|
||||||
AlarmBearingOnly = options.AlarmBearingOnly,
|
|
||||||
HistorizedOnly = options.HistorizedOnly,
|
|
||||||
};
|
|
||||||
request.CategoryIds.Add(options.CategoryIds);
|
|
||||||
request.TemplateChainContains.Add(options.TemplateChainContains);
|
|
||||||
if (!string.IsNullOrWhiteSpace(options.TagNameGlob))
|
|
||||||
{
|
|
||||||
request.TagNameGlob = options.TagNameGlob;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.IncludeAttributes.HasValue)
|
|
||||||
{
|
|
||||||
request.IncludeAttributes = options.IncludeAttributes.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return request;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Subscribes to Galaxy deploy events. The server emits a bootstrap event with the
|
/// Subscribes to Galaxy deploy events. The server emits a bootstrap event with the
|
||||||
/// current state on subscribe so callers can prime their cache, then emits one event
|
/// current state on subscribe so callers can prime their cache, then emits one event
|
||||||
@@ -427,15 +347,13 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Closes the gRPC channel and releases resources.
|
/// Closes the gRPC channel and releases resources.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>A task that represents the asynchronous dispose operation.</returns>
|
|
||||||
public ValueTask DisposeAsync()
|
public ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
if (_disposed)
|
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||||
{
|
{
|
||||||
return ValueTask.CompletedTask;
|
return ValueTask.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
_disposed = true;
|
|
||||||
_channel?.Dispose();
|
_channel?.Dispose();
|
||||||
return ValueTask.CompletedTask;
|
return ValueTask.CompletedTask;
|
||||||
}
|
}
|
||||||
@@ -494,13 +412,7 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
|||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options) =>
|
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options)
|
||||||
CreateHttpHandlerForTests(options);
|
|
||||||
|
|
||||||
/// <summary>Creates an <see cref="HttpMessageHandler"/> configured from the provided options for test use.</summary>
|
|
||||||
/// <param name="options">Client options used to configure TLS and timeouts.</param>
|
|
||||||
/// <returns>The configured HTTP message handler.</returns>
|
|
||||||
internal static SocketsHttpHandler CreateHttpHandlerForTests(MxGatewayClientOptions options)
|
|
||||||
{
|
{
|
||||||
SocketsHttpHandler handler = new()
|
SocketsHttpHandler handler = new()
|
||||||
{
|
{
|
||||||
@@ -520,11 +432,6 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
|||||||
X509Certificate2 trustedRoot = X509CertificateLoader.LoadCertificateFromFile(options.CaCertificatePath);
|
X509Certificate2 trustedRoot = X509CertificateLoader.LoadCertificateFromFile(options.CaCertificatePath);
|
||||||
handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, errors) =>
|
handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, errors) =>
|
||||||
{
|
{
|
||||||
if ((errors & System.Net.Security.SslPolicyErrors.RemoteCertificateNameMismatch) != 0)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (certificate is null)
|
if (certificate is null)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
@@ -540,10 +447,6 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
|||||||
return customChain.Build(certificateToValidate);
|
return customChain.Build(certificateToValidate);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
else if (!options.RequireCertificateValidation)
|
|
||||||
{
|
|
||||||
handler.SslOptions.RemoteCertificateValidationCallback = (_, _, _, _) => true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return handler;
|
return handler;
|
||||||
@@ -551,6 +454,6 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
|||||||
|
|
||||||
private void ThrowIfDisposed()
|
private void ThrowIfDisposed()
|
||||||
{
|
{
|
||||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+9
-52
@@ -1,7 +1,7 @@
|
|||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
using MxGateway.Contracts.Proto.Galaxy;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Client;
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// gRPC implementation of IGalaxyRepositoryClientTransport.
|
/// gRPC implementation of IGalaxyRepositoryClientTransport.
|
||||||
@@ -10,7 +10,9 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
|
|||||||
MxGatewayClientOptions options,
|
MxGatewayClientOptions options,
|
||||||
GalaxyRepository.GalaxyRepositoryClient rawClient) : IGalaxyRepositoryClientTransport
|
GalaxyRepository.GalaxyRepositoryClient rawClient) : IGalaxyRepositoryClientTransport
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <summary>
|
||||||
|
/// Gets the gateway client options.
|
||||||
|
/// </summary>
|
||||||
public MxGatewayClientOptions Options { get; } = options;
|
public MxGatewayClientOptions Options { get; } = options;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -34,7 +36,7 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
|
|||||||
}
|
}
|
||||||
catch (RpcException exception)
|
catch (RpcException exception)
|
||||||
{
|
{
|
||||||
throw MapRpcException(exception, callOptions.CancellationToken);
|
throw RpcExceptionMapper.Map(exception, callOptions.CancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +53,7 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
|
|||||||
}
|
}
|
||||||
catch (RpcException exception)
|
catch (RpcException exception)
|
||||||
{
|
{
|
||||||
throw MapRpcException(exception, callOptions.CancellationToken);
|
throw RpcExceptionMapper.Map(exception, callOptions.CancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,32 +70,11 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
|
|||||||
}
|
}
|
||||||
catch (RpcException exception)
|
catch (RpcException exception)
|
||||||
{
|
{
|
||||||
throw MapRpcException(exception, callOptions.CancellationToken);
|
throw RpcExceptionMapper.Map(exception, callOptions.CancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<BrowseChildrenReply> BrowseChildrenAsync(
|
|
||||||
BrowseChildrenRequest request,
|
|
||||||
CallOptions callOptions)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return await RawClient.BrowseChildrenAsync(request, callOptions)
|
|
||||||
.ResponseAsync
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
catch (RpcException exception)
|
|
||||||
{
|
|
||||||
throw MapRpcException(exception, callOptions.CancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Streams deploy events from the Galaxy Repository, using an explicit cancellation token that overrides the call options token when provided.</summary>
|
|
||||||
/// <param name="request">The watch deploy events request.</param>
|
|
||||||
/// <param name="callOptions">Call options for the underlying gRPC call.</param>
|
|
||||||
/// <param name="cancellationToken">Optional cancellation token; takes precedence over the token in <paramref name="callOptions"/> when cancellable.</param>
|
|
||||||
/// <returns>An async enumerable of deploy events.</returns>
|
|
||||||
public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
|
public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
|
||||||
WatchDeployEventsRequest request,
|
WatchDeployEventsRequest request,
|
||||||
CallOptions callOptions,
|
CallOptions callOptions,
|
||||||
@@ -120,7 +101,7 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
|
|||||||
}
|
}
|
||||||
catch (RpcException exception)
|
catch (RpcException exception)
|
||||||
{
|
{
|
||||||
throw MapRpcException(exception, effectiveCancellationToken);
|
throw RpcExceptionMapper.Map(exception, effectiveCancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
yield return deployEvent;
|
yield return deployEvent;
|
||||||
@@ -134,28 +115,4 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
|
|||||||
{
|
{
|
||||||
return WatchDeployEventsAsync(request, callOptions);
|
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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+12
-88
@@ -1,7 +1,7 @@
|
|||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Client;
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// gRPC implementation of IMxGatewayClientTransport.
|
/// gRPC implementation of IMxGatewayClientTransport.
|
||||||
@@ -10,7 +10,9 @@ internal sealed class GrpcMxGatewayClientTransport(
|
|||||||
MxGatewayClientOptions options,
|
MxGatewayClientOptions options,
|
||||||
MxAccessGateway.MxAccessGatewayClient rawClient) : IMxGatewayClientTransport
|
MxAccessGateway.MxAccessGatewayClient rawClient) : IMxGatewayClientTransport
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <summary>
|
||||||
|
/// Gets the gateway client options.
|
||||||
|
/// </summary>
|
||||||
public MxGatewayClientOptions Options { get; } = options;
|
public MxGatewayClientOptions Options { get; } = options;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -34,7 +36,7 @@ internal sealed class GrpcMxGatewayClientTransport(
|
|||||||
}
|
}
|
||||||
catch (RpcException exception)
|
catch (RpcException exception)
|
||||||
{
|
{
|
||||||
throw MapRpcException(exception, callOptions.CancellationToken);
|
throw RpcExceptionMapper.Map(exception, callOptions.CancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +53,7 @@ internal sealed class GrpcMxGatewayClientTransport(
|
|||||||
}
|
}
|
||||||
catch (RpcException exception)
|
catch (RpcException exception)
|
||||||
{
|
{
|
||||||
throw MapRpcException(exception, callOptions.CancellationToken);
|
throw RpcExceptionMapper.Map(exception, callOptions.CancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,15 +70,11 @@ internal sealed class GrpcMxGatewayClientTransport(
|
|||||||
}
|
}
|
||||||
catch (RpcException exception)
|
catch (RpcException exception)
|
||||||
{
|
{
|
||||||
throw MapRpcException(exception, callOptions.CancellationToken);
|
throw RpcExceptionMapper.Map(exception, callOptions.CancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Streams MXAccess events from the gateway, forwarding an explicit cancellation token to the stream reader.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="request">The stream events request.</param>
|
|
||||||
/// <param name="callOptions">gRPC call options.</param>
|
|
||||||
/// <param name="cancellationToken">Token to cancel the streaming enumeration.</param>
|
|
||||||
/// <returns>An async enumerable of MXAccess events.</returns>
|
|
||||||
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||||||
StreamEventsRequest request,
|
StreamEventsRequest request,
|
||||||
CallOptions callOptions,
|
CallOptions callOptions,
|
||||||
@@ -103,7 +101,7 @@ internal sealed class GrpcMxGatewayClientTransport(
|
|||||||
}
|
}
|
||||||
catch (RpcException exception)
|
catch (RpcException exception)
|
||||||
{
|
{
|
||||||
throw MapRpcException(exception, effectiveCancellationToken);
|
throw RpcExceptionMapper.Map(exception, effectiveCancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
yield return gatewayEvent;
|
yield return gatewayEvent;
|
||||||
@@ -131,61 +129,11 @@ internal sealed class GrpcMxGatewayClientTransport(
|
|||||||
}
|
}
|
||||||
catch (RpcException exception)
|
catch (RpcException exception)
|
||||||
{
|
{
|
||||||
throw MapRpcException(exception, callOptions.CancellationToken);
|
throw RpcExceptionMapper.Map(exception, callOptions.CancellationToken);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Queries active alarms from the gateway, forwarding an explicit cancellation token to the stream reader.</summary>
|
|
||||||
/// <param name="request">The query active alarms request.</param>
|
|
||||||
/// <param name="callOptions">gRPC call options.</param>
|
|
||||||
/// <param name="cancellationToken">Token to cancel the streaming enumeration.</param>
|
|
||||||
/// <returns>An async enumerable of active alarm snapshots.</returns>
|
|
||||||
public async IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
|
|
||||||
QueryActiveAlarmsRequest request,
|
|
||||||
CallOptions callOptions,
|
|
||||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
CancellationToken effectiveCancellationToken = cancellationToken.CanBeCanceled
|
|
||||||
? cancellationToken
|
|
||||||
: callOptions.CancellationToken;
|
|
||||||
|
|
||||||
using AsyncServerStreamingCall<ActiveAlarmSnapshot> call = RawClient.QueryActiveAlarms(request, callOptions);
|
|
||||||
|
|
||||||
IAsyncStreamReader<ActiveAlarmSnapshot> responseStream = call.ResponseStream;
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
ActiveAlarmSnapshot? snapshot;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (!await responseStream.MoveNext(effectiveCancellationToken).ConfigureAwait(false))
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
snapshot = responseStream.Current;
|
|
||||||
}
|
|
||||||
catch (RpcException exception)
|
|
||||||
{
|
|
||||||
throw MapRpcException(exception, effectiveCancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
yield return snapshot;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
IAsyncEnumerable<ActiveAlarmSnapshot> IMxGatewayClientTransport.QueryActiveAlarmsAsync(
|
|
||||||
QueryActiveAlarmsRequest request,
|
|
||||||
CallOptions callOptions)
|
|
||||||
{
|
|
||||||
return QueryActiveAlarmsAsync(request, callOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Streams alarm feed messages from the gateway, forwarding an explicit cancellation token to the stream reader.</summary>
|
|
||||||
/// <param name="request">The stream alarms request.</param>
|
|
||||||
/// <param name="callOptions">gRPC call options.</param>
|
|
||||||
/// <param name="cancellationToken">Token to cancel the streaming enumeration.</param>
|
|
||||||
/// <returns>An async enumerable of alarm feed messages.</returns>
|
|
||||||
public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
||||||
StreamAlarmsRequest request,
|
StreamAlarmsRequest request,
|
||||||
CallOptions callOptions,
|
CallOptions callOptions,
|
||||||
@@ -212,7 +160,7 @@ internal sealed class GrpcMxGatewayClientTransport(
|
|||||||
}
|
}
|
||||||
catch (RpcException exception)
|
catch (RpcException exception)
|
||||||
{
|
{
|
||||||
throw MapRpcException(exception, effectiveCancellationToken);
|
throw RpcExceptionMapper.Map(exception, effectiveCancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
yield return message;
|
yield return message;
|
||||||
@@ -226,28 +174,4 @@ internal sealed class GrpcMxGatewayClientTransport(
|
|||||||
{
|
{
|
||||||
return StreamAlarmsAsync(request, callOptions);
|
return StreamAlarmsAsync(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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+2
-14
@@ -1,7 +1,7 @@
|
|||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
using MxGateway.Contracts.Proto.Galaxy;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Client;
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
/// <summary>Transport layer for Galaxy Repository gRPC operations.</summary>
|
/// <summary>Transport layer for Galaxy Repository gRPC operations.</summary>
|
||||||
internal interface IGalaxyRepositoryClientTransport
|
internal interface IGalaxyRepositoryClientTransport
|
||||||
@@ -15,7 +15,6 @@ internal interface IGalaxyRepositoryClientTransport
|
|||||||
/// <summary>Tests the connection to the Galaxy Repository server.</summary>
|
/// <summary>Tests the connection to the Galaxy Repository server.</summary>
|
||||||
/// <param name="request">The test connection request.</param>
|
/// <param name="request">The test connection request.</param>
|
||||||
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
|
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
|
||||||
/// <returns>A task that resolves to the test connection reply.</returns>
|
|
||||||
Task<TestConnectionReply> TestConnectionAsync(
|
Task<TestConnectionReply> TestConnectionAsync(
|
||||||
TestConnectionRequest request,
|
TestConnectionRequest request,
|
||||||
CallOptions callOptions);
|
CallOptions callOptions);
|
||||||
@@ -23,7 +22,6 @@ internal interface IGalaxyRepositoryClientTransport
|
|||||||
/// <summary>Gets the last deploy time from the Galaxy Repository server.</summary>
|
/// <summary>Gets the last deploy time from the Galaxy Repository server.</summary>
|
||||||
/// <param name="request">The get last deploy time request.</param>
|
/// <param name="request">The get last deploy time request.</param>
|
||||||
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
|
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
|
||||||
/// <returns>A task that resolves to the last deploy time reply.</returns>
|
|
||||||
Task<GetLastDeployTimeReply> GetLastDeployTimeAsync(
|
Task<GetLastDeployTimeReply> GetLastDeployTimeAsync(
|
||||||
GetLastDeployTimeRequest request,
|
GetLastDeployTimeRequest request,
|
||||||
CallOptions callOptions);
|
CallOptions callOptions);
|
||||||
@@ -31,23 +29,13 @@ internal interface IGalaxyRepositoryClientTransport
|
|||||||
/// <summary>Discovers the object hierarchy in the Galaxy Repository.</summary>
|
/// <summary>Discovers the object hierarchy in the Galaxy Repository.</summary>
|
||||||
/// <param name="request">The discover hierarchy request.</param>
|
/// <param name="request">The discover hierarchy request.</param>
|
||||||
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
|
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
|
||||||
/// <returns>A task that resolves to the hierarchy discovery reply.</returns>
|
|
||||||
Task<DiscoverHierarchyReply> DiscoverHierarchyAsync(
|
Task<DiscoverHierarchyReply> DiscoverHierarchyAsync(
|
||||||
DiscoverHierarchyRequest request,
|
DiscoverHierarchyRequest request,
|
||||||
CallOptions callOptions);
|
CallOptions callOptions);
|
||||||
|
|
||||||
/// <summary>Returns direct children of a parent in the Galaxy hierarchy.</summary>
|
|
||||||
/// <param name="request">The browse children request.</param>
|
|
||||||
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
|
|
||||||
/// <returns>A task that resolves to the browse children reply.</returns>
|
|
||||||
Task<BrowseChildrenReply> BrowseChildrenAsync(
|
|
||||||
BrowseChildrenRequest request,
|
|
||||||
CallOptions callOptions);
|
|
||||||
|
|
||||||
/// <summary>Watches for deployment events from the Galaxy Repository server.</summary>
|
/// <summary>Watches for deployment events from the Galaxy Repository server.</summary>
|
||||||
/// <param name="request">The watch deploy events request.</param>
|
/// <param name="request">The watch deploy events request.</param>
|
||||||
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
|
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
|
||||||
/// <returns>An async enumerable of deploy events.</returns>
|
|
||||||
IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
|
IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
|
||||||
WatchDeployEventsRequest request,
|
WatchDeployEventsRequest request,
|
||||||
CallOptions callOptions);
|
CallOptions callOptions);
|
||||||
+2
-13
@@ -1,7 +1,7 @@
|
|||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Client;
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
internal interface IMxGatewayClientTransport
|
internal interface IMxGatewayClientTransport
|
||||||
{
|
{
|
||||||
@@ -65,17 +65,6 @@ internal interface IMxGatewayClientTransport
|
|||||||
AcknowledgeAlarmRequest request,
|
AcknowledgeAlarmRequest request,
|
||||||
CallOptions callOptions);
|
CallOptions callOptions);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Streams a snapshot of all alarms currently in Active or ActiveAcked state — the
|
|
||||||
/// ConditionRefresh equivalent for the gateway.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">The query 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,
|
|
||||||
CallOptions callOptions);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Attaches to the gateway's central alarm feed — the current active-alarm
|
/// Attaches to the gateway's central alarm feed — the current active-alarm
|
||||||
/// snapshot followed by live transitions.
|
/// snapshot followed by live transitions.
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Client;
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
/// <summary>Exception thrown when an MXAccess command fails with a non-zero HResult or failing status.</summary>
|
/// <summary>Exception thrown when an MXAccess command fails with a non-zero HResult or failing status.</summary>
|
||||||
public sealed class MxAccessException : MxGatewayCommandException
|
public sealed class MxAccessException : MxGatewayCommandException
|
||||||
+2
-4
@@ -1,13 +1,12 @@
|
|||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Client;
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
/// <summary>Extension methods for checking MxCommandReply success conditions.</summary>
|
/// <summary>Extension methods for checking MxCommandReply success conditions.</summary>
|
||||||
public static class MxCommandReplyExtensions
|
public static class MxCommandReplyExtensions
|
||||||
{
|
{
|
||||||
/// <summary>Validates that the reply has a successful protocol status (Ok or MxAccessFailure), throwing a gateway exception if not.</summary>
|
/// <summary>Validates that the reply has a successful protocol status (Ok or MxAccessFailure), throwing a gateway exception if not.</summary>
|
||||||
/// <param name="reply">The command reply to check.</param>
|
/// <param name="reply">The command reply to check.</param>
|
||||||
/// <returns>The same <paramref name="reply"/> for fluent chaining when validation passes.</returns>
|
|
||||||
public static MxCommandReply EnsureProtocolSuccess(this MxCommandReply reply)
|
public static MxCommandReply EnsureProtocolSuccess(this MxCommandReply reply)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(reply);
|
ArgumentNullException.ThrowIfNull(reply);
|
||||||
@@ -25,7 +24,6 @@ public static class MxCommandReplyExtensions
|
|||||||
|
|
||||||
/// <summary>Validates that the reply indicates MXAccess success (no HResult or status failures), throwing MxAccessException if not.</summary>
|
/// <summary>Validates that the reply indicates MXAccess success (no HResult or status failures), throwing MxAccessException if not.</summary>
|
||||||
/// <param name="reply">The command reply to check.</param>
|
/// <param name="reply">The command reply to check.</param>
|
||||||
/// <returns>The same <paramref name="reply"/> for fluent chaining when validation passes.</returns>
|
|
||||||
public static MxCommandReply EnsureMxAccessSuccess(this MxCommandReply reply)
|
public static MxCommandReply EnsureMxAccessSuccess(this MxCommandReply reply)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(reply);
|
ArgumentNullException.ThrowIfNull(reply);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\..\src\MxGateway.Contracts\MxGateway.Contracts.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Grpc.Net.Client" Version="2.76.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
|
||||||
|
<PackageReference Include="Polly.Core" Version="8.6.6" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
+8
-4
@@ -1,6 +1,7 @@
|
|||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using Grpc.Core;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Client;
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
/// <summary>Exception thrown when an API key is invalid, expired, or malformed.</summary>
|
/// <summary>Exception thrown when an API key is invalid, expired, or malformed.</summary>
|
||||||
public sealed class MxGatewayAuthenticationException : MxGatewayException
|
public sealed class MxGatewayAuthenticationException : MxGatewayException
|
||||||
@@ -13,6 +14,7 @@ public sealed class MxGatewayAuthenticationException : MxGatewayException
|
|||||||
/// <param name="hResult">The HResult code, if available.</param>
|
/// <param name="hResult">The HResult code, if available.</param>
|
||||||
/// <param name="statuses">The MXAccess statuses, if available.</param>
|
/// <param name="statuses">The MXAccess statuses, if available.</param>
|
||||||
/// <param name="innerException">The underlying exception, if any.</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(
|
public MxGatewayAuthenticationException(
|
||||||
string message,
|
string message,
|
||||||
string? sessionId = null,
|
string? sessionId = null,
|
||||||
@@ -20,7 +22,8 @@ public sealed class MxGatewayAuthenticationException : MxGatewayException
|
|||||||
ProtocolStatus? protocolStatus = null,
|
ProtocolStatus? protocolStatus = null,
|
||||||
int? hResult = null,
|
int? hResult = null,
|
||||||
IReadOnlyList<MxStatusProxy>? statuses = null,
|
IReadOnlyList<MxStatusProxy>? statuses = null,
|
||||||
Exception? innerException = null)
|
Exception? innerException = null,
|
||||||
|
StatusCode? statusCode = null)
|
||||||
: base(
|
: base(
|
||||||
message,
|
message,
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -28,7 +31,8 @@ public sealed class MxGatewayAuthenticationException : MxGatewayException
|
|||||||
protocolStatus,
|
protocolStatus,
|
||||||
hResult,
|
hResult,
|
||||||
statuses ?? [],
|
statuses ?? [],
|
||||||
innerException)
|
innerException,
|
||||||
|
statusCode)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+8
-4
@@ -1,6 +1,7 @@
|
|||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using Grpc.Core;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Client;
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
/// <summary>Exception thrown when the API key lacks required scopes for an operation.</summary>
|
/// <summary>Exception thrown when the API key lacks required scopes for an operation.</summary>
|
||||||
public sealed class MxGatewayAuthorizationException : MxGatewayException
|
public sealed class MxGatewayAuthorizationException : MxGatewayException
|
||||||
@@ -13,6 +14,7 @@ public sealed class MxGatewayAuthorizationException : MxGatewayException
|
|||||||
/// <param name="hResult">The HResult code, if available.</param>
|
/// <param name="hResult">The HResult code, if available.</param>
|
||||||
/// <param name="statuses">The MXAccess statuses, if available.</param>
|
/// <param name="statuses">The MXAccess statuses, if available.</param>
|
||||||
/// <param name="innerException">The underlying exception, if any.</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(
|
public MxGatewayAuthorizationException(
|
||||||
string message,
|
string message,
|
||||||
string? sessionId = null,
|
string? sessionId = null,
|
||||||
@@ -20,7 +22,8 @@ public sealed class MxGatewayAuthorizationException : MxGatewayException
|
|||||||
ProtocolStatus? protocolStatus = null,
|
ProtocolStatus? protocolStatus = null,
|
||||||
int? hResult = null,
|
int? hResult = null,
|
||||||
IReadOnlyList<MxStatusProxy>? statuses = null,
|
IReadOnlyList<MxStatusProxy>? statuses = null,
|
||||||
Exception? innerException = null)
|
Exception? innerException = null,
|
||||||
|
StatusCode? statusCode = null)
|
||||||
: base(
|
: base(
|
||||||
message,
|
message,
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -28,7 +31,8 @@ public sealed class MxGatewayAuthorizationException : MxGatewayException
|
|||||||
protocolStatus,
|
protocolStatus,
|
||||||
hResult,
|
hResult,
|
||||||
statuses ?? [],
|
statuses ?? [],
|
||||||
innerException)
|
innerException,
|
||||||
|
statusCode)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+10
-47
@@ -1,13 +1,13 @@
|
|||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
using Grpc.Net.Client;
|
using Grpc.Net.Client;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
using Polly;
|
using Polly;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Net.Security;
|
using System.Net.Security;
|
||||||
using System.Security.Cryptography.X509Certificates;
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Client;
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Provides the .NET client entry point for the public MXAccess Gateway gRPC API.
|
/// Provides the .NET client entry point for the public MXAccess Gateway gRPC API.
|
||||||
@@ -17,7 +17,7 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
|||||||
private readonly GrpcChannel _channel;
|
private readonly GrpcChannel _channel;
|
||||||
private readonly IMxGatewayClientTransport _transport;
|
private readonly IMxGatewayClientTransport _transport;
|
||||||
private readonly ResiliencePipeline _safeUnaryRetryPipeline;
|
private readonly ResiliencePipeline _safeUnaryRetryPipeline;
|
||||||
private bool _disposed;
|
private int _disposed;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="MxGatewayClient"/> with given options and transport.
|
/// Initializes a new instance of the <see cref="MxGatewayClient"/> with given options and transport.
|
||||||
@@ -184,9 +184,10 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Acknowledges an active MXAccess alarm condition through the gateway. The
|
/// Acknowledges an active MXAccess alarm condition through the gateway. The
|
||||||
/// gateway authenticates the request against the API key's <c>invoke:alarm-ack</c>
|
/// gateway authorizes <see cref="AcknowledgeAlarmRequest"/> against the API
|
||||||
/// scope and forwards the acknowledge to the worker's MXAccess session;
|
/// key's <c>admin</c> scope (there is no finer-grained alarm-ack sub-scope)
|
||||||
/// the resulting <see cref="MxStatusProxy"/> is returned in the reply.
|
/// and forwards the acknowledge to the worker's MXAccess session; the
|
||||||
|
/// resulting <see cref="MxStatusProxy"/> is returned in the reply.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="request">The acknowledge request — alarm reference, comment, operator user.</param>
|
/// <param name="request">The acknowledge request — alarm reference, comment, operator user.</param>
|
||||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||||
@@ -203,27 +204,6 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
|||||||
cancellationToken);
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <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.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">The query 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,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(request);
|
|
||||||
ThrowIfDisposed();
|
|
||||||
|
|
||||||
return _transport.QueryActiveAlarmsAsync(request, CreateStreamCallOptions(cancellationToken));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Attaches to the gateway's central alarm feed. The stream opens with one
|
/// Attaches to the gateway's central alarm feed. The stream opens with one
|
||||||
/// <see cref="AlarmFeedMessage"/> per currently-active alarm (the
|
/// <see cref="AlarmFeedMessage"/> per currently-active alarm (the
|
||||||
@@ -249,15 +229,13 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Disposes the client and releases all resources.
|
/// Disposes the client and releases all resources.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>A task that represents the asynchronous dispose operation.</returns>
|
|
||||||
public ValueTask DisposeAsync()
|
public ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
if (_disposed)
|
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||||
{
|
{
|
||||||
return ValueTask.CompletedTask;
|
return ValueTask.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
_disposed = true;
|
|
||||||
_channel?.Dispose();
|
_channel?.Dispose();
|
||||||
return ValueTask.CompletedTask;
|
return ValueTask.CompletedTask;
|
||||||
}
|
}
|
||||||
@@ -316,13 +294,7 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
|||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options) =>
|
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options)
|
||||||
CreateHttpHandlerForTests(options);
|
|
||||||
|
|
||||||
/// <summary>Creates an <see cref="HttpMessageHandler"/> configured from the provided options for test use.</summary>
|
|
||||||
/// <param name="options">Client options used to configure TLS and timeouts.</param>
|
|
||||||
/// <returns>The configured HTTP message handler.</returns>
|
|
||||||
internal static SocketsHttpHandler CreateHttpHandlerForTests(MxGatewayClientOptions options)
|
|
||||||
{
|
{
|
||||||
SocketsHttpHandler handler = new()
|
SocketsHttpHandler handler = new()
|
||||||
{
|
{
|
||||||
@@ -342,11 +314,6 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
|||||||
X509Certificate2 trustedRoot = X509CertificateLoader.LoadCertificateFromFile(options.CaCertificatePath);
|
X509Certificate2 trustedRoot = X509CertificateLoader.LoadCertificateFromFile(options.CaCertificatePath);
|
||||||
handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, errors) =>
|
handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, errors) =>
|
||||||
{
|
{
|
||||||
if ((errors & System.Net.Security.SslPolicyErrors.RemoteCertificateNameMismatch) != 0)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (certificate is null)
|
if (certificate is null)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
@@ -362,10 +329,6 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
|||||||
return customChain.Build(certificateToValidate);
|
return customChain.Build(certificateToValidate);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
else if (!options.RequireCertificateValidation)
|
|
||||||
{
|
|
||||||
handler.SslOptions.RemoteCertificateValidationCallback = (_, _, _, _) => true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return handler;
|
return handler;
|
||||||
@@ -373,6 +336,6 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
|||||||
|
|
||||||
private void ThrowIfDisposed()
|
private void ThrowIfDisposed()
|
||||||
{
|
{
|
||||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using MxGateway.Contracts;
|
||||||
|
|
||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exposes the protocol versions compiled into this client package.
|
||||||
|
/// </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;
|
||||||
|
}
|
||||||
+10
-11
@@ -1,6 +1,6 @@
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Client;
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Configures the gRPC channel used by the .NET MXAccess Gateway client.
|
/// Configures the gRPC channel used by the .NET MXAccess Gateway client.
|
||||||
@@ -27,14 +27,6 @@ public sealed class MxGatewayClientOptions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string? CaCertificatePath { get; init; }
|
public string? CaCertificatePath { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// When true, TLS connections without a pinned <see cref="CaCertificatePath"/>
|
|
||||||
/// use the OS trust store. When false (default), the gateway certificate is
|
|
||||||
/// accepted without verification — appropriate for this internal tool's
|
|
||||||
/// auto-generated self-signed certificate. Pinning a CA always verifies.
|
|
||||||
/// </summary>
|
|
||||||
public bool RequireCertificateValidation { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the server name override for SNI during TLS handshake.
|
/// Gets the server name override for SNI during TLS handshake.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -46,7 +38,12 @@ public sealed class MxGatewayClientOptions
|
|||||||
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(10);
|
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
public TimeSpan DefaultCallTimeout { get; init; } = TimeSpan.FromSeconds(30);
|
public TimeSpan DefaultCallTimeout { get; init; } = TimeSpan.FromSeconds(30);
|
||||||
|
|
||||||
@@ -56,7 +53,9 @@ public sealed class MxGatewayClientOptions
|
|||||||
public TimeSpan? StreamTimeout { get; init; }
|
public TimeSpan? StreamTimeout { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the maximum size in bytes for gRPC messages.
|
/// 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>
|
/// </summary>
|
||||||
public int MaxGrpcMessageBytes { get; init; } = 16 * 1024 * 1024;
|
public int MaxGrpcMessageBytes { get; init; } = 16 * 1024 * 1024;
|
||||||
|
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
namespace ZB.MOM.WW.MxGateway.Client;
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
/// <summary>Configuration for automatic retry behavior on transient gRPC call failures.</summary>
|
/// <summary>Configuration for automatic retry behavior on transient gRPC call failures.</summary>
|
||||||
public sealed class MxGatewayClientRetryOptions
|
public sealed class MxGatewayClientRetryOptions
|
||||||
+8
-5
@@ -1,10 +1,10 @@
|
|||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
using Polly;
|
using Polly;
|
||||||
using Polly.Retry;
|
using Polly.Retry;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Client;
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
/// <summary>Factory and helpers for exponential-backoff retry policies on transient gRPC failures.</summary>
|
/// <summary>Factory and helpers for exponential-backoff retry policies on transient gRPC failures.</summary>
|
||||||
internal static class MxGatewayClientRetryPolicy
|
internal static class MxGatewayClientRetryPolicy
|
||||||
@@ -12,7 +12,6 @@ internal static class MxGatewayClientRetryPolicy
|
|||||||
/// <summary>Creates a Polly ResiliencePipeline that retries transient gRPC failures with exponential backoff.</summary>
|
/// <summary>Creates a Polly ResiliencePipeline that retries transient gRPC failures with exponential backoff.</summary>
|
||||||
/// <param name="options">Retry configuration (max attempts, delay bounds, jitter).</param>
|
/// <param name="options">Retry configuration (max attempts, delay bounds, jitter).</param>
|
||||||
/// <param name="logger">Optional logger for retry diagnostics.</param>
|
/// <param name="logger">Optional logger for retry diagnostics.</param>
|
||||||
/// <returns>A configured <see cref="ResiliencePipeline"/> with exponential-backoff retry.</returns>
|
|
||||||
public static ResiliencePipeline Create(
|
public static ResiliencePipeline Create(
|
||||||
MxGatewayClientRetryOptions options,
|
MxGatewayClientRetryOptions options,
|
||||||
ILogger? logger)
|
ILogger? logger)
|
||||||
@@ -43,7 +42,6 @@ internal static class MxGatewayClientRetryPolicy
|
|||||||
|
|
||||||
/// <summary>Returns whether a command kind is eligible for automatic retry on transient failures.</summary>
|
/// <summary>Returns whether a command kind is eligible for automatic retry on transient failures.</summary>
|
||||||
/// <param name="kind">The command kind to check.</param>
|
/// <param name="kind">The command kind to check.</param>
|
||||||
/// <returns><see langword="true"/> if the command kind is safe to retry; otherwise <see langword="false"/>.</returns>
|
|
||||||
public static bool IsRetryableCommand(MxCommandKind kind)
|
public static bool IsRetryableCommand(MxCommandKind kind)
|
||||||
{
|
{
|
||||||
return kind is MxCommandKind.Ping
|
return kind is MxCommandKind.Ping
|
||||||
@@ -63,8 +61,13 @@ internal static class MxGatewayClientRetryPolicy
|
|||||||
|
|
||||||
private static bool IsTransientStatus(StatusCode statusCode)
|
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
|
return statusCode is StatusCode.Unavailable
|
||||||
or StatusCode.DeadlineExceeded
|
|
||||||
or StatusCode.ResourceExhausted;
|
or StatusCode.ResourceExhausted;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Client;
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
/// <summary>Exception thrown when a gateway command fails due to an unclassified protocol error.</summary>
|
/// <summary>Exception thrown when a gateway command fails due to an unclassified protocol error.</summary>
|
||||||
public class MxGatewayCommandException : MxGatewayException
|
public class MxGatewayCommandException : MxGatewayException
|
||||||
+32
-3
@@ -1,6 +1,7 @@
|
|||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using Grpc.Core;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Client;
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Exception thrown when a gateway RPC call fails or returns an error status.
|
/// Exception thrown when a gateway RPC call fails or returns an error status.
|
||||||
@@ -28,6 +29,20 @@ public class MxGatewayException : Exception
|
|||||||
Statuses = [];
|
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>
|
/// <summary>
|
||||||
/// Initializes a new instance of the MxGatewayException class with full diagnostic information.
|
/// Initializes a new instance of the MxGatewayException class with full diagnostic information.
|
||||||
/// </summary>
|
/// </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="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="statuses">List of MXAccess status codes returned by the operation.</param>
|
||||||
/// <param name="innerException">Underlying exception that caused this failure.</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(
|
public MxGatewayException(
|
||||||
string message,
|
string message,
|
||||||
string? sessionId,
|
string? sessionId,
|
||||||
@@ -45,7 +61,8 @@ public class MxGatewayException : Exception
|
|||||||
ProtocolStatus? protocolStatus,
|
ProtocolStatus? protocolStatus,
|
||||||
int? hResult,
|
int? hResult,
|
||||||
IReadOnlyList<MxStatusProxy> statuses,
|
IReadOnlyList<MxStatusProxy> statuses,
|
||||||
Exception? innerException = null)
|
Exception? innerException = null,
|
||||||
|
StatusCode? statusCode = null)
|
||||||
: base(message, innerException)
|
: base(message, innerException)
|
||||||
{
|
{
|
||||||
SessionId = sessionId;
|
SessionId = sessionId;
|
||||||
@@ -53,6 +70,7 @@ public class MxGatewayException : Exception
|
|||||||
ProtocolStatus = protocolStatus;
|
ProtocolStatus = protocolStatus;
|
||||||
HResultCode = hResult;
|
HResultCode = hResult;
|
||||||
Statuses = statuses;
|
Statuses = statuses;
|
||||||
|
StatusCode = statusCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -79,4 +97,15 @@ public class MxGatewayException : Exception
|
|||||||
/// Gets the list of MXAccess status codes returned by the operation.
|
/// Gets the list of MXAccess status codes returned by the operation.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IReadOnlyList<MxStatusProxy> Statuses { get; }
|
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; }
|
||||||
}
|
}
|
||||||
+78
-46
@@ -1,6 +1,6 @@
|
|||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Client;
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Represents one gateway-backed MXAccess session.
|
/// Represents one gateway-backed MXAccess session.
|
||||||
@@ -9,7 +9,10 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
private readonly MxGatewayClient _client;
|
private readonly MxGatewayClient _client;
|
||||||
private readonly SemaphoreSlim _closeLock = new(1, 1);
|
private readonly SemaphoreSlim _closeLock = new(1, 1);
|
||||||
|
private readonly object _disposeGate = new();
|
||||||
private CloseSessionReply? _closeReply;
|
private CloseSessionReply? _closeReply;
|
||||||
|
private int _activeCloseCount;
|
||||||
|
private bool _closeLockDisposed;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new session backed by the given MXAccess gateway client.
|
/// Initializes a new session backed by the given MXAccess gateway client.
|
||||||
@@ -46,6 +49,17 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
|||||||
return _closeReply;
|
return _closeReply;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
{
|
||||||
await _closeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
await _closeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -65,6 +79,14 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
|||||||
_closeLock.Release();
|
_closeLock.Release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
lock (_disposeGate)
|
||||||
|
{
|
||||||
|
_activeCloseCount--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Registers a client with the MXAccess session, returning a ServerHandle.
|
/// Registers a client with the MXAccess session, returning a ServerHandle.
|
||||||
@@ -79,7 +101,8 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
|||||||
MxCommandReply reply = await RegisterRawAsync(clientName, cancellationToken)
|
MxCommandReply reply = await RegisterRawAsync(clientName, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||||
return reply.Register?.ServerHandle ?? reply.ReturnValue.Int32Value;
|
return reply.Register?.ServerHandle
|
||||||
|
?? throw CreateMissingPayloadException(reply, "register");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -121,7 +144,8 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
|||||||
cancellationToken)
|
cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||||
return reply.AddItem?.ItemHandle ?? reply.ReturnValue.Int32Value;
|
return reply.AddItem?.ItemHandle
|
||||||
|
?? throw CreateMissingPayloadException(reply, "add_item");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -172,7 +196,8 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
|||||||
cancellationToken)
|
cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||||
return reply.AddItem2?.ItemHandle ?? reply.ReturnValue.Int32Value;
|
return reply.AddItem2?.ItemHandle
|
||||||
|
?? throw CreateMissingPayloadException(reply, "add_item2");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -211,7 +236,6 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
|||||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||||
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
public async Task AdviseAsync(
|
public async Task AdviseAsync(
|
||||||
int serverHandle,
|
int serverHandle,
|
||||||
int itemHandle,
|
int itemHandle,
|
||||||
@@ -253,7 +277,6 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
|||||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||||
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
public async Task UnAdviseAsync(
|
public async Task UnAdviseAsync(
|
||||||
int serverHandle,
|
int serverHandle,
|
||||||
int itemHandle,
|
int itemHandle,
|
||||||
@@ -295,7 +318,6 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
|||||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||||
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
public async Task RemoveItemAsync(
|
public async Task RemoveItemAsync(
|
||||||
int serverHandle,
|
int serverHandle,
|
||||||
int itemHandle,
|
int itemHandle,
|
||||||
@@ -507,14 +529,10 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Bulk Write — sequential MXAccess Write per entry on the worker's STA.
|
/// Bulk Write — sequential MXAccess Write per entry on the worker's STA.
|
||||||
/// Per-item failures appear as <see cref="BulkWriteResult"/> entries with
|
/// Per-item failures appear as BulkWriteResult entries with
|
||||||
/// <c>WasSuccessful = false</c>; the call never throws on per-item errors.
|
/// <c>WasSuccessful = false</c>; the call never throws on per-item errors.
|
||||||
/// Protocol-level failures still throw via EnsureProtocolSuccess.
|
/// Protocol-level failures still throw via EnsureProtocolSuccess.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
|
||||||
/// <param name="entries">Per-item write entries; each carries the item handle, value, and user id.</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
|
||||||
/// <returns>One <see cref="BulkWriteResult"/> per requested entry, in request order.</returns>
|
|
||||||
public async Task<IReadOnlyList<BulkWriteResult>> WriteBulkAsync(
|
public async Task<IReadOnlyList<BulkWriteResult>> WriteBulkAsync(
|
||||||
int serverHandle,
|
int serverHandle,
|
||||||
IReadOnlyList<WriteBulkEntry> entries,
|
IReadOnlyList<WriteBulkEntry> entries,
|
||||||
@@ -537,15 +555,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
|||||||
return reply.WriteBulk?.Results.ToArray() ?? [];
|
return reply.WriteBulk?.Results.ToArray() ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Bulk Write2 — sequential MXAccess Write2 (timestamped) per entry.</summary>
|
||||||
/// Bulk Write2 — sequential MXAccess Write2 (timestamped) per entry.
|
|
||||||
/// Per-item failures appear as <see cref="BulkWriteResult"/> entries with
|
|
||||||
/// <c>WasSuccessful = false</c>; the call never throws on per-item errors.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
|
||||||
/// <param name="entries">Per-item write entries; each carries the item handle, value, timestamp, and user id.</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
|
||||||
/// <returns>One <see cref="BulkWriteResult"/> per requested entry, in request order.</returns>
|
|
||||||
public async Task<IReadOnlyList<BulkWriteResult>> Write2BulkAsync(
|
public async Task<IReadOnlyList<BulkWriteResult>> Write2BulkAsync(
|
||||||
int serverHandle,
|
int serverHandle,
|
||||||
IReadOnlyList<Write2BulkEntry> entries,
|
IReadOnlyList<Write2BulkEntry> entries,
|
||||||
@@ -573,10 +583,6 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
|||||||
/// Credential-sensitive values must never reach logs; the client mirrors
|
/// Credential-sensitive values must never reach logs; the client mirrors
|
||||||
/// the single-item WriteSecured redaction contract.
|
/// the single-item WriteSecured redaction contract.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
|
||||||
/// <param name="entries">Per-item write entries; each carries the item handle, value, current user id, and verifier user id.</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
|
||||||
/// <returns>One <see cref="BulkWriteResult"/> per requested entry, in request order.</returns>
|
|
||||||
public async Task<IReadOnlyList<BulkWriteResult>> WriteSecuredBulkAsync(
|
public async Task<IReadOnlyList<BulkWriteResult>> WriteSecuredBulkAsync(
|
||||||
int serverHandle,
|
int serverHandle,
|
||||||
IReadOnlyList<WriteSecuredBulkEntry> entries,
|
IReadOnlyList<WriteSecuredBulkEntry> entries,
|
||||||
@@ -599,14 +605,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
|||||||
return reply.WriteSecuredBulk?.Results.ToArray() ?? [];
|
return reply.WriteSecuredBulk?.Results.ToArray() ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Bulk WriteSecured2 — sequential MXAccess WriteSecured2 (timestamped) per entry.</summary>
|
||||||
/// Bulk WriteSecured2 — sequential MXAccess WriteSecured2 (timestamped) per entry.
|
|
||||||
/// Same redaction rules as <see cref="WriteSecuredBulkAsync"/>.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
|
||||||
/// <param name="entries">Per-item write entries; each carries the item handle, value, timestamp, current user id, and verifier user id.</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
|
||||||
/// <returns>One <see cref="BulkWriteResult"/> per requested entry, in request order.</returns>
|
|
||||||
public async Task<IReadOnlyList<BulkWriteResult>> WriteSecured2BulkAsync(
|
public async Task<IReadOnlyList<BulkWriteResult>> WriteSecured2BulkAsync(
|
||||||
int serverHandle,
|
int serverHandle,
|
||||||
IReadOnlyList<WriteSecured2BulkEntry> entries,
|
IReadOnlyList<WriteSecured2BulkEntry> entries,
|
||||||
@@ -632,17 +631,11 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Bulk Read — snapshot the current value for each requested tag.
|
/// Bulk Read — snapshot the current value for each requested tag.
|
||||||
/// Returns the cached OnDataChange value when the tag is already advised
|
/// Returns the cached OnDataChange value when the tag is already advised
|
||||||
/// (<c>WasCached = true</c>), otherwise the worker takes the full AddItem +
|
/// (was_cached = true), otherwise the worker takes the full AddItem +
|
||||||
/// Advise + wait + UnAdvise + RemoveItem snapshot lifecycle. Per-tag
|
/// Advise + wait + UnAdvise + RemoveItem snapshot lifecycle. Per-tag
|
||||||
/// failures (timeout, invalid tag) appear as <see cref="BulkReadResult"/>
|
/// failures (timeout, invalid tag) appear as BulkReadResult entries with
|
||||||
/// entries with <c>WasSuccessful = false</c>; the call never throws on
|
/// <c>WasSuccessful = false</c>; the call never throws on per-tag errors.
|
||||||
/// per-tag errors.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
|
||||||
/// <param name="tagAddresses">Tag addresses to read (one per result).</param>
|
|
||||||
/// <param name="timeout">Per-call timeout for the snapshot lifecycle path; <see cref="TimeSpan.Zero"/> uses the gateway default.</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
|
||||||
/// <returns>One <see cref="BulkReadResult"/> per requested tag, in request order.</returns>
|
|
||||||
public async Task<IReadOnlyList<BulkReadResult>> ReadBulkAsync(
|
public async Task<IReadOnlyList<BulkReadResult>> ReadBulkAsync(
|
||||||
int serverHandle,
|
int serverHandle,
|
||||||
IReadOnlyList<string> tagAddresses,
|
IReadOnlyList<string> tagAddresses,
|
||||||
@@ -678,7 +671,6 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
|||||||
/// <param name="value">The value to write.</param>
|
/// <param name="value">The value to write.</param>
|
||||||
/// <param name="userId">User ID context for the write.</param>
|
/// <param name="userId">User ID context for the write.</param>
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
public async Task WriteAsync(
|
public async Task WriteAsync(
|
||||||
int serverHandle,
|
int serverHandle,
|
||||||
int itemHandle,
|
int itemHandle,
|
||||||
@@ -733,7 +725,6 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
|||||||
/// <param name="timestampValue">The timestamp to write with the value.</param>
|
/// <param name="timestampValue">The timestamp to write with the value.</param>
|
||||||
/// <param name="userId">User ID context for the write.</param>
|
/// <param name="userId">User ID context for the write.</param>
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
public async Task Write2Async(
|
public async Task Write2Async(
|
||||||
int serverHandle,
|
int serverHandle,
|
||||||
int itemHandle,
|
int itemHandle,
|
||||||
@@ -826,10 +817,34 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Closes the session and releases resources.
|
/// Closes the session and releases resources.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
|
lock (_disposeGate)
|
||||||
|
{
|
||||||
|
if (_closeLockDisposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await CloseAsync().ConfigureAwait(false);
|
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();
|
_closeLock.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -847,4 +862,21 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
|||||||
cancellationToken);
|
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}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Client;
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
/// <summary>Exception thrown when a session is not found, not ready, or invalid.</summary>
|
/// <summary>Exception thrown when a session is not found, not ready, or invalid.</summary>
|
||||||
public sealed class MxGatewaySessionException : MxGatewayException
|
public sealed class MxGatewaySessionException : MxGatewayException
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Client;
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
/// <summary>Exception thrown when the worker process is unavailable or fails to process a command.</summary>
|
/// <summary>Exception thrown when the worker process is unavailable or fails to process a command.</summary>
|
||||||
public sealed class MxGatewayWorkerException : MxGatewayException
|
public sealed class MxGatewayWorkerException : MxGatewayException
|
||||||
+2
-4
@@ -1,13 +1,12 @@
|
|||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Client;
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
/// <summary>Extension methods for MxStatusProxy values.</summary>
|
/// <summary>Extension methods for MxStatusProxy values.</summary>
|
||||||
public static class MxStatusProxyExtensions
|
public static class MxStatusProxyExtensions
|
||||||
{
|
{
|
||||||
/// <summary>Returns whether the status indicates success (success flag set and category is Ok).</summary>
|
/// <summary>Returns whether the status indicates success (success flag set and category is Ok).</summary>
|
||||||
/// <param name="status">The status to check.</param>
|
/// <param name="status">The status to check.</param>
|
||||||
/// <returns><c>true</c> if the status is successful; <c>false</c> otherwise.</returns>
|
|
||||||
public static bool IsSuccess(this MxStatusProxy status)
|
public static bool IsSuccess(this MxStatusProxy status)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(status);
|
ArgumentNullException.ThrowIfNull(status);
|
||||||
@@ -18,7 +17,6 @@ public static class MxStatusProxyExtensions
|
|||||||
|
|
||||||
/// <summary>Returns a formatted summary of the status for diagnostic output.</summary>
|
/// <summary>Returns a formatted summary of the status for diagnostic output.</summary>
|
||||||
/// <param name="status">The status to summarize.</param>
|
/// <param name="status">The status to summarize.</param>
|
||||||
/// <returns>A human-readable string combining category, source, detail, and diagnostic text.</returns>
|
|
||||||
public static string ToDiagnosticSummary(this MxStatusProxy status)
|
public static string ToDiagnosticSummary(this MxStatusProxy status)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(status);
|
ArgumentNullException.ThrowIfNull(status);
|
||||||
+2
-21
@@ -1,8 +1,8 @@
|
|||||||
using Google.Protobuf;
|
using Google.Protobuf;
|
||||||
using Google.Protobuf.WellKnownTypes;
|
using Google.Protobuf.WellKnownTypes;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Client;
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates and projects gateway MXAccess values without hiding the raw
|
/// Creates and projects gateway MXAccess values without hiding the raw
|
||||||
@@ -14,7 +14,6 @@ public static class MxValueExtensions
|
|||||||
/// Converts a boolean value to an MxValue with MxDataType.Boolean.
|
/// Converts a boolean value to an MxValue with MxDataType.Boolean.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="value">Scalar boolean value to wrap.</param>
|
/// <param name="value">Scalar boolean value to wrap.</param>
|
||||||
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Boolean</c>.</returns>
|
|
||||||
public static MxValue ToMxValue(this bool value)
|
public static MxValue ToMxValue(this bool value)
|
||||||
{
|
{
|
||||||
return new MxValue
|
return new MxValue
|
||||||
@@ -29,7 +28,6 @@ public static class MxValueExtensions
|
|||||||
/// Converts a 32-bit integer value to an MxValue with MxDataType.Integer.
|
/// Converts a 32-bit integer value to an MxValue with MxDataType.Integer.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="value">32-bit integer value to wrap.</param>
|
/// <param name="value">32-bit integer value to wrap.</param>
|
||||||
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Integer</c>.</returns>
|
|
||||||
public static MxValue ToMxValue(this int value)
|
public static MxValue ToMxValue(this int value)
|
||||||
{
|
{
|
||||||
return new MxValue
|
return new MxValue
|
||||||
@@ -44,7 +42,6 @@ public static class MxValueExtensions
|
|||||||
/// Converts a 64-bit integer value to an MxValue with MxDataType.Integer.
|
/// Converts a 64-bit integer value to an MxValue with MxDataType.Integer.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="value">64-bit integer value to wrap.</param>
|
/// <param name="value">64-bit integer value to wrap.</param>
|
||||||
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Integer</c>.</returns>
|
|
||||||
public static MxValue ToMxValue(this long value)
|
public static MxValue ToMxValue(this long value)
|
||||||
{
|
{
|
||||||
return new MxValue
|
return new MxValue
|
||||||
@@ -59,7 +56,6 @@ public static class MxValueExtensions
|
|||||||
/// Converts a single-precision floating-point value to an MxValue with MxDataType.Float.
|
/// Converts a single-precision floating-point value to an MxValue with MxDataType.Float.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="value">Single-precision floating-point value to wrap.</param>
|
/// <param name="value">Single-precision floating-point value to wrap.</param>
|
||||||
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Float</c>.</returns>
|
|
||||||
public static MxValue ToMxValue(this float value)
|
public static MxValue ToMxValue(this float value)
|
||||||
{
|
{
|
||||||
return new MxValue
|
return new MxValue
|
||||||
@@ -74,7 +70,6 @@ public static class MxValueExtensions
|
|||||||
/// Converts a double-precision floating-point value to an MxValue with MxDataType.Double.
|
/// Converts a double-precision floating-point value to an MxValue with MxDataType.Double.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="value">Double-precision floating-point value to wrap.</param>
|
/// <param name="value">Double-precision floating-point value to wrap.</param>
|
||||||
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Double</c>.</returns>
|
|
||||||
public static MxValue ToMxValue(this double value)
|
public static MxValue ToMxValue(this double value)
|
||||||
{
|
{
|
||||||
return new MxValue
|
return new MxValue
|
||||||
@@ -89,7 +84,6 @@ public static class MxValueExtensions
|
|||||||
/// Converts a string value to an MxValue with MxDataType.String.
|
/// Converts a string value to an MxValue with MxDataType.String.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="value">String value to wrap.</param>
|
/// <param name="value">String value to wrap.</param>
|
||||||
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.String</c>.</returns>
|
|
||||||
public static MxValue ToMxValue(this string value)
|
public static MxValue ToMxValue(this string value)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(value);
|
ArgumentNullException.ThrowIfNull(value);
|
||||||
@@ -106,7 +100,6 @@ public static class MxValueExtensions
|
|||||||
/// Converts a DateTimeOffset value to an MxValue with MxDataType.Time.
|
/// Converts a DateTimeOffset value to an MxValue with MxDataType.Time.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="value">DateTimeOffset value to wrap.</param>
|
/// <param name="value">DateTimeOffset value to wrap.</param>
|
||||||
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Time</c>.</returns>
|
|
||||||
public static MxValue ToMxValue(this DateTimeOffset value)
|
public static MxValue ToMxValue(this DateTimeOffset value)
|
||||||
{
|
{
|
||||||
return new MxValue
|
return new MxValue
|
||||||
@@ -121,7 +114,6 @@ public static class MxValueExtensions
|
|||||||
/// Converts a DateTime value to an MxValue with MxDataType.Time.
|
/// Converts a DateTime value to an MxValue with MxDataType.Time.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="value">DateTime value to wrap.</param>
|
/// <param name="value">DateTime value to wrap.</param>
|
||||||
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Time</c>.</returns>
|
|
||||||
public static MxValue ToMxValue(this DateTime value)
|
public static MxValue ToMxValue(this DateTime value)
|
||||||
{
|
{
|
||||||
return new DateTimeOffset(
|
return new DateTimeOffset(
|
||||||
@@ -135,7 +127,6 @@ public static class MxValueExtensions
|
|||||||
/// Converts a boolean array to an MxValue with MxDataType.Boolean.
|
/// Converts a boolean array to an MxValue with MxDataType.Boolean.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="values">Array of boolean values to wrap.</param>
|
/// <param name="values">Array of boolean values to wrap.</param>
|
||||||
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Boolean</c> and an array payload.</returns>
|
|
||||||
public static MxValue ToMxValue(this IReadOnlyList<bool> values)
|
public static MxValue ToMxValue(this IReadOnlyList<bool> values)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(values);
|
ArgumentNullException.ThrowIfNull(values);
|
||||||
@@ -154,7 +145,6 @@ public static class MxValueExtensions
|
|||||||
/// Converts a 32-bit integer array to an MxValue with MxDataType.Integer.
|
/// Converts a 32-bit integer array to an MxValue with MxDataType.Integer.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="values">Array of 32-bit integer values to wrap.</param>
|
/// <param name="values">Array of 32-bit integer values to wrap.</param>
|
||||||
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Integer</c> and an array payload.</returns>
|
|
||||||
public static MxValue ToMxValue(this IReadOnlyList<int> values)
|
public static MxValue ToMxValue(this IReadOnlyList<int> values)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(values);
|
ArgumentNullException.ThrowIfNull(values);
|
||||||
@@ -173,7 +163,6 @@ public static class MxValueExtensions
|
|||||||
/// Converts a 64-bit integer array to an MxValue with MxDataType.Integer.
|
/// Converts a 64-bit integer array to an MxValue with MxDataType.Integer.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="values">Array of 64-bit integer values to wrap.</param>
|
/// <param name="values">Array of 64-bit integer values to wrap.</param>
|
||||||
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Integer</c> and an array payload.</returns>
|
|
||||||
public static MxValue ToMxValue(this IReadOnlyList<long> values)
|
public static MxValue ToMxValue(this IReadOnlyList<long> values)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(values);
|
ArgumentNullException.ThrowIfNull(values);
|
||||||
@@ -192,7 +181,6 @@ public static class MxValueExtensions
|
|||||||
/// Converts a single-precision floating-point array to an MxValue with MxDataType.Float.
|
/// Converts a single-precision floating-point array to an MxValue with MxDataType.Float.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="values">Array of single-precision floating-point values to wrap.</param>
|
/// <param name="values">Array of single-precision floating-point values to wrap.</param>
|
||||||
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Float</c> and an array payload.</returns>
|
|
||||||
public static MxValue ToMxValue(this IReadOnlyList<float> values)
|
public static MxValue ToMxValue(this IReadOnlyList<float> values)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(values);
|
ArgumentNullException.ThrowIfNull(values);
|
||||||
@@ -211,7 +199,6 @@ public static class MxValueExtensions
|
|||||||
/// Converts a double-precision floating-point array to an MxValue with MxDataType.Double.
|
/// Converts a double-precision floating-point array to an MxValue with MxDataType.Double.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="values">Array of double-precision floating-point values to wrap.</param>
|
/// <param name="values">Array of double-precision floating-point values to wrap.</param>
|
||||||
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Double</c> and an array payload.</returns>
|
|
||||||
public static MxValue ToMxValue(this IReadOnlyList<double> values)
|
public static MxValue ToMxValue(this IReadOnlyList<double> values)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(values);
|
ArgumentNullException.ThrowIfNull(values);
|
||||||
@@ -230,7 +217,6 @@ public static class MxValueExtensions
|
|||||||
/// Converts a string array to an MxValue with MxDataType.String.
|
/// Converts a string array to an MxValue with MxDataType.String.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="values">Array of string values to wrap.</param>
|
/// <param name="values">Array of string values to wrap.</param>
|
||||||
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.String</c> and an array payload.</returns>
|
|
||||||
public static MxValue ToMxValue(this IReadOnlyList<string> values)
|
public static MxValue ToMxValue(this IReadOnlyList<string> values)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(values);
|
ArgumentNullException.ThrowIfNull(values);
|
||||||
@@ -249,7 +235,6 @@ public static class MxValueExtensions
|
|||||||
/// Converts a DateTimeOffset array to an MxValue with MxDataType.Time.
|
/// Converts a DateTimeOffset array to an MxValue with MxDataType.Time.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="values">Array of DateTimeOffset values to wrap.</param>
|
/// <param name="values">Array of DateTimeOffset values to wrap.</param>
|
||||||
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Time</c> and an array payload.</returns>
|
|
||||||
public static MxValue ToMxValue(this IReadOnlyList<DateTimeOffset> values)
|
public static MxValue ToMxValue(this IReadOnlyList<DateTimeOffset> values)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(values);
|
ArgumentNullException.ThrowIfNull(values);
|
||||||
@@ -268,7 +253,6 @@ public static class MxValueExtensions
|
|||||||
/// Gets the projection kind (field name) of the given MxValue's current oneof value.
|
/// Gets the projection kind (field name) of the given MxValue's current oneof value.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="value">The MxValue whose oneof projection kind is returned.</param>
|
/// <param name="value">The MxValue whose oneof projection kind is returned.</param>
|
||||||
/// <returns>The JSON field name of the active oneof case, or <c>"nullValue"</c>/<c>"unspecified"</c> for null/unset values.</returns>
|
|
||||||
public static string GetProjectionKind(this MxValue value)
|
public static string GetProjectionKind(this MxValue value)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(value);
|
ArgumentNullException.ThrowIfNull(value);
|
||||||
@@ -292,7 +276,6 @@ public static class MxValueExtensions
|
|||||||
/// Converts an MxValue to a CLR object; returns the boxed value or null for null MxValues.
|
/// Converts an MxValue to a CLR object; returns the boxed value or null for null MxValues.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="value">The MxValue to convert.</param>
|
/// <param name="value">The MxValue to convert.</param>
|
||||||
/// <returns>The boxed CLR value, or null if the MxValue represents a null.</returns>
|
|
||||||
public static object? ToClrValue(this MxValue value)
|
public static object? ToClrValue(this MxValue value)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(value);
|
ArgumentNullException.ThrowIfNull(value);
|
||||||
@@ -316,7 +299,6 @@ public static class MxValueExtensions
|
|||||||
/// Converts an MxArray to a CLR array; returns null if the array does not have a known element type.
|
/// Converts an MxArray to a CLR array; returns null if the array does not have a known element type.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="array">The MxArray to convert.</param>
|
/// <param name="array">The MxArray to convert.</param>
|
||||||
/// <returns>A CLR array of the appropriate element type, or null for unknown element types.</returns>
|
|
||||||
public static object? ToClrArrayValue(this MxArray array)
|
public static object? ToClrArrayValue(this MxArray array)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(array);
|
ArgumentNullException.ThrowIfNull(array);
|
||||||
@@ -346,7 +328,6 @@ public static class MxValueExtensions
|
|||||||
/// <param name="variantType">Variant type string (e.g., "VT_BSTR").</param>
|
/// <param name="variantType">Variant type string (e.g., "VT_BSTR").</param>
|
||||||
/// <param name="rawDiagnostic">Diagnostic string describing the raw value.</param>
|
/// <param name="rawDiagnostic">Diagnostic string describing the raw value.</param>
|
||||||
/// <param name="rawDataType">Optional MXAccess data type override.</param>
|
/// <param name="rawDataType">Optional MXAccess data type override.</param>
|
||||||
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Unknown</c> and the raw byte payload.</returns>
|
|
||||||
public static MxValue ToRawMxValue(
|
public static MxValue ToRawMxValue(
|
||||||
byte[] value,
|
byte[] value,
|
||||||
string variantType,
|
string variantType,
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("MxGateway.Client.Tests")]
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+81
-120
@@ -7,11 +7,11 @@ CLI, and unit tests.
|
|||||||
|
|
||||||
| Project | Purpose |
|
| Project | Purpose |
|
||||||
|---------|---------|
|
|---------|---------|
|
||||||
| `ZB.MOM.WW.MxGateway.Client` | .NET 10 library entry point, raw gRPC calls, and session helpers. |
|
| `MxGateway.Client` | .NET 10 library entry point, raw gRPC calls, and session helpers. |
|
||||||
| `ZB.MOM.WW.MxGateway.Client.Cli` | Test CLI for smoke and diagnostic commands. |
|
| `MxGateway.Client.Cli` | Test CLI for smoke and diagnostic commands. |
|
||||||
| `ZB.MOM.WW.MxGateway.Client.Tests` | Unit tests for client options, generated contract wiring, auth metadata, session helpers, cancellation, and event streaming. |
|
| `MxGateway.Client.Tests` | Unit tests for client options, generated contract wiring, auth metadata, session helpers, cancellation, and event streaming. |
|
||||||
|
|
||||||
The projects reference `src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj` so
|
The projects reference `src/MxGateway.Contracts/MxGateway.Contracts.csproj` so
|
||||||
the client compiles against the same generated protobuf and gRPC types as the
|
the client compiles against the same generated protobuf and gRPC types as the
|
||||||
gateway. `clients/dotnet/generated` remains reserved for generator output if a
|
gateway. `clients/dotnet/generated` remains reserved for generator output if a
|
||||||
future client build switches to client-local `Grpc.Tools` generation.
|
future client build switches to client-local `Grpc.Tools` generation.
|
||||||
@@ -19,8 +19,8 @@ future client build switches to client-local `Grpc.Tools` generation.
|
|||||||
## Build And Test
|
## Build And Test
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
dotnet build clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx
|
dotnet build clients/dotnet/MxGateway.Client.sln
|
||||||
dotnet test clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx --no-build
|
dotnet test clients/dotnet/MxGateway.Client.sln --no-build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Packaging
|
## Packaging
|
||||||
@@ -29,8 +29,8 @@ Create local library and CLI artifacts from the repository root:
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$dotnetPackageOutput = Join-Path (Get-Location) 'artifacts/clients/dotnet'
|
$dotnetPackageOutput = Join-Path (Get-Location) 'artifacts/clients/dotnet'
|
||||||
dotnet pack clients/dotnet/ZB.MOM.WW.MxGateway.Client/ZB.MOM.WW.MxGateway.Client.csproj -c Release -p:PackageOutputPath="$dotnetPackageOutput"
|
dotnet pack clients/dotnet/MxGateway.Client/MxGateway.Client.csproj -c Release -p:PackageOutputPath="$dotnetPackageOutput"
|
||||||
dotnet publish clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/ZB.MOM.WW.MxGateway.Client.Cli.csproj -c Release -o artifacts/clients/dotnet/mxgw-dotnet
|
dotnet publish clients/dotnet/MxGateway.Client.Cli/MxGateway.Client.Cli.csproj -c Release -o artifacts/clients/dotnet/mxgw-dotnet
|
||||||
```
|
```
|
||||||
|
|
||||||
The library package references the shared contracts project at build time. The
|
The library package references the shared contracts project at build time. The
|
||||||
@@ -39,11 +39,11 @@ published CLI runs from `artifacts/clients/dotnet/mxgw-dotnet`.
|
|||||||
## Regenerating Protobuf Bindings
|
## Regenerating Protobuf Bindings
|
||||||
|
|
||||||
The .NET client uses the generated C# types from
|
The .NET client uses the generated C# types from
|
||||||
`src/ZB.MOM.WW.MxGateway.Contracts/Generated`. Regenerate those files through the
|
`src/MxGateway.Contracts/Generated`. Regenerate those files through the
|
||||||
contracts project:
|
contracts project:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj
|
dotnet build src/MxGateway.Contracts/MxGateway.Contracts.csproj
|
||||||
```
|
```
|
||||||
|
|
||||||
## Client Usage
|
## Client Usage
|
||||||
@@ -84,14 +84,47 @@ messages. `MxGatewaySession.OpenSessionReply` keeps the raw session-open reply
|
|||||||
available, and command helpers have `*RawAsync` variants when callers need the
|
available, and command helpers have `*RawAsync` variants when callers need the
|
||||||
complete `MxCommandReply`.
|
complete `MxCommandReply`.
|
||||||
|
|
||||||
For alarms, the client exposes `QueryActiveAlarmsAsync` (one-shot snapshot of
|
### Bulk Commands
|
||||||
the active alarms the gateway's central monitor currently holds),
|
|
||||||
`StreamAlarmsAsync` (server-streaming feed of alarm-state-change messages
|
The session exposes bulk variants for every command family that has one
|
||||||
keyed by the same monitor), and `AcknowledgeAlarmAsync` (ack by alarm
|
upstream — they all carry a list of entries in one gRPC round-trip, the worker
|
||||||
reference, optional comment, ack target). All three accept a cancellation
|
runs the per-item MXAccess calls sequentially on its STA, and the reply
|
||||||
token and pass through the `MxGateway:Alarms` configuration on the
|
returns one result per requested entry. Per-entry failures populate
|
||||||
server — when alarms are disabled, the gateway returns an empty list / empty
|
`WasSuccessful = false` with the underlying HRESULT and never throw; only
|
||||||
stream rather than failing.
|
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
|
`MxGatewaySession.CloseAsync` is explicit and idempotent. Repeated calls return
|
||||||
the first `CloseSessionReply` instead of sending another close request.
|
the first `CloseSessionReply` instead of sending another close request.
|
||||||
@@ -121,28 +154,38 @@ can keep the full `MxCommandReply`, HRESULT, and status array when MXAccess
|
|||||||
itself rejects a command. `MxAccessException.Reply` contains the raw generated
|
itself rejects a command. `MxAccessException.Reply` contains the raw generated
|
||||||
reply.
|
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
|
## CLI Usage
|
||||||
|
|
||||||
The test CLI supports deterministic JSON output for automation:
|
The test CLI supports deterministic JSON output for automation:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- version --json
|
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- version --json
|
||||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- open-session --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
|
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- open-session --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
|
||||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- register --session-id <id> --client-name mxgw-dotnet-cli --json
|
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- register --session-id <id> --client-name mxgw-dotnet-cli --json
|
||||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- add-item --session-id <id> --server-handle 1 --item Area001.Pump001.Speed --json
|
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- add-item --session-id <id> --server-handle 1 --item Area001.Pump001.Speed --json
|
||||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- advise --session-id <id> --server-handle 1 --item-handle 1 --json
|
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- advise --session-id <id> --server-handle 1 --item-handle 1 --json
|
||||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- write --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --json
|
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- write --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --json
|
||||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- write2 --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --timestamp 2026-01-01T00:00:00Z --json
|
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- write2 --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --timestamp 2026-01-01T00:00:00Z --json
|
||||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- stream-events --session-id <id> --max-events 1 --json
|
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- stream-events --session-id <id> --max-events 1 --json
|
||||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- stream-alarms --filter-prefix Area001 --max-events 1 --json
|
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- smoke --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --item Area001.Pump001.Speed --json
|
||||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- acknowledge-alarm --reference "\\Galaxy\Area001.Pump001.PumpFault" --comment "ack from cli" --operator operator1 --json
|
|
||||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- smoke --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --item Area001.Pump001.Speed --json
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`smoke` opens a session, registers a client, adds one item, advises it,
|
`smoke` opens a session, registers a client, adds one item, advises it,
|
||||||
optionally writes a value when `--type` and `--value` are supplied, reads a
|
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
|
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
|
## Galaxy Repository Browse
|
||||||
|
|
||||||
@@ -191,59 +234,11 @@ IReadOnlyList<GalaxyObject> pumps = await repository.DiscoverHierarchyAsync(
|
|||||||
The CLI exposes the same operations:
|
The CLI exposes the same operations:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-test-connection --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
|
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-test-connection --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
|
||||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-last-deploy --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
|
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-last-deploy --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
|
||||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-discover --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY
|
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-discover --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY
|
||||||
```
|
```
|
||||||
|
|
||||||
### Browsing lazily
|
|
||||||
|
|
||||||
For UI trees or OPC UA bridges, use `BrowseChildrenAsync` to walk one level at a
|
|
||||||
time instead of paging the full hierarchy. Pass an empty request for root objects;
|
|
||||||
subsequent calls supply `ParentGobjectId`, `ParentTagName`, or
|
|
||||||
`ParentContainedPath`. Each child's `ChildHasChildren[i]` tells you whether to
|
|
||||||
draw an expand triangle. Filter fields match `DiscoverHierarchy`. See
|
|
||||||
[Galaxy Repository](../../docs/GalaxyRepository.md#browsechildren) for full
|
|
||||||
request and filter semantics.
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
BrowseChildrenReply roots = await repository.BrowseChildrenAsync(
|
|
||||||
new BrowseChildrenRequest());
|
|
||||||
|
|
||||||
for (int i = 0; i < roots.Children.Count; i++)
|
|
||||||
{
|
|
||||||
GalaxyObject child = roots.Children[i];
|
|
||||||
bool hasChildren = roots.ChildHasChildren[i];
|
|
||||||
Console.WriteLine($"{child.TagName} expand={hasChildren}");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### High-level walker
|
|
||||||
|
|
||||||
For UI trees, the client provides a `LazyBrowseNode` walker that handles
|
|
||||||
sibling pagination and the `child_has_children` hint for you:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
await using GalaxyRepositoryClient repository = GalaxyRepositoryClient.Create(
|
|
||||||
new MxGatewayClientOptions { Endpoint = new Uri("http://localhost:5000"), ApiKey = apiKey });
|
|
||||||
IReadOnlyList<LazyBrowseNode> roots = await repository.BrowseAsync();
|
|
||||||
foreach (LazyBrowseNode root in roots)
|
|
||||||
{
|
|
||||||
if (root.HasChildrenHint)
|
|
||||||
{
|
|
||||||
await root.ExpandAsync();
|
|
||||||
}
|
|
||||||
foreach (LazyBrowseNode child in root.Children)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"{child.Object.TagName} ({(child.HasChildrenHint ? "has children" : "leaf")})");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`ExpandAsync` is idempotent — calling it twice fires only one RPC,
|
|
||||||
and is safe under concurrent callers. To refresh after a Galaxy redeploy, call
|
|
||||||
`BrowseAsync` again from the root.
|
|
||||||
|
|
||||||
### Watching deploy events
|
### Watching deploy events
|
||||||
|
|
||||||
`WatchDeployEventsAsync` opens the `WatchDeployEvents` server-streaming RPC. The
|
`WatchDeployEventsAsync` opens the `WatchDeployEvents` server-streaming RPC. The
|
||||||
@@ -276,28 +271,17 @@ await foreach (DeployEvent evt in repository.WatchDeployEventsAsync(
|
|||||||
The CLI counterpart streams events until Ctrl+C (or `--max-events`):
|
The CLI counterpart streams events until Ctrl+C (or `--max-events`):
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY
|
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY
|
||||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --last-seen-deploy-time 2026-04-28T14:30:00Z --json
|
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --last-seen-deploy-time 2026-04-28T14:30:00Z --json
|
||||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --max-events 5 --json
|
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --max-events 5 --json
|
||||||
```
|
```
|
||||||
|
|
||||||
Use TLS options for a secured gateway:
|
Use TLS options for a secured gateway:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- smoke --endpoint https://ZB.MOM.WW.MxGateway.example.local:5001 --tls --ca-file C:\certs\mxgateway-ca.pem --server-name ZB.MOM.WW.MxGateway.example.local --api-key-env MXGATEWAY_API_KEY --item Area001.Pump001.Speed --json
|
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- smoke --endpoint https://mxgateway.example.local:5001 --tls --ca-file C:\certs\mxgateway-ca.pem --server-name mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item Area001.Pump001.Speed --json
|
||||||
```
|
```
|
||||||
|
|
||||||
### TLS trust
|
|
||||||
|
|
||||||
The gateway can auto-generate its own self-signed certificate (it has no PKI), so
|
|
||||||
the client is **lenient by default**: a TLS connection (`UseTls` / `--tls`) with
|
|
||||||
no pinned CA accepts whatever certificate the gateway presents. To verify
|
|
||||||
instead, pin a CA with `CaCertificatePath` / `--ca-file` (this path also enforces
|
|
||||||
the certificate hostname/SAN match), or set `RequireCertificateValidation` to
|
|
||||||
force OS/system-trust verification without pinning. Use `ServerNameOverride` /
|
|
||||||
`--server-name` when the dialed host differs from the certificate SAN. See
|
|
||||||
[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate).
|
|
||||||
|
|
||||||
## Integration Checks
|
## Integration Checks
|
||||||
|
|
||||||
Run live checks only when a gateway and MXAccess-backed worker are available:
|
Run live checks only when a gateway and MXAccess-backed worker are available:
|
||||||
@@ -307,32 +291,9 @@ $env:MXGATEWAY_INTEGRATION = '1'
|
|||||||
$env:MXGATEWAY_ENDPOINT = 'http://localhost:5000'
|
$env:MXGATEWAY_ENDPOINT = 'http://localhost:5000'
|
||||||
$env:MXGATEWAY_API_KEY = '<gateway-api-key>'
|
$env:MXGATEWAY_API_KEY = '<gateway-api-key>'
|
||||||
$env:MXGATEWAY_TEST_ITEM = 'Area001.Pump001.Speed'
|
$env:MXGATEWAY_TEST_ITEM = 'Area001.Pump001.Speed'
|
||||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- smoke --endpoint $env:MXGATEWAY_ENDPOINT --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json
|
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- smoke --endpoint $env:MXGATEWAY_ENDPOINT --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json
|
||||||
```
|
```
|
||||||
|
|
||||||
## Installing as a NuGet Package
|
|
||||||
|
|
||||||
The client publishes to the internal Gitea NuGet feed at
|
|
||||||
`https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json`.
|
|
||||||
|
|
||||||
Add the feed once:
|
|
||||||
|
|
||||||
````bash
|
|
||||||
dotnet nuget add source https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json \
|
|
||||||
--name dohertj2-gitea \
|
|
||||||
--username <gitea-username> \
|
|
||||||
--password <gitea-token-or-password> \
|
|
||||||
--store-password-in-clear-text
|
|
||||||
````
|
|
||||||
|
|
||||||
Then add the package to your project:
|
|
||||||
|
|
||||||
````bash
|
|
||||||
dotnet add package ZB.MOM.WW.MxGateway.Client --version 0.1.0
|
|
||||||
````
|
|
||||||
|
|
||||||
The `ZB.MOM.WW.MxGateway.Contracts` package is pulled in transitively.
|
|
||||||
|
|
||||||
## Related Documentation
|
## Related Documentation
|
||||||
|
|
||||||
- [Client Packaging](../../docs/ClientPackaging.md)
|
- [Client Packaging](../../docs/ClientPackaging.md)
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
using Grpc.Core;
|
|
||||||
using Grpc.Net.Client;
|
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Live smoke tests for the BrowseChildren RPC. Skipped by default; set
|
|
||||||
/// MXGATEWAY_API_KEY and MXGATEWAY_ENDPOINT to run against a real gateway.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class BrowseChildrenSmokeTests
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Verifies that BrowseChildren returns a non-zero cache sequence and
|
|
||||||
/// a consistent children/child-has-children count from a live gateway.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact(Skip = "Set MXGATEWAY_API_KEY and MXGATEWAY_ENDPOINT to enable.")]
|
|
||||||
public async Task BrowseChildren_LiveGateway_ReturnsRootsWithCacheSequence()
|
|
||||||
{
|
|
||||||
string? apiKey = Environment.GetEnvironmentVariable("MXGATEWAY_API_KEY");
|
|
||||||
string endpoint = Environment.GetEnvironmentVariable("MXGATEWAY_ENDPOINT") ?? "http://localhost:5120";
|
|
||||||
|
|
||||||
Assert.False(string.IsNullOrEmpty(apiKey), "MXGATEWAY_API_KEY must be set.");
|
|
||||||
|
|
||||||
using GrpcChannel channel = GrpcChannel.ForAddress(endpoint);
|
|
||||||
GalaxyRepository.GalaxyRepositoryClient client = new(channel);
|
|
||||||
|
|
||||||
Metadata headers = new() { { "authorization", $"Bearer {apiKey}" } };
|
|
||||||
BrowseChildrenReply reply = await client.BrowseChildrenAsync(new BrowseChildrenRequest(), headers);
|
|
||||||
|
|
||||||
Assert.True(reply.CacheSequence > 0UL);
|
|
||||||
Assert.Equal(reply.Children.Count, reply.ChildHasChildren.Count);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,228 +0,0 @@
|
|||||||
using Grpc.Core;
|
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Tests for the <see cref="LazyBrowseNode"/> walker over the BrowseChildren RPC.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class LazyBrowseNodeTests
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Verifies that calling BrowseAsync with no parent returns the root nodes
|
|
||||||
/// from the first BrowseChildren reply and surfaces the per-child has-children hint.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
|
||||||
public async Task Browse_NoParent_ReturnsRoots()
|
|
||||||
{
|
|
||||||
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
|
||||||
transport.BrowseChildrenReplies.Enqueue(BuildReply(
|
|
||||||
children: [BuildObject(1, "Plant", isArea: true), BuildObject(2, "Other")],
|
|
||||||
childHasChildren: [true, false],
|
|
||||||
cacheSequence: 1));
|
|
||||||
await using GalaxyRepositoryClient client = CreateClient(transport);
|
|
||||||
|
|
||||||
IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();
|
|
||||||
|
|
||||||
Assert.Equal(2, roots.Count);
|
|
||||||
Assert.Equal("Plant", roots[0].Object.TagName);
|
|
||||||
Assert.True(roots[0].HasChildrenHint);
|
|
||||||
Assert.False(roots[0].IsExpanded);
|
|
||||||
Assert.Equal("Other", roots[1].Object.TagName);
|
|
||||||
Assert.False(roots[1].HasChildrenHint);
|
|
||||||
Assert.False(roots[1].IsExpanded);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Verifies that ExpandAsync populates Children and marks the node expanded after one RPC.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
|
||||||
public async Task Expand_PopulatesChildrenAndMarksExpanded()
|
|
||||||
{
|
|
||||||
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
|
||||||
transport.BrowseChildrenReplies.Enqueue(BuildReply(
|
|
||||||
children: [BuildObject(1, "Plant", isArea: true)],
|
|
||||||
childHasChildren: [true],
|
|
||||||
cacheSequence: 1));
|
|
||||||
transport.BrowseChildrenReplies.Enqueue(BuildReply(
|
|
||||||
children: [BuildObject(10, "Line1")],
|
|
||||||
childHasChildren: [false],
|
|
||||||
cacheSequence: 1));
|
|
||||||
await using GalaxyRepositoryClient client = CreateClient(transport);
|
|
||||||
|
|
||||||
IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();
|
|
||||||
await roots[0].ExpandAsync();
|
|
||||||
|
|
||||||
Assert.True(roots[0].IsExpanded);
|
|
||||||
Assert.Single(roots[0].Children);
|
|
||||||
Assert.Equal("Line1", roots[0].Children[0].Object.TagName);
|
|
||||||
Assert.Equal(2, transport.BrowseChildrenCalls.Count);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Verifies that a second ExpandAsync call is a no-op and issues no additional RPC.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
|
||||||
public async Task Expand_CalledTwice_NoSecondRpc()
|
|
||||||
{
|
|
||||||
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
|
||||||
transport.BrowseChildrenReplies.Enqueue(BuildReply(
|
|
||||||
children: [BuildObject(1, "Plant", isArea: true)],
|
|
||||||
childHasChildren: [true],
|
|
||||||
cacheSequence: 1));
|
|
||||||
transport.BrowseChildrenReplies.Enqueue(BuildReply(
|
|
||||||
children: [BuildObject(10, "Line1")],
|
|
||||||
childHasChildren: [false],
|
|
||||||
cacheSequence: 1));
|
|
||||||
await using GalaxyRepositoryClient client = CreateClient(transport);
|
|
||||||
|
|
||||||
IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();
|
|
||||||
await roots[0].ExpandAsync();
|
|
||||||
await roots[0].ExpandAsync();
|
|
||||||
|
|
||||||
Assert.Equal(2, transport.BrowseChildrenCalls.Count);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Verifies that an RPC failure (NotFound) during expand is wrapped in MxGatewayException.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
|
||||||
public async Task Expand_UnknownParent_ThrowsMxGatewayException()
|
|
||||||
{
|
|
||||||
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
|
||||||
transport.BrowseChildrenReplies.Enqueue(BuildReply(
|
|
||||||
children: [BuildObject(1, "Plant", isArea: true)],
|
|
||||||
childHasChildren: [true],
|
|
||||||
cacheSequence: 1));
|
|
||||||
await using GalaxyRepositoryClient client = CreateClient(transport);
|
|
||||||
|
|
||||||
IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();
|
|
||||||
|
|
||||||
// Queue the failure for the upcoming ExpandAsync call so it consumes
|
|
||||||
// the exception on its first RPC rather than the BrowseAsync above.
|
|
||||||
transport.BrowseChildrenExceptions.Enqueue(
|
|
||||||
new MxGatewayException(
|
|
||||||
"Parent not found",
|
|
||||||
new RpcException(new Status(StatusCode.NotFound, "Parent not found"))));
|
|
||||||
|
|
||||||
await Assert.ThrowsAsync<MxGatewayException>(async () => await roots[0].ExpandAsync());
|
|
||||||
Assert.False(roots[0].IsExpanded);
|
|
||||||
Assert.Empty(roots[0].Children);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Verifies that ExpandAsync drains multi-page sibling replies and forwards the page token.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
|
||||||
public async Task Expand_MultiPageSiblings_GathersAllPages()
|
|
||||||
{
|
|
||||||
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
|
||||||
// Roots
|
|
||||||
transport.BrowseChildrenReplies.Enqueue(BuildReply(
|
|
||||||
children: [BuildObject(7, "Plant", isArea: true)],
|
|
||||||
childHasChildren: [true],
|
|
||||||
cacheSequence: 1));
|
|
||||||
// First child page (2 children) with a next token
|
|
||||||
BrowseChildrenReply childPage1 = BuildReply(
|
|
||||||
children: [BuildObject(70, "ChildA"), BuildObject(71, "ChildB")],
|
|
||||||
childHasChildren: [false, false],
|
|
||||||
cacheSequence: 1);
|
|
||||||
childPage1.NextPageToken = "7:abc:2";
|
|
||||||
transport.BrowseChildrenReplies.Enqueue(childPage1);
|
|
||||||
// Second child page (1 child) with no next token
|
|
||||||
transport.BrowseChildrenReplies.Enqueue(BuildReply(
|
|
||||||
children: [BuildObject(72, "ChildC")],
|
|
||||||
childHasChildren: [false],
|
|
||||||
cacheSequence: 1));
|
|
||||||
await using GalaxyRepositoryClient client = CreateClient(transport);
|
|
||||||
|
|
||||||
IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();
|
|
||||||
await roots[0].ExpandAsync();
|
|
||||||
|
|
||||||
Assert.Equal(3, roots[0].Children.Count);
|
|
||||||
Assert.Equal(3, transport.BrowseChildrenCalls.Count);
|
|
||||||
Assert.Equal("7:abc:2", transport.BrowseChildrenCalls[2].Request.PageToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Verifies that ten concurrent ExpandAsync calls issue exactly one RPC, not ten.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
|
||||||
public async Task Expand_CalledConcurrently_OnlyFiresOneRpc()
|
|
||||||
{
|
|
||||||
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
|
||||||
transport.BrowseChildrenReplies.Enqueue(BuildReply(
|
|
||||||
children: [BuildObject(1, "Plant", isArea: true)],
|
|
||||||
childHasChildren: [true],
|
|
||||||
cacheSequence: 7));
|
|
||||||
transport.BrowseChildrenReplies.Enqueue(BuildReply(
|
|
||||||
children: [BuildObject(2, "Mixer_001")],
|
|
||||||
childHasChildren: [false],
|
|
||||||
cacheSequence: 7));
|
|
||||||
|
|
||||||
await using GalaxyRepositoryClient client = CreateClient(transport);
|
|
||||||
IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();
|
|
||||||
|
|
||||||
// Fire ten concurrent expands of the same node.
|
|
||||||
Task[] tasks = Enumerable.Range(0, 10)
|
|
||||||
.Select(_ => roots[0].ExpandAsync())
|
|
||||||
.ToArray();
|
|
||||||
await Task.WhenAll(tasks);
|
|
||||||
|
|
||||||
Assert.True(roots[0].IsExpanded);
|
|
||||||
Assert.Single(roots[0].Children);
|
|
||||||
// 1 roots fetch + exactly 1 expand fetch = 2 total
|
|
||||||
Assert.Equal(2, transport.BrowseChildrenCalls.Count);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Verifies that BrowseChildrenOptions filter fields are forwarded to the BrowseChildren request.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
|
||||||
public async Task Browse_WithFilter_ForwardsToRequest()
|
|
||||||
{
|
|
||||||
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
|
||||||
await using GalaxyRepositoryClient client = CreateClient(transport);
|
|
||||||
|
|
||||||
await client.BrowseAsync(new BrowseChildrenOptions
|
|
||||||
{
|
|
||||||
TagNameGlob = "Mixer*",
|
|
||||||
AlarmBearingOnly = true,
|
|
||||||
});
|
|
||||||
|
|
||||||
BrowseChildrenRequest request = Assert.Single(transport.BrowseChildrenCalls).Request;
|
|
||||||
Assert.Equal("Mixer*", request.TagNameGlob);
|
|
||||||
Assert.True(request.AlarmBearingOnly);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static GalaxyObject BuildObject(int id, string tag, bool isArea = false)
|
|
||||||
=> new() { GobjectId = id, TagName = tag, BrowseName = tag, IsArea = isArea };
|
|
||||||
|
|
||||||
private static BrowseChildrenReply BuildReply(
|
|
||||||
IReadOnlyList<GalaxyObject> children,
|
|
||||||
IReadOnlyList<bool> childHasChildren,
|
|
||||||
ulong cacheSequence)
|
|
||||||
{
|
|
||||||
BrowseChildrenReply reply = new() { TotalChildCount = children.Count, CacheSequence = cacheSequence };
|
|
||||||
reply.Children.AddRange(children);
|
|
||||||
reply.ChildHasChildren.AddRange(childHasChildren);
|
|
||||||
return reply;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static GalaxyRepositoryClient CreateClient(FakeGalaxyRepositoryTransport transport)
|
|
||||||
=> new(transport.Options, transport);
|
|
||||||
|
|
||||||
private static FakeGalaxyRepositoryTransport CreateTransport()
|
|
||||||
=> new(new MxGatewayClientOptions
|
|
||||||
{
|
|
||||||
Endpoint = new Uri("http://localhost:5000"),
|
|
||||||
ApiKey = "test-api-key",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
using System.Net.Http;
|
|
||||||
using System.Net.Security;
|
|
||||||
using ZB.MOM.WW.MxGateway.Client;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
|
||||||
|
|
||||||
public sealed class MxGatewayClientTlsHandlerTests
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Verifies that when TLS is used with no pinned CA and RequireCertificateValidation is false (default),
|
|
||||||
/// the handler installs an accept-all callback so the gateway's self-signed cert is trusted.
|
|
||||||
/// The callback must return true regardless of chain errors.
|
|
||||||
/// </summary>
|
|
||||||
[Fact]
|
|
||||||
public void Handler_SkipsVerification_WhenTlsAndNoCaPinned()
|
|
||||||
{
|
|
||||||
MxGatewayClientOptions options = new()
|
|
||||||
{
|
|
||||||
Endpoint = new Uri("https://localhost:5120"),
|
|
||||||
ApiKey = "k",
|
|
||||||
UseTls = true,
|
|
||||||
};
|
|
||||||
using SocketsHttpHandler handler = MxGatewayClient.CreateHttpHandlerForTests(options);
|
|
||||||
Assert.NotNull(handler.SslOptions.RemoteCertificateValidationCallback);
|
|
||||||
Assert.True(handler.SslOptions.RemoteCertificateValidationCallback!(null!, null!, null, SslPolicyErrors.RemoteCertificateChainErrors));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Verifies that when RequireCertificateValidation is true, the callback is left null
|
|
||||||
/// so the OS trust store performs validation.
|
|
||||||
/// </summary>
|
|
||||||
[Fact]
|
|
||||||
public void Handler_KeepsDefaultVerification_WhenRequireCertificateValidation()
|
|
||||||
{
|
|
||||||
MxGatewayClientOptions options = new()
|
|
||||||
{
|
|
||||||
Endpoint = new Uri("https://localhost:5120"),
|
|
||||||
ApiKey = "k",
|
|
||||||
UseTls = true,
|
|
||||||
RequireCertificateValidation = true,
|
|
||||||
};
|
|
||||||
using SocketsHttpHandler handler = MxGatewayClient.CreateHttpHandlerForTests(options);
|
|
||||||
Assert.Null(handler.SslOptions.RemoteCertificateValidationCallback);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class GalaxyRepositoryClientTlsHandlerTests
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Verifies that when TLS is used with no pinned CA and RequireCertificateValidation is false (default),
|
|
||||||
/// the Galaxy client handler installs an accept-all callback so the gateway's self-signed cert is trusted.
|
|
||||||
/// The callback must return true regardless of chain errors.
|
|
||||||
/// </summary>
|
|
||||||
[Fact]
|
|
||||||
public void Handler_SkipsVerification_WhenTlsAndNoCaPinned()
|
|
||||||
{
|
|
||||||
MxGatewayClientOptions options = new()
|
|
||||||
{
|
|
||||||
Endpoint = new Uri("https://localhost:5120"),
|
|
||||||
ApiKey = "k",
|
|
||||||
UseTls = true,
|
|
||||||
};
|
|
||||||
using SocketsHttpHandler handler = GalaxyRepositoryClient.CreateHttpHandlerForTests(options);
|
|
||||||
Assert.NotNull(handler.SslOptions.RemoteCertificateValidationCallback);
|
|
||||||
Assert.True(handler.SslOptions.RemoteCertificateValidationCallback!(null!, null!, null, SslPolicyErrors.RemoteCertificateChainErrors));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Verifies that when RequireCertificateValidation is true, the Galaxy client callback is left null
|
|
||||||
/// so the OS trust store performs validation.
|
|
||||||
/// </summary>
|
|
||||||
[Fact]
|
|
||||||
public void Handler_KeepsDefaultVerification_WhenRequireCertificateValidation()
|
|
||||||
{
|
|
||||||
MxGatewayClientOptions options = new()
|
|
||||||
{
|
|
||||||
Endpoint = new Uri("https://localhost:5120"),
|
|
||||||
ApiKey = "k",
|
|
||||||
UseTls = true,
|
|
||||||
RequireCertificateValidation = true,
|
|
||||||
};
|
|
||||||
using SocketsHttpHandler handler = GalaxyRepositoryClient.CreateHttpHandlerForTests(options);
|
|
||||||
Assert.Null(handler.SslOptions.RemoteCertificateValidationCallback);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<Solution>
|
|
||||||
<Configurations>
|
|
||||||
<Platform Name="Any CPU" />
|
|
||||||
<Platform Name="x64" />
|
|
||||||
<Platform Name="x86" />
|
|
||||||
</Configurations>
|
|
||||||
<Project Path="../../src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj" />
|
|
||||||
<Project Path="ZB.MOM.WW.MxGateway.Client.Cli/ZB.MOM.WW.MxGateway.Client.Cli.csproj" />
|
|
||||||
<Project Path="ZB.MOM.WW.MxGateway.Client.Tests/ZB.MOM.WW.MxGateway.Client.Tests.csproj" />
|
|
||||||
<Project Path="ZB.MOM.WW.MxGateway.Client/ZB.MOM.WW.MxGateway.Client.csproj" />
|
|
||||||
</Solution>
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.MxGateway.Client;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Filters and shape options for <see cref="GalaxyRepositoryClient.BrowseAsync(BrowseChildrenOptions, System.Threading.CancellationToken)"/>.
|
|
||||||
/// Mirror of <see cref="DiscoverHierarchyOptions"/> for the lazy-browse path.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class BrowseChildrenOptions
|
|
||||||
{
|
|
||||||
/// <summary>Restrict to children whose Galaxy category is in this set.</summary>
|
|
||||||
public IReadOnlyList<int> CategoryIds { get; init; } = [];
|
|
||||||
|
|
||||||
/// <summary>Restrict to children whose template chain contains any of these tokens.</summary>
|
|
||||||
public IReadOnlyList<string> TemplateChainContains { get; init; } = [];
|
|
||||||
|
|
||||||
/// <summary>Optional glob-style filter on <c>tag_name</c>.</summary>
|
|
||||||
public string? TagNameGlob { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Whether to populate each <c>GalaxyObject.Attributes</c>. Null leaves the server default.</summary>
|
|
||||||
public bool? IncludeAttributes { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Restrict to children that bear at least one alarm attribute.</summary>
|
|
||||||
public bool AlarmBearingOnly { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Restrict to children that have at least one historized attribute.</summary>
|
|
||||||
public bool HistorizedOnly { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.MxGateway.Client;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Filters and shape options for <see cref="GalaxyRepositoryClient.DiscoverHierarchyAsync(DiscoverHierarchyOptions, System.Threading.CancellationToken)"/>.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Hand-written ergonomic wrapper around the generated
|
|
||||||
/// <c>DiscoverHierarchyRequest</c>: lets callers express a Galaxy-browse
|
|
||||||
/// slice with .NET-friendly nullable scalars and collection initializers,
|
|
||||||
/// without touching the protobuf message's <c>oneof root</c> directly.
|
|
||||||
/// </remarks>
|
|
||||||
public sealed class DiscoverHierarchyOptions
|
|
||||||
{
|
|
||||||
/// <summary>Restrict to the subtree rooted at this Galaxy <c>gobject_id</c>.</summary>
|
|
||||||
public int? RootGobjectId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Restrict to the subtree rooted at the object with this tag name.</summary>
|
|
||||||
public string? RootTagName { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Restrict to the subtree rooted at this <c>contained_name</c> path.</summary>
|
|
||||||
public string? RootContainedPath { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Maximum traversal depth, measured from the chosen root.</summary>
|
|
||||||
public int? MaxDepth { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Restrict to objects whose Galaxy category is in this set.</summary>
|
|
||||||
public IReadOnlyList<int> CategoryIds { get; init; } = [];
|
|
||||||
|
|
||||||
/// <summary>Restrict to objects whose template chain contains any of these tokens.</summary>
|
|
||||||
public IReadOnlyList<string> TemplateChainContains { get; init; } = [];
|
|
||||||
|
|
||||||
/// <summary>Optional glob-style filter on <c>tag_name</c>.</summary>
|
|
||||||
public string? TagNameGlob { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Whether to populate each <c>GalaxyObject.Attributes</c>. Null leaves the server default.</summary>
|
|
||||||
public bool? IncludeAttributes { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Restrict to objects that bear at least one alarm attribute.</summary>
|
|
||||||
public bool AlarmBearingOnly { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Restrict to objects that have at least one historized attribute.</summary>
|
|
||||||
public bool HistorizedOnly { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Client;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// One node in a lazy-loaded Galaxy browse tree. Holds the underlying
|
|
||||||
/// <see cref="GalaxyObject"/> and exposes <see cref="ExpandAsync"/> to fetch
|
|
||||||
/// its direct children on demand. Expansion is one-shot: a second call is a
|
|
||||||
/// no-op. Pagination of large sibling sets is handled internally.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class LazyBrowseNode
|
|
||||||
{
|
|
||||||
private readonly GalaxyRepositoryClient _client;
|
|
||||||
private readonly BrowseChildrenOptions _options;
|
|
||||||
private readonly List<LazyBrowseNode> _children = [];
|
|
||||||
private readonly SemaphoreSlim _expandLock = new(1, 1);
|
|
||||||
private bool _isExpanded;
|
|
||||||
|
|
||||||
/// <summary>Initializes a new instance of <see cref="LazyBrowseNode"/>.</summary>
|
|
||||||
/// <param name="client">The repository client used to fetch children.</param>
|
|
||||||
/// <param name="object">The underlying Galaxy object for this node.</param>
|
|
||||||
/// <param name="hasChildrenHint">True when the server reports the node has at least one matching descendant.</param>
|
|
||||||
/// <param name="options">Options controlling child browse behavior.</param>
|
|
||||||
internal LazyBrowseNode(
|
|
||||||
GalaxyRepositoryClient client,
|
|
||||||
GalaxyObject @object,
|
|
||||||
bool hasChildrenHint,
|
|
||||||
BrowseChildrenOptions options)
|
|
||||||
{
|
|
||||||
_client = client;
|
|
||||||
Object = @object;
|
|
||||||
HasChildrenHint = hasChildrenHint;
|
|
||||||
_options = options;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>The underlying Galaxy object for this node.</summary>
|
|
||||||
public GalaxyObject Object { get; }
|
|
||||||
|
|
||||||
/// <summary>True when the server reports this node has at least one matching descendant.</summary>
|
|
||||||
public bool HasChildrenHint { get; }
|
|
||||||
|
|
||||||
/// <summary>Direct children loaded by <see cref="ExpandAsync"/>; empty until then.</summary>
|
|
||||||
public IReadOnlyList<LazyBrowseNode> Children => _children;
|
|
||||||
|
|
||||||
/// <summary>True after the first <see cref="ExpandAsync"/> call completes.</summary>
|
|
||||||
public bool IsExpanded => _isExpanded;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Fetches direct children from the gateway and populates <see cref="Children"/>.
|
|
||||||
/// Idempotent: subsequent calls are no-ops.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Thread-safe: concurrent callers see exactly one fetch; subsequent callers
|
|
||||||
/// (after the first completes) return immediately.
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
public async Task ExpandAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (_isExpanded)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await _expandLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (_isExpanded)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
string pageToken = string.Empty;
|
|
||||||
HashSet<string> seenPageTokens = new(StringComparer.Ordinal);
|
|
||||||
do
|
|
||||||
{
|
|
||||||
BrowseChildrenRequest request = GalaxyRepositoryClient.BuildBrowseChildrenRequest(_options);
|
|
||||||
request.ParentGobjectId = Object.GobjectId;
|
|
||||||
request.PageToken = pageToken;
|
|
||||||
|
|
||||||
BrowseChildrenReply reply = await _client
|
|
||||||
.BrowseChildrenRawAsync(request, cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
for (int i = 0; i < reply.Children.Count; i++)
|
|
||||||
{
|
|
||||||
bool hint = i < reply.ChildHasChildren.Count && reply.ChildHasChildren[i];
|
|
||||||
_children.Add(new LazyBrowseNode(_client, reply.Children[i], hint, _options));
|
|
||||||
}
|
|
||||||
|
|
||||||
pageToken = reply.NextPageToken;
|
|
||||||
if (!string.IsNullOrWhiteSpace(pageToken) && !seenPageTokens.Add(pageToken))
|
|
||||||
{
|
|
||||||
throw new MxGatewayException(
|
|
||||||
$"Galaxy BrowseChildren returned a repeated page token '{pageToken}'.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
while (!string.IsNullOrWhiteSpace(pageToken));
|
|
||||||
|
|
||||||
_isExpanded = true;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_expandLock.Release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
using ZB.MOM.WW.MxGateway.Contracts;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Client;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Exposes the protocol versions compiled into this client package.
|
|
||||||
/// </summary>
|
|
||||||
public static class MxGatewayClientContractInfo
|
|
||||||
{
|
|
||||||
/// <inheritdoc cref="GatewayContractInfo.GatewayProtocolVersion"/>
|
|
||||||
public const uint GatewayProtocolVersion =
|
|
||||||
GatewayContractInfo.GatewayProtocolVersion;
|
|
||||||
|
|
||||||
/// <inheritdoc cref="GatewayContractInfo.WorkerProtocolVersion"/>
|
|
||||||
public const uint WorkerProtocolVersion =
|
|
||||||
GatewayContractInfo.WorkerProtocolVersion;
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
using System.Runtime.CompilerServices;
|
|
||||||
|
|
||||||
[assembly: InternalsVisibleTo("ZB.MOM.WW.MxGateway.Client.Tests")]
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\..\..\src\ZB.MOM.WW.MxGateway.Contracts\ZB.MOM.WW.MxGateway.Contracts.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Grpc.Net.Client" Version="2.76.0" />
|
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
|
|
||||||
<PackageReference Include="Polly.Core" Version="8.6.6" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<IsPackable>true</IsPackable>
|
|
||||||
<PackageId>ZB.MOM.WW.MxGateway.Client</PackageId>
|
|
||||||
<Description>.NET 10 gRPC client for the MxAccessGateway service. Provides typed wrappers, retry, and a lazy-browse walker over the Galaxy Repository hierarchy.</Description>
|
|
||||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<None Include="..\README.md" Pack="true" PackagePath="\" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
|
|
||||||
<_Parameter1>ZB.MOM.WW.MxGateway.Client.Tests</_Parameter1>
|
|
||||||
</AssemblyAttribute>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -104,23 +104,6 @@ Support:
|
|||||||
- `credentials.NewClientTLSFromFile`,
|
- `credentials.NewClientTLSFromFile`,
|
||||||
- custom `tls.Config` for advanced callers.
|
- custom `tls.Config` for advanced callers.
|
||||||
|
|
||||||
### Trust posture
|
|
||||||
|
|
||||||
The gateway can serve a self-signed certificate it generates itself (it has no
|
|
||||||
PKI). To make that usable, TLS is **lenient by default**: when `Plaintext` is
|
|
||||||
`false` and no `CACertFile`/`TLSConfig`/`TransportCredentials` is supplied,
|
|
||||||
`buildCredentials` dials with `tls.Config{InsecureSkipVerify: true}` (carrying
|
|
||||||
`ServerNameOverride` as the SNI when set), so the gateway's self-signed
|
|
||||||
certificate is accepted without verification.
|
|
||||||
|
|
||||||
To verify the gateway instead:
|
|
||||||
|
|
||||||
- set `CACertFile` to pin a CA (full verification against that root), or
|
|
||||||
- set `RequireCertificateValidation: true` to verify against the OS/system trust
|
|
||||||
roots without pinning.
|
|
||||||
|
|
||||||
Pinning a CA always wins over the lenient default.
|
|
||||||
|
|
||||||
## Streaming
|
## Streaming
|
||||||
|
|
||||||
`Events(ctx)` should return a receive channel of:
|
`Events(ctx)` should return a receive channel of:
|
||||||
|
|||||||
+31
-111
@@ -75,29 +75,42 @@ client, err := mxgateway.Dial(ctx, mxgateway.Options{
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
The gateway can auto-generate its own self-signed certificate (it has no PKI), so
|
|
||||||
the client is **lenient by default**: a TLS connection (`Plaintext: false`) with
|
|
||||||
no `CACertFile`/`TLSConfig` accepts whatever certificate the gateway presents
|
|
||||||
(`InsecureSkipVerify`, with `ServerNameOverride` as the SNI when set). To verify
|
|
||||||
instead, set `CACertFile` to pin a CA, or set `RequireCertificateValidation:
|
|
||||||
true` to verify against the OS/system trust roots without pinning. See
|
|
||||||
[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate).
|
|
||||||
|
|
||||||
`Client.OpenSession` returns a `Session` with helpers for `Register`,
|
`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
|
`SubscribeEvents` or `SubscribeEventsAfter` for long-running streams because the
|
||||||
returned subscription owns cancellation and exposes `Close` for deterministic
|
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
|
`mxgateway` package aliases and the `Raw` helper methods. Typed errors support
|
||||||
`errors.As` for `GatewayError`, `CommandError`, and `MxAccessError`; command
|
`errors.As` for `GatewayError`, `CommandError`, and `MxAccessError`; command
|
||||||
errors preserve the raw reply.
|
errors preserve the raw reply.
|
||||||
|
|
||||||
For alarms, the package exposes `Client.QueryActiveAlarms` for one-shot
|
`Dial` and `DialGalaxy` create the connection lazily (`grpc.NewClient`): a
|
||||||
snapshots, `Client.StreamAlarms` for the server-streaming feed, and
|
gateway that is briefly unavailable no longer turns into a hard error — the
|
||||||
`Client.AcknowledgeAlarm` to ack an alarm by full reference. The streaming
|
connection recovers once the gateway comes up. To keep fail-fast behavior,
|
||||||
call returns a `StreamAlarmsClient`; cancel its context to terminate the
|
both run a readiness probe bounded by `DialTimeout` (default 10s, or the
|
||||||
stream. All three pass straight through to the gateway's central alarm
|
context deadline when sooner) and return a `*GatewayError` if the gateway
|
||||||
monitor.
|
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
|
## Galaxy Repository browse
|
||||||
|
|
||||||
@@ -129,68 +142,6 @@ reports `present=false` (no deploy recorded). `DiscoverHierarchy` returns
|
|||||||
the generated `*GalaxyObject` slice with each object's dynamic attributes
|
the generated `*GalaxyObject` slice with each object's dynamic attributes
|
||||||
populated for direct contract access.
|
populated for direct contract access.
|
||||||
|
|
||||||
### Browsing lazily
|
|
||||||
|
|
||||||
For UI trees or OPC UA bridges, use `BrowseChildren` to walk one level at a
|
|
||||||
time instead of loading the full hierarchy. Pass an empty request for root
|
|
||||||
objects; subsequent calls set `ParentGobjectId`, `ParentTagName`, or
|
|
||||||
`ParentContainedPath`. Filter fields match `DiscoverHierarchy`. Each response
|
|
||||||
pairs `Children` with `ChildHasChildren` so you know which nodes to expand. See
|
|
||||||
[Galaxy Repository](../../docs/GalaxyRepository.md#browsechildren) for full
|
|
||||||
request and filter semantics.
|
|
||||||
|
|
||||||
```go
|
|
||||||
import pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated/galaxy_repository/v1"
|
|
||||||
|
|
||||||
reply, err := galaxy.BrowseChildren(ctx, &pb.BrowseChildrenRequest{})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for i, child := range reply.GetChildren() {
|
|
||||||
fmt.Printf("%s expand=%v\n", child.GetTagName(), reply.GetChildHasChildren()[i])
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### High-level walker
|
|
||||||
|
|
||||||
For UI trees, the client provides a `LazyBrowseNode` walker that handles
|
|
||||||
sibling pagination and the `child_has_children` hint for you:
|
|
||||||
|
|
||||||
```go
|
|
||||||
galaxy, err := mxgateway.DialGalaxy(ctx, mxgateway.Options{
|
|
||||||
Endpoint: "localhost:5000",
|
|
||||||
APIKey: os.Getenv("MXGATEWAY_API_KEY"),
|
|
||||||
Plaintext: true,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
defer galaxy.Close()
|
|
||||||
|
|
||||||
roots, err := galaxy.Browse(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, root := range roots {
|
|
||||||
if root.HasChildrenHint() {
|
|
||||||
if err := root.Expand(ctx); err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, child := range root.Children() {
|
|
||||||
kind := "leaf"
|
|
||||||
if child.HasChildrenHint() {
|
|
||||||
kind = "has children"
|
|
||||||
}
|
|
||||||
fmt.Printf("%s (%s)\n", child.Object().GetTagName(), kind)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`Expand` is idempotent — calling it twice fires only one RPC,
|
|
||||||
and is safe under concurrent callers. To refresh after a Galaxy redeploy, call
|
|
||||||
`Browse` again from the root.
|
|
||||||
|
|
||||||
### Watching deploy events
|
### Watching deploy events
|
||||||
|
|
||||||
`WatchDeployEvents` opens a server-streaming subscription. The server emits a
|
`WatchDeployEvents` opens a server-streaming subscription. The server emits a
|
||||||
@@ -235,7 +186,8 @@ The CLI exposes the same RPC via `galaxy-watch`:
|
|||||||
```powershell
|
```powershell
|
||||||
go run ./cmd/mxgw-go galaxy-watch -plaintext
|
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 -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
|
go run ./cmd/mxgw-go galaxy-watch -plaintext -limit 5
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -283,38 +235,6 @@ $env:MXGATEWAY_TEST_ITEM = 'Area001.Tag.Value'
|
|||||||
go run ./cmd/mxgw-go smoke -endpoint $env:MXGATEWAY_ENDPOINT -plaintext -api-key-env MXGATEWAY_API_KEY -item $env:MXGATEWAY_TEST_ITEM -json
|
go run ./cmd/mxgw-go smoke -endpoint $env:MXGATEWAY_ENDPOINT -plaintext -api-key-env MXGATEWAY_API_KEY -item $env:MXGATEWAY_TEST_ITEM -json
|
||||||
```
|
```
|
||||||
|
|
||||||
## Installing the Go client
|
|
||||||
|
|
||||||
The module is resolved directly from the git repo — no package registry:
|
|
||||||
|
|
||||||
````bash
|
|
||||||
go get gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go@v0.1.0
|
|
||||||
````
|
|
||||||
|
|
||||||
Then import:
|
|
||||||
|
|
||||||
````go
|
|
||||||
import "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/mxgateway"
|
|
||||||
````
|
|
||||||
|
|
||||||
If your build environment cannot reach `gitea.dohertylan.com` directly,
|
|
||||||
configure `GOPROXY` to point at an internal proxy that fronts the Gitea
|
|
||||||
repo, or use `GONOSUMCHECK` + `GOPRIVATE` to bypass the checksum database
|
|
||||||
for the internal module path.
|
|
||||||
|
|
||||||
## Releasing a new version
|
|
||||||
|
|
||||||
Go modules in monorepo subdirectories use prefixed tags. To tag a release
|
|
||||||
from this repo:
|
|
||||||
|
|
||||||
````bash
|
|
||||||
pwsh scripts/tag-go-module.ps1 -Version v0.1.1 -Push
|
|
||||||
````
|
|
||||||
|
|
||||||
The script validates semver, refuses to tag with uncommitted tracked
|
|
||||||
changes, creates an annotated tag `clients/go/v0.1.1`, and (with `-Push`)
|
|
||||||
pushes it to origin.
|
|
||||||
|
|
||||||
## Related Documentation
|
## Related Documentation
|
||||||
|
|
||||||
- [Client Packaging](../../docs/ClientPackaging.md)
|
- [Client Packaging](../../docs/ClientPackaging.md)
|
||||||
|
|||||||
@@ -351,17 +351,17 @@ func runUnsubscribeBulk(ctx context.Context, args []string, stdout, stderr io.Wr
|
|||||||
return errors.New("session-id and item-handles are required")
|
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)
|
client, options, err := dialForCommand(ctx, common)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer client.Close()
|
defer client.Close()
|
||||||
|
|
||||||
handles, err := parseInt32List(*itemHandles)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
session := mxgateway.NewSessionForID(client, *sessionID)
|
session := mxgateway.NewSessionForID(client, *sessionID)
|
||||||
results, err := session.UnsubscribeBulk(ctx, int32(*serverHandle), handles)
|
results, err := session.UnsubscribeBulk(ctx, int32(*serverHandle), handles)
|
||||||
return writeBulkOutput(stdout, *jsonOutput, "unsubscribe-bulk", options, results, err)
|
return writeBulkOutput(stdout, *jsonOutput, "unsubscribe-bulk", options, results, err)
|
||||||
@@ -412,15 +412,14 @@ func runWriteSecured2Bulk(ctx context.Context, args []string, stdout, stderr io.
|
|||||||
}
|
}
|
||||||
|
|
||||||
// runWriteBulkVariant shares the flag-parsing + entry-build skeleton across
|
// runWriteBulkVariant shares the flag-parsing + entry-build skeleton across
|
||||||
// the four bulk-write families. The variant is derived from command alone;
|
// the four bulk-write families. command selects which of the four routes
|
||||||
// withTimestamp adds a --timestamp-value flag. To keep wrong-variant flags
|
// runs; withTimestamp adds a --timestamp-value flag for the Write2 / Secured2
|
||||||
// from silently no-op'ing, secured-only flags (-current-user-id /
|
// variants. Secured-only flags (--current-user-id / --verifier-user-id) are
|
||||||
// -verifier-user-id) are only registered for the secured variants, and
|
// only registered for the secured variants and the non-secured -user-id flag
|
||||||
// -user-id only for the non-secured Write/Write2 variants — a wrong-variant
|
// is only registered for Write/Write2, so a wrong-variant flag becomes a
|
||||||
// flag then surfaces as a clean "flag provided but not defined" error.
|
// 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 {
|
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"
|
secured := command == "write-secured-bulk" || command == "write-secured2-bulk"
|
||||||
|
|
||||||
flags := flag.NewFlagSet(command, flag.ContinueOnError)
|
flags := flag.NewFlagSet(command, flag.ContinueOnError)
|
||||||
flags.SetOutput(stderr)
|
flags.SetOutput(stderr)
|
||||||
common := bindCommonFlags(flags)
|
common := bindCommonFlags(flags)
|
||||||
@@ -430,11 +429,7 @@ func runWriteBulkVariant(ctx context.Context, args []string, stdout, stderr io.W
|
|||||||
itemHandles := flags.String("item-handles", "", "comma-separated item handles")
|
itemHandles := flags.String("item-handles", "", "comma-separated item handles")
|
||||||
valueType := flags.String("type", "string", "value type: bool, int32, int64, float, double, string")
|
valueType := flags.String("type", "string", "value type: bool, int32, int64, float, double, string")
|
||||||
values := flags.String("values", "", "comma-separated values (one per item handle)")
|
values := flags.String("values", "", "comma-separated values (one per item handle)")
|
||||||
var (
|
var userID, currentUserID, verifierUserID *int
|
||||||
userID *int
|
|
||||||
currentUserID *int
|
|
||||||
verifierUserID *int
|
|
||||||
)
|
|
||||||
if secured {
|
if secured {
|
||||||
currentUserID = flags.Int("current-user-id", 0, "MXAccess current user id (Secured variants)")
|
currentUserID = flags.Int("current-user-id", 0, "MXAccess current user id (Secured variants)")
|
||||||
verifierUserID = flags.Int("verifier-user-id", 0, "MXAccess verifier user id (Secured variants)")
|
verifierUserID = flags.Int("verifier-user-id", 0, "MXAccess verifier user id (Secured variants)")
|
||||||
@@ -531,16 +526,6 @@ func runWriteBulkVariant(ctx context.Context, args []string, stdout, stderr io.W
|
|||||||
return writeWriteBulkOutput(stdout, *jsonOutput, command, options, results, err)
|
return writeWriteBulkOutput(stdout, *jsonOutput, command, options, results, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseRfc3339Timestamp parses an RFC 3339 timestamp and returns the
|
|
||||||
// MxValue protobuf representation used for the timestamped write families.
|
|
||||||
func parseRfc3339Timestamp(text string) (*mxgateway.MxValue, error) {
|
|
||||||
t, err := time.Parse(time.RFC3339Nano, text)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid RFC 3339 timestamp %q: %w", text, err)
|
|
||||||
}
|
|
||||||
return mxgateway.TimestampValue(t), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// runBenchReadBulk drives the cross-language ReadBulk stress benchmark from Go:
|
// runBenchReadBulk drives the cross-language ReadBulk stress benchmark from Go:
|
||||||
// opens its own session, subscribes to bulk-size tags so the worker value cache
|
// 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
|
// populates from real OnDataChange events, runs ReadBulk in a tight loop for
|
||||||
@@ -611,16 +596,19 @@ func runBenchReadBulk(ctx context.Context, args []string, stdout, stderr io.Writ
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
// Warm-up: drive identical calls so any first-call JIT / connection-pool
|
// Warm-up: drive identical calls so any first-call JIT / connection-pool
|
||||||
// setup is amortised before the measurement window opens. The ctx.Err()
|
// setup is amortised before the measurement window opens. Honor ctx so
|
||||||
// guard short-circuits on Ctrl+C / parent-cancel instead of spinning
|
// Ctrl+C or a parent-cancel (e.g. the cross-language bench driver killing
|
||||||
// failing ReadBulk calls until the wall-clock deadline elapses.
|
// the child early) exits promptly rather than spinning failing calls until
|
||||||
|
// the wall-clock deadline.
|
||||||
warmupDeadline := time.Now().Add(time.Duration(*warmupSeconds) * time.Second)
|
warmupDeadline := time.Now().Add(time.Duration(*warmupSeconds) * time.Second)
|
||||||
timeout := time.Duration(*timeoutMs) * time.Millisecond
|
timeout := time.Duration(*timeoutMs) * time.Millisecond
|
||||||
for time.Now().Before(warmupDeadline) && ctx.Err() == nil {
|
for time.Now().Before(warmupDeadline) && ctx.Err() == nil {
|
||||||
_, _ = session.ReadBulk(ctx, serverHandle, tags, timeout)
|
_, _ = session.ReadBulk(ctx, serverHandle, tags, timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Steady state: per-call latency captured via time.Now() deltas.
|
// 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)
|
latenciesMs := make([]float64, 0, 65536)
|
||||||
var totalReadResults int64
|
var totalReadResults int64
|
||||||
var cachedReadResults int64
|
var cachedReadResults int64
|
||||||
@@ -688,7 +676,7 @@ func percentileSummary(sample []float64) map[string]float64 {
|
|||||||
sorted := append([]float64(nil), sample...)
|
sorted := append([]float64(nil), sample...)
|
||||||
sort.Float64s(sorted)
|
sort.Float64s(sorted)
|
||||||
mean := 0.0
|
mean := 0.0
|
||||||
maxValue := sorted[len(sorted)-1]
|
max := sorted[len(sorted)-1]
|
||||||
for _, v := range sample {
|
for _, v := range sample {
|
||||||
mean += v
|
mean += v
|
||||||
}
|
}
|
||||||
@@ -697,7 +685,7 @@ func percentileSummary(sample []float64) map[string]float64 {
|
|||||||
"p50": roundTo(percentile(sorted, 0.50), 3),
|
"p50": roundTo(percentile(sorted, 0.50), 3),
|
||||||
"p95": roundTo(percentile(sorted, 0.95), 3),
|
"p95": roundTo(percentile(sorted, 0.95), 3),
|
||||||
"p99": roundTo(percentile(sorted, 0.99), 3),
|
"p99": roundTo(percentile(sorted, 0.99), 3),
|
||||||
"max": roundTo(maxValue, 3),
|
"max": roundTo(max, 3),
|
||||||
"mean": roundTo(mean, 3),
|
"mean": roundTo(mean, 3),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -729,6 +717,16 @@ func roundTo(value float64, digits int) float64 {
|
|||||||
return float64(int64(value*shift+0.5)) / shift
|
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 {
|
func runWrite(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||||
flags := flag.NewFlagSet("write", flag.ContinueOnError)
|
flags := flag.NewFlagSet("write", flag.ContinueOnError)
|
||||||
flags.SetOutput(stderr)
|
flags.SetOutput(stderr)
|
||||||
@@ -786,8 +784,15 @@ func runStreamEvents(ctx context.Context, args []string, stdout, stderr io.Write
|
|||||||
}
|
}
|
||||||
defer client.Close()
|
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)
|
session := mxgateway.NewSessionForID(client, *sessionID)
|
||||||
streamCtx, cancelStream := context.WithCancel(ctx)
|
streamCtx, cancelStream := context.WithCancel(signalCtx)
|
||||||
defer cancelStream()
|
defer cancelStream()
|
||||||
subscription, err := session.SubscribeEventsAfter(streamCtx, *after)
|
subscription, err := session.SubscribeEventsAfter(streamCtx, *after)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1083,31 +1088,31 @@ func parseValue(valueType, valueText string) (*mxgateway.MxValue, error) {
|
|||||||
case "bool":
|
case "bool":
|
||||||
value, err := strconv.ParseBool(valueText)
|
value, err := strconv.ParseBool(valueText)
|
||||||
if err != nil {
|
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
|
return mxgateway.BoolValue(value), nil
|
||||||
case "int32":
|
case "int32":
|
||||||
value, err := strconv.ParseInt(valueText, 10, 32)
|
value, err := strconv.ParseInt(valueText, 10, 32)
|
||||||
if err != nil {
|
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
|
return mxgateway.Int32Value(int32(value)), nil
|
||||||
case "int64":
|
case "int64":
|
||||||
value, err := strconv.ParseInt(valueText, 10, 64)
|
value, err := strconv.ParseInt(valueText, 10, 64)
|
||||||
if err != nil {
|
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
|
return mxgateway.Int64Value(value), nil
|
||||||
case "float":
|
case "float":
|
||||||
value, err := strconv.ParseFloat(valueText, 32)
|
value, err := strconv.ParseFloat(valueText, 32)
|
||||||
if err != nil {
|
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
|
return mxgateway.FloatValue(float32(value)), nil
|
||||||
case "double":
|
case "double":
|
||||||
value, err := strconv.ParseFloat(valueText, 64)
|
value, err := strconv.ParseFloat(valueText, 64)
|
||||||
if err != nil {
|
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
|
return mxgateway.DoubleValue(value), nil
|
||||||
case "string":
|
case "string":
|
||||||
@@ -1195,10 +1200,6 @@ type protojsonMessage interface {
|
|||||||
ProtoReflect() protoreflect.Message
|
ProtoReflect() protoreflect.Message
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeUsage(writer io.Writer) {
|
|
||||||
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>")
|
|
||||||
}
|
|
||||||
|
|
||||||
// batchEOR is the end-of-result sentinel emitted to stdout after every command
|
// batchEOR is the end-of-result sentinel emitted to stdout after every command
|
||||||
// in batch mode, regardless of success or failure.
|
// in batch mode, regardless of success or failure.
|
||||||
const batchEOR = "__MXGW_BATCH_EOR__"
|
const batchEOR = "__MXGW_BATCH_EOR__"
|
||||||
@@ -1206,28 +1207,18 @@ const batchEOR = "__MXGW_BATCH_EOR__"
|
|||||||
// runBatch reads one command line at a time from in, dispatches each via the
|
// 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
|
// normal runWithIO routing, and writes a batchEOR sentinel to stdout after
|
||||||
// every result. Errors are serialised as JSON to stdout (not stderr) so the
|
// every result. Errors are serialised as JSON to stdout (not stderr) so the
|
||||||
// harness can parse them without interleaving stderr. Blank lines are
|
// harness can parse them without interleaving stderr. The loop never terminates
|
||||||
// skipped; only stdin EOF ends the session.
|
// on command error; only stdin EOF (or an empty line) ends the session.
|
||||||
//
|
|
||||||
// The scanner buffer is widened to 16 MiB so a single long command line
|
|
||||||
// (e.g. a bulk-write with several thousand handles) does not trip the
|
|
||||||
// default 64 KiB bufio.Scanner token-too-long error and abort the session.
|
|
||||||
// If a line still exceeds the cap, the error is surfaced as a per-command
|
|
||||||
// error-with-sentinel and the session continues.
|
|
||||||
func runBatch(ctx context.Context, in io.Reader, stdout, stderr io.Writer) error {
|
func runBatch(ctx context.Context, in io.Reader, stdout, stderr io.Writer) error {
|
||||||
bw := bufio.NewWriter(stdout)
|
bw := bufio.NewWriter(stdout)
|
||||||
scanner := bufio.NewScanner(in)
|
scanner := bufio.NewScanner(in)
|
||||||
scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
|
for scanner.Scan() {
|
||||||
for {
|
line := scanner.Text()
|
||||||
if !scanner.Scan() {
|
if line == "" {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
line := scanner.Text()
|
|
||||||
args := strings.Fields(line)
|
args := strings.Fields(line)
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
// Skip blank / whitespace-only lines; do NOT terminate. The
|
|
||||||
// session ends only on stdin EOF so a stray blank line in a
|
|
||||||
// PowerShell here-string does not silently drop later commands.
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := runWithIO(ctx, args, bw, stderr); err != nil {
|
if err := runWithIO(ctx, args, bw, stderr); err != nil {
|
||||||
@@ -1242,21 +1233,11 @@ func runBatch(ctx context.Context, in io.Reader, stdout, stderr io.Writer) error
|
|||||||
_, _ = fmt.Fprintln(bw, batchEOR)
|
_, _ = fmt.Fprintln(bw, batchEOR)
|
||||||
_ = bw.Flush()
|
_ = bw.Flush()
|
||||||
}
|
}
|
||||||
if err := scanner.Err(); err != nil {
|
return scanner.Err()
|
||||||
// Emit the scanner failure as a final error-with-sentinel so the
|
|
||||||
// harness sees the failure framed, then return the error so the
|
|
||||||
// process exit reflects it. This handles bufio.ErrTooLong for any
|
|
||||||
// pathological line above the 16 MiB cap.
|
|
||||||
errPayload := map[string]string{
|
|
||||||
"error": err.Error(),
|
|
||||||
"type": "error",
|
|
||||||
}
|
}
|
||||||
_ = writeJSON(bw, errPayload)
|
|
||||||
_, _ = fmt.Fprintln(bw, batchEOR)
|
func writeUsage(writer io.Writer) {
|
||||||
_ = bw.Flush()
|
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>")
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func dialGalaxyForCommand(ctx context.Context, common *commonOptions) (*mxgateway.GalaxyClient, commonOptions, error) {
|
func dialGalaxyForCommand(ctx context.Context, common *commonOptions) (*mxgateway.GalaxyClient, commonOptions, error) {
|
||||||
@@ -1388,7 +1369,7 @@ func runGalaxyWatch(ctx context.Context, args []string, stdout, stderr io.Writer
|
|||||||
flags.SetOutput(stderr)
|
flags.SetOutput(stderr)
|
||||||
common := bindCommonFlags(flags)
|
common := bindCommonFlags(flags)
|
||||||
jsonOutput := flags.Bool("json", false, "write JSON output")
|
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)")
|
limit := flags.Int("limit", 0, "maximum events to read; 0 means unbounded (Ctrl+C to stop)")
|
||||||
|
|
||||||
if err := flags.Parse(args); err != nil {
|
if err := flags.Parse(args); err != nil {
|
||||||
@@ -1397,7 +1378,11 @@ func runGalaxyWatch(ctx context.Context, args []string, stdout, stderr io.Writer
|
|||||||
|
|
||||||
var lastSeenPtr *time.Time
|
var lastSeenPtr *time.Time
|
||||||
if *lastSeen != "" {
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid -last-seen-deploy-time: %w", err)
|
return fmt.Errorf("invalid -last-seen-deploy-time: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1440,6 +1425,9 @@ func runGalaxyWatch(ctx context.Context, args []string, stdout, stderr io.Writer
|
|||||||
count++
|
count++
|
||||||
if *limit > 0 && count >= *limit {
|
if *limit > 0 && count >= *limit {
|
||||||
cancelStream()
|
cancelStream()
|
||||||
|
// Allow goroutine to drain.
|
||||||
|
for range events {
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
case streamErr, ok := <-errs:
|
case streamErr, ok := <-errs:
|
||||||
|
|||||||
+161
-206
@@ -2,15 +2,10 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
|
||||||
"google.golang.org/grpc"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRunVersionJSON(t *testing.T) {
|
func TestRunVersionJSON(t *testing.T) {
|
||||||
@@ -53,34 +48,6 @@ func TestCommonOptionsRedactsAPIKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunBatchEmitsEORAfterVersion(t *testing.T) {
|
|
||||||
var stdout bytes.Buffer
|
|
||||||
var stderr bytes.Buffer
|
|
||||||
|
|
||||||
in := strings.NewReader("version --json\n")
|
|
||||||
if err := runBatch(t.Context(), in, &stdout, &stderr); err != nil {
|
|
||||||
t.Fatalf("runBatch() error = %v; stderr = %s", err, stderr.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
out := stdout.String()
|
|
||||||
if !strings.Contains(out, "\n"+batchEOR+"\n") && !strings.HasSuffix(out, batchEOR+"\n") {
|
|
||||||
t.Fatalf("expected EOR marker %q in stdout; got: %q", batchEOR, out)
|
|
||||||
}
|
|
||||||
|
|
||||||
idx := strings.Index(out, batchEOR)
|
|
||||||
if idx <= 0 {
|
|
||||||
t.Fatalf("EOR marker not found or appeared before any output: %q", out)
|
|
||||||
}
|
|
||||||
payload := out[:idx]
|
|
||||||
var output versionOutput
|
|
||||||
if err := json.Unmarshal([]byte(payload), &output); err != nil {
|
|
||||||
t.Fatalf("parse JSON block before EOR: %v (payload=%q)", err, payload)
|
|
||||||
}
|
|
||||||
if output.GatewayProtocolVersion == 0 || output.WorkerProtocolVersion == 0 {
|
|
||||||
t.Fatalf("protocol versions were not populated: %+v", output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseValueBuildsTypedValue(t *testing.T) {
|
func TestParseValueBuildsTypedValue(t *testing.T) {
|
||||||
value, err := parseValue("int32", "123")
|
value, err := parseValue("int32", "123")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -91,206 +58,194 @@ func TestParseValueBuildsTypedValue(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRunWriteBulkVariantGatesSecuredFlags pins the Client.Go-022 fix:
|
func TestParseInt32ListParsesValidTokens(t *testing.T) {
|
||||||
// secured-only flags must be unavailable on non-secured variants, and
|
items, err := parseInt32List("1, 2 ,3")
|
||||||
// vice-versa, so a wrong-variant flag fails with a clean "flag provided
|
if err != nil {
|
||||||
// but not defined" error instead of silently no-op'ing.
|
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) {
|
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 {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
args []string
|
args []string
|
||||||
}{
|
}{
|
||||||
{
|
{"no flags", []string{"read-bulk"}},
|
||||||
name: "write-bulk-rejects-current-user-id",
|
{"missing items", []string{"read-bulk", "-plaintext", "-session-id", "sess"}},
|
||||||
args: []string{"write-bulk", "-current-user-id", "5", "-item-handles", "1", "-values", "1"},
|
{"missing session-id", []string{"read-bulk", "-plaintext", "-items", "Tag.Attr"}},
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "write-bulk-rejects-verifier-user-id",
|
|
||||||
args: []string{"write-bulk", "-verifier-user-id", "5", "-item-handles", "1", "-values", "1"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "write2-bulk-rejects-current-user-id",
|
|
||||||
args: []string{"write2-bulk", "-current-user-id", "5", "-item-handles", "1", "-values", "1"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "write-secured-bulk-rejects-user-id",
|
|
||||||
args: []string{"write-secured-bulk", "-user-id", "5", "-item-handles", "1", "-values", "1"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "write-secured2-bulk-rejects-user-id",
|
|
||||||
args: []string{"write-secured2-bulk", "-user-id", "5", "-item-handles", "1", "-values", "1"},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
err := runWithIO(t.Context(), tc.args, &stdout, &stderr)
|
err := runWithIO(t.Context(), tc.args, &stdout, &stderr)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatalf("runWithIO(%v) returned no error", tc.args)
|
t.Fatalf("runWithIO(%v) error = nil, want validation error", tc.args)
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), "flag provided but not defined") {
|
if !strings.Contains(err.Error(), "session-id and items are required") {
|
||||||
t.Fatalf("runWithIO(%v) error = %v; want 'flag provided but not defined'", tc.args, err)
|
t.Fatalf("runWithIO(%v) error = %q, want 'session-id and items are required'", tc.args, err.Error())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRunBenchReadBulkRespectsContextCancellation pins the Client.Go-023
|
// TestRunBenchReadBulkRejectsNonPositiveBulkSize pins the bulk-size>=1 check
|
||||||
// fix: the warm-up and steady-state wall-clock loops must honour ctx.Err()
|
// at runBenchReadBulk's flag-parsing stage so a future refactor cannot drop
|
||||||
// so an external cancel (Ctrl+C, parent-cancel from a cross-language bench
|
// the positivity guard without breaking this test.
|
||||||
// driver) short-circuits the bench instead of spinning failing ReadBulk
|
|
||||||
// calls until the wall-clock deadline elapses.
|
|
||||||
func TestRunBenchReadBulkRespectsContextCancellation(t *testing.T) {
|
|
||||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("listen: %v", err)
|
|
||||||
}
|
|
||||||
server := grpc.NewServer()
|
|
||||||
fake := &benchFakeGateway{}
|
|
||||||
pb.RegisterMxAccessGatewayServer(server, fake)
|
|
||||||
go func() {
|
|
||||||
_ = server.Serve(listener)
|
|
||||||
}()
|
|
||||||
defer server.Stop()
|
|
||||||
defer listener.Close()
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// Long warm-up + duration, so if the ctx.Err() guard were missing the
|
|
||||||
// loops would run for ~10s. With the guard, the cancel below short-
|
|
||||||
// circuits both loops within ~one ReadBulk iteration.
|
|
||||||
args := []string{
|
|
||||||
"bench-read-bulk",
|
|
||||||
"-endpoint", listener.Addr().String(),
|
|
||||||
"-plaintext",
|
|
||||||
"-api-key", "test",
|
|
||||||
"-warmup-seconds", "5",
|
|
||||||
"-duration-seconds", "5",
|
|
||||||
"-bulk-size", "1",
|
|
||||||
"-timeout-ms", "100",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cancel after a brief delay — far less than warmup+duration (10s).
|
|
||||||
go func() {
|
|
||||||
time.Sleep(150 * time.Millisecond)
|
|
||||||
cancel()
|
|
||||||
}()
|
|
||||||
|
|
||||||
var stdout, stderr bytes.Buffer
|
|
||||||
start := time.Now()
|
|
||||||
err = runWithIO(ctx, args, &stdout, &stderr)
|
|
||||||
elapsed := time.Since(start)
|
|
||||||
|
|
||||||
// With the ctx.Err() guard, the loops exit well before the wall-clock
|
|
||||||
// deadlines (warmup=5s + duration=5s = 10s). Allow generous slack for
|
|
||||||
// CI noise but assert clearly less than the un-guarded worst case.
|
|
||||||
if elapsed > 4*time.Second {
|
|
||||||
t.Fatalf("bench-read-bulk took %s after ctx cancel; want <4s (ctx.Err() guard missing?). err=%v stderr=%s", elapsed, err, stderr.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// benchFakeGateway is a minimal MxAccessGatewayServer that satisfies the
|
|
||||||
// bench-read-bulk session-setup sequence (OpenSession + Invoke for Register
|
|
||||||
// / SubscribeBulk / ReadBulk / UnsubscribeBulk / CloseSession).
|
|
||||||
type benchFakeGateway struct {
|
|
||||||
pb.UnimplementedMxAccessGatewayServer
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g *benchFakeGateway) OpenSession(_ context.Context, _ *pb.OpenSessionRequest) (*pb.OpenSessionReply, error) {
|
|
||||||
return &pb.OpenSessionReply{
|
|
||||||
SessionId: "bench-session",
|
|
||||||
ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g *benchFakeGateway) CloseSession(_ context.Context, req *pb.CloseSessionRequest) (*pb.CloseSessionReply, error) {
|
|
||||||
return &pb.CloseSessionReply{
|
|
||||||
SessionId: req.GetSessionId(),
|
|
||||||
ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g *benchFakeGateway) Invoke(_ context.Context, req *pb.MxCommandRequest) (*pb.MxCommandReply, error) {
|
|
||||||
kind := req.GetCommand().GetKind()
|
|
||||||
reply := &pb.MxCommandReply{
|
|
||||||
SessionId: req.GetSessionId(),
|
|
||||||
Kind: kind,
|
|
||||||
ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK},
|
|
||||||
}
|
|
||||||
switch kind {
|
|
||||||
case pb.MxCommandKind_MX_COMMAND_KIND_REGISTER:
|
|
||||||
reply.Payload = &pb.MxCommandReply_Register{Register: &pb.RegisterReply{ServerHandle: 1}}
|
|
||||||
case pb.MxCommandKind_MX_COMMAND_KIND_SUBSCRIBE_BULK:
|
|
||||||
reply.Payload = &pb.MxCommandReply_SubscribeBulk{SubscribeBulk: &pb.BulkSubscribeReply{
|
|
||||||
Results: []*pb.SubscribeResult{{ServerHandle: 1, ItemHandle: 1, WasSuccessful: true}},
|
|
||||||
}}
|
|
||||||
case pb.MxCommandKind_MX_COMMAND_KIND_READ_BULK:
|
|
||||||
reply.Payload = &pb.MxCommandReply_ReadBulk{ReadBulk: &pb.BulkReadReply{
|
|
||||||
Results: []*pb.BulkReadResult{{ItemHandle: 1, WasSuccessful: true, WasCached: true}},
|
|
||||||
}}
|
|
||||||
case pb.MxCommandKind_MX_COMMAND_KIND_UNSUBSCRIBE_BULK:
|
|
||||||
reply.Payload = &pb.MxCommandReply_UnsubscribeBulk{UnsubscribeBulk: &pb.BulkSubscribeReply{}}
|
|
||||||
}
|
|
||||||
return reply, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRunBenchReadBulkRejectsNonPositiveBulkSize pins the Client.Go-023-adjacent
|
|
||||||
// positivity checks so they cannot drift while resolving the cancellation finding.
|
|
||||||
func TestRunBenchReadBulkRejectsNonPositiveBulkSize(t *testing.T) {
|
func TestRunBenchReadBulkRejectsNonPositiveBulkSize(t *testing.T) {
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
err := runWithIO(t.Context(), []string{"bench-read-bulk", "-bulk-size", "0"}, &stdout, &stderr)
|
err := runWithIO(t.Context(), []string{
|
||||||
if err == nil || !strings.Contains(err.Error(), "bulk-size must be positive") {
|
"bench-read-bulk",
|
||||||
t.Fatalf("bench-read-bulk -bulk-size 0 error = %v", err)
|
"-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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRunBatchSkipsBlankLinesAndContinuesUntilEOF pins the Client.Go-027 fix:
|
// TestRunBenchReadBulkRejectsNonPositiveDuration pins the duration-seconds>=1
|
||||||
// a blank line in the middle of a batch session must NOT terminate the loop —
|
// check at runBenchReadBulk's flag-parsing stage.
|
||||||
// only stdin EOF ends the session.
|
func TestRunBenchReadBulkRejectsNonPositiveDuration(t *testing.T) {
|
||||||
func TestRunBatchSkipsBlankLinesAndContinuesUntilEOF(t *testing.T) {
|
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
|
err := runWithIO(t.Context(), []string{
|
||||||
// version -> blank -> version (a stray blank line in the middle of a
|
"bench-read-bulk",
|
||||||
// programmatic session).
|
"-plaintext",
|
||||||
in := strings.NewReader("version --json\n\nversion --json\n")
|
"-duration-seconds", "0",
|
||||||
if err := runBatch(t.Context(), in, &stdout, &stderr); err != nil {
|
}, &stdout, &stderr)
|
||||||
t.Fatalf("runBatch() error = %v; stderr = %s", err, stderr.String())
|
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") {
|
||||||
out := stdout.String()
|
t.Fatalf("runWithIO error = %q, want 'duration-seconds must be positive'", err.Error())
|
||||||
// Both version commands must have produced a result before the EOR sentinel.
|
|
||||||
if count := strings.Count(out, batchEOR); count != 2 {
|
|
||||||
t.Fatalf("EOR sentinel count = %d, want 2 (one per command, blank line skipped); out = %q", count, out)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRunBatchHandlesLongCommandLine pins the Client.Go-026 fix: a command
|
// TestRunWriteBulkVariantRejectsMismatchedHandlesAndValues pins the explicit
|
||||||
// line longer than the default bufio.Scanner token size (64 KiB) must not
|
// "item-handles count ... does not match values count ..." check at the CLI
|
||||||
// abort the batch session.
|
// surface so the validation error surfaces before any dial happens.
|
||||||
func TestRunBatchHandlesLongCommandLine(t *testing.T) {
|
func TestRunWriteBulkVariantRejectsMismatchedHandlesAndValues(t *testing.T) {
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
|
err := runWithIO(t.Context(), []string{
|
||||||
// Build a single command line larger than 64 KiB. The command itself is
|
"write-bulk",
|
||||||
// invalid (no real session) but runBatch must still emit an EOR sentinel
|
"-plaintext",
|
||||||
// and continue to the next command rather than dropping the line on the
|
"-session-id", "sess",
|
||||||
// floor with a bufio.ErrTooLong from the outer return.
|
"-server-handle", "1",
|
||||||
huge := strings.Repeat("tag-with-a-reasonably-long-name-and-suffix,", 2000) + "trailing"
|
"-item-handles", "1,2,3",
|
||||||
line := "subscribe-bulk -session-id none -items " + huge
|
"-values", "10,20",
|
||||||
if len(line) <= 64*1024 {
|
}, &stdout, &stderr)
|
||||||
t.Fatalf("test setup error: long line length = %d, want > 64KiB", len(line))
|
if err == nil {
|
||||||
|
t.Fatalf("runWithIO(write-bulk mismatched counts) error = nil, want mismatch error")
|
||||||
}
|
}
|
||||||
in := strings.NewReader(line + "\nversion --json\n")
|
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())
|
||||||
if err := runBatch(t.Context(), in, &stdout, &stderr); err != nil {
|
|
||||||
t.Fatalf("runBatch() error = %v; stderr = %s", err, stderr.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
out := stdout.String()
|
|
||||||
// Both commands must produce an EOR sentinel — the long line should be a
|
|
||||||
// per-command error (still emitted with EOR), then the version command
|
|
||||||
// should run normally.
|
|
||||||
if count := strings.Count(out, batchEOR); count != 2 {
|
|
||||||
t.Fatalf("EOR sentinel count = %d, want 2 (one per command, even when first is too long); out length = %d", count, len(out))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ Set-StrictMode -Version Latest
|
|||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..')
|
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..')
|
||||||
$protoRoot = Join-Path $repoRoot 'src\ZB.MOM.WW.MxGateway.Contracts\Protos'
|
$protoRoot = Join-Path $repoRoot 'src\MxGateway.Contracts\Protos'
|
||||||
$outputRoot = Join-Path $PSScriptRoot 'internal\generated'
|
$outputRoot = Join-Path $PSScriptRoot 'internal\generated'
|
||||||
$modulePath = 'gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated'
|
$modulePath = 'gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated'
|
||||||
$protoc = 'C:\Users\dohertj2\AppData\Local\Microsoft\WinGet\Packages\Google.Protobuf_Microsoft.Winget.Source_8wekyb3d8bbwe\bin\protoc.exe'
|
$protoc = 'C:\Users\dohertj2\AppData\Local\Microsoft\WinGet\Packages\Google.Protobuf_Microsoft.Winget.Source_8wekyb3d8bbwe\bin\protoc.exe'
|
||||||
|
|||||||
@@ -824,260 +824,6 @@ func (x *GalaxyAttribute) GetIsAlarm() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
type BrowseChildrenRequest struct {
|
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
|
||||||
// Parent selector. Empty oneof returns root objects (parent_gobject_id == 0).
|
|
||||||
//
|
|
||||||
// Types that are valid to be assigned to Parent:
|
|
||||||
//
|
|
||||||
// *BrowseChildrenRequest_ParentGobjectId
|
|
||||||
// *BrowseChildrenRequest_ParentTagName
|
|
||||||
// *BrowseChildrenRequest_ParentContainedPath
|
|
||||||
Parent isBrowseChildrenRequest_Parent `protobuf_oneof:"parent"`
|
|
||||||
// Maximum number of direct children to return. Server default 500; cap 5000.
|
|
||||||
PageSize int32 `protobuf:"varint,4,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
|
|
||||||
// Opaque token returned by a previous BrowseChildren response. Bound to the
|
|
||||||
// cache sequence, parent selector, and the filter set; a mismatch returns
|
|
||||||
// InvalidArgument.
|
|
||||||
PageToken string `protobuf:"bytes,5,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"`
|
|
||||||
// --- Filter parity with DiscoverHierarchy. AND-combined. ---
|
|
||||||
CategoryIds []int32 `protobuf:"varint,6,rep,packed,name=category_ids,json=categoryIds,proto3" json:"category_ids,omitempty"`
|
|
||||||
TemplateChainContains []string `protobuf:"bytes,7,rep,name=template_chain_contains,json=templateChainContains,proto3" json:"template_chain_contains,omitempty"`
|
|
||||||
TagNameGlob string `protobuf:"bytes,8,opt,name=tag_name_glob,json=tagNameGlob,proto3" json:"tag_name_glob,omitempty"`
|
|
||||||
IncludeAttributes *bool `protobuf:"varint,9,opt,name=include_attributes,json=includeAttributes,proto3,oneof" json:"include_attributes,omitempty"`
|
|
||||||
AlarmBearingOnly bool `protobuf:"varint,10,opt,name=alarm_bearing_only,json=alarmBearingOnly,proto3" json:"alarm_bearing_only,omitempty"`
|
|
||||||
HistorizedOnly bool `protobuf:"varint,11,opt,name=historized_only,json=historizedOnly,proto3" json:"historized_only,omitempty"`
|
|
||||||
unknownFields protoimpl.UnknownFields
|
|
||||||
sizeCache protoimpl.SizeCache
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *BrowseChildrenRequest) Reset() {
|
|
||||||
*x = BrowseChildrenRequest{}
|
|
||||||
mi := &file_galaxy_repository_proto_msgTypes[10]
|
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
|
||||||
ms.StoreMessageInfo(mi)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *BrowseChildrenRequest) String() string {
|
|
||||||
return protoimpl.X.MessageStringOf(x)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*BrowseChildrenRequest) ProtoMessage() {}
|
|
||||||
|
|
||||||
func (x *BrowseChildrenRequest) ProtoReflect() protoreflect.Message {
|
|
||||||
mi := &file_galaxy_repository_proto_msgTypes[10]
|
|
||||||
if x != nil {
|
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
|
||||||
if ms.LoadMessageInfo() == nil {
|
|
||||||
ms.StoreMessageInfo(mi)
|
|
||||||
}
|
|
||||||
return ms
|
|
||||||
}
|
|
||||||
return mi.MessageOf(x)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deprecated: Use BrowseChildrenRequest.ProtoReflect.Descriptor instead.
|
|
||||||
func (*BrowseChildrenRequest) Descriptor() ([]byte, []int) {
|
|
||||||
return file_galaxy_repository_proto_rawDescGZIP(), []int{10}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *BrowseChildrenRequest) GetParent() isBrowseChildrenRequest_Parent {
|
|
||||||
if x != nil {
|
|
||||||
return x.Parent
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *BrowseChildrenRequest) GetParentGobjectId() int32 {
|
|
||||||
if x != nil {
|
|
||||||
if x, ok := x.Parent.(*BrowseChildrenRequest_ParentGobjectId); ok {
|
|
||||||
return x.ParentGobjectId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *BrowseChildrenRequest) GetParentTagName() string {
|
|
||||||
if x != nil {
|
|
||||||
if x, ok := x.Parent.(*BrowseChildrenRequest_ParentTagName); ok {
|
|
||||||
return x.ParentTagName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *BrowseChildrenRequest) GetParentContainedPath() string {
|
|
||||||
if x != nil {
|
|
||||||
if x, ok := x.Parent.(*BrowseChildrenRequest_ParentContainedPath); ok {
|
|
||||||
return x.ParentContainedPath
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *BrowseChildrenRequest) GetPageSize() int32 {
|
|
||||||
if x != nil {
|
|
||||||
return x.PageSize
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *BrowseChildrenRequest) GetPageToken() string {
|
|
||||||
if x != nil {
|
|
||||||
return x.PageToken
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *BrowseChildrenRequest) GetCategoryIds() []int32 {
|
|
||||||
if x != nil {
|
|
||||||
return x.CategoryIds
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *BrowseChildrenRequest) GetTemplateChainContains() []string {
|
|
||||||
if x != nil {
|
|
||||||
return x.TemplateChainContains
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *BrowseChildrenRequest) GetTagNameGlob() string {
|
|
||||||
if x != nil {
|
|
||||||
return x.TagNameGlob
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *BrowseChildrenRequest) GetIncludeAttributes() bool {
|
|
||||||
if x != nil && x.IncludeAttributes != nil {
|
|
||||||
return *x.IncludeAttributes
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *BrowseChildrenRequest) GetAlarmBearingOnly() bool {
|
|
||||||
if x != nil {
|
|
||||||
return x.AlarmBearingOnly
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *BrowseChildrenRequest) GetHistorizedOnly() bool {
|
|
||||||
if x != nil {
|
|
||||||
return x.HistorizedOnly
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
type isBrowseChildrenRequest_Parent interface {
|
|
||||||
isBrowseChildrenRequest_Parent()
|
|
||||||
}
|
|
||||||
|
|
||||||
type BrowseChildrenRequest_ParentGobjectId struct {
|
|
||||||
ParentGobjectId int32 `protobuf:"varint,1,opt,name=parent_gobject_id,json=parentGobjectId,proto3,oneof"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type BrowseChildrenRequest_ParentTagName struct {
|
|
||||||
ParentTagName string `protobuf:"bytes,2,opt,name=parent_tag_name,json=parentTagName,proto3,oneof"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type BrowseChildrenRequest_ParentContainedPath struct {
|
|
||||||
ParentContainedPath string `protobuf:"bytes,3,opt,name=parent_contained_path,json=parentContainedPath,proto3,oneof"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*BrowseChildrenRequest_ParentGobjectId) isBrowseChildrenRequest_Parent() {}
|
|
||||||
|
|
||||||
func (*BrowseChildrenRequest_ParentTagName) isBrowseChildrenRequest_Parent() {}
|
|
||||||
|
|
||||||
func (*BrowseChildrenRequest_ParentContainedPath) isBrowseChildrenRequest_Parent() {}
|
|
||||||
|
|
||||||
type BrowseChildrenReply struct {
|
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
|
||||||
// Direct children matching the filter, sorted areas-first then by
|
|
||||||
// case-insensitive display name (same order as the dashboard tree).
|
|
||||||
Children []*GalaxyObject `protobuf:"bytes,1,rep,name=children,proto3" json:"children,omitempty"`
|
|
||||||
// Non-empty when another page of siblings is available.
|
|
||||||
NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"`
|
|
||||||
// Total matching direct children of the parent (post-filter).
|
|
||||||
TotalChildCount int32 `protobuf:"varint,3,opt,name=total_child_count,json=totalChildCount,proto3" json:"total_child_count,omitempty"`
|
|
||||||
// Parallel array, indexed with `children`. True when the child has at least
|
|
||||||
// one matching descendant under the same filter set. Lets a UI choose
|
|
||||||
// whether to draw an expand triangle without an extra round trip.
|
|
||||||
ChildHasChildren []bool `protobuf:"varint,4,rep,packed,name=child_has_children,json=childHasChildren,proto3" json:"child_has_children,omitempty"`
|
|
||||||
// Cache sequence this reply was projected from. Clients may pass it back as
|
|
||||||
// part of the page_token contract. Mismatch on the next page -> InvalidArgument.
|
|
||||||
CacheSequence uint64 `protobuf:"varint,5,opt,name=cache_sequence,json=cacheSequence,proto3" json:"cache_sequence,omitempty"`
|
|
||||||
unknownFields protoimpl.UnknownFields
|
|
||||||
sizeCache protoimpl.SizeCache
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *BrowseChildrenReply) Reset() {
|
|
||||||
*x = BrowseChildrenReply{}
|
|
||||||
mi := &file_galaxy_repository_proto_msgTypes[11]
|
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
|
||||||
ms.StoreMessageInfo(mi)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *BrowseChildrenReply) String() string {
|
|
||||||
return protoimpl.X.MessageStringOf(x)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*BrowseChildrenReply) ProtoMessage() {}
|
|
||||||
|
|
||||||
func (x *BrowseChildrenReply) ProtoReflect() protoreflect.Message {
|
|
||||||
mi := &file_galaxy_repository_proto_msgTypes[11]
|
|
||||||
if x != nil {
|
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
|
||||||
if ms.LoadMessageInfo() == nil {
|
|
||||||
ms.StoreMessageInfo(mi)
|
|
||||||
}
|
|
||||||
return ms
|
|
||||||
}
|
|
||||||
return mi.MessageOf(x)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deprecated: Use BrowseChildrenReply.ProtoReflect.Descriptor instead.
|
|
||||||
func (*BrowseChildrenReply) Descriptor() ([]byte, []int) {
|
|
||||||
return file_galaxy_repository_proto_rawDescGZIP(), []int{11}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *BrowseChildrenReply) GetChildren() []*GalaxyObject {
|
|
||||||
if x != nil {
|
|
||||||
return x.Children
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *BrowseChildrenReply) GetNextPageToken() string {
|
|
||||||
if x != nil {
|
|
||||||
return x.NextPageToken
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *BrowseChildrenReply) GetTotalChildCount() int32 {
|
|
||||||
if x != nil {
|
|
||||||
return x.TotalChildCount
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *BrowseChildrenReply) GetChildHasChildren() []bool {
|
|
||||||
if x != nil {
|
|
||||||
return x.ChildHasChildren
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *BrowseChildrenReply) GetCacheSequence() uint64 {
|
|
||||||
if x != nil {
|
|
||||||
return x.CacheSequence
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
var File_galaxy_repository_proto protoreflect.FileDescriptor
|
var File_galaxy_repository_proto protoreflect.FileDescriptor
|
||||||
|
|
||||||
const file_galaxy_repository_proto_rawDesc = "" +
|
const file_galaxy_repository_proto_rawDesc = "" +
|
||||||
@@ -1151,35 +897,12 @@ const file_galaxy_repository_proto_rawDesc = "" +
|
|||||||
"\x17security_classification\x18\t \x01(\x05R\x16securityClassification\x12#\n" +
|
"\x17security_classification\x18\t \x01(\x05R\x16securityClassification\x12#\n" +
|
||||||
"\ris_historized\x18\n" +
|
"\ris_historized\x18\n" +
|
||||||
" \x01(\bR\fisHistorized\x12\x19\n" +
|
" \x01(\bR\fisHistorized\x12\x19\n" +
|
||||||
"\bis_alarm\x18\v \x01(\bR\aisAlarm\"\x8c\x04\n" +
|
"\bis_alarm\x18\v \x01(\bR\aisAlarm2\xcc\x03\n" +
|
||||||
"\x15BrowseChildrenRequest\x12,\n" +
|
|
||||||
"\x11parent_gobject_id\x18\x01 \x01(\x05H\x00R\x0fparentGobjectId\x12(\n" +
|
|
||||||
"\x0fparent_tag_name\x18\x02 \x01(\tH\x00R\rparentTagName\x124\n" +
|
|
||||||
"\x15parent_contained_path\x18\x03 \x01(\tH\x00R\x13parentContainedPath\x12\x1b\n" +
|
|
||||||
"\tpage_size\x18\x04 \x01(\x05R\bpageSize\x12\x1d\n" +
|
|
||||||
"\n" +
|
|
||||||
"page_token\x18\x05 \x01(\tR\tpageToken\x12!\n" +
|
|
||||||
"\fcategory_ids\x18\x06 \x03(\x05R\vcategoryIds\x126\n" +
|
|
||||||
"\x17template_chain_contains\x18\a \x03(\tR\x15templateChainContains\x12\"\n" +
|
|
||||||
"\rtag_name_glob\x18\b \x01(\tR\vtagNameGlob\x122\n" +
|
|
||||||
"\x12include_attributes\x18\t \x01(\bH\x01R\x11includeAttributes\x88\x01\x01\x12,\n" +
|
|
||||||
"\x12alarm_bearing_only\x18\n" +
|
|
||||||
" \x01(\bR\x10alarmBearingOnly\x12'\n" +
|
|
||||||
"\x0fhistorized_only\x18\v \x01(\bR\x0ehistorizedOnlyB\b\n" +
|
|
||||||
"\x06parentB\x15\n" +
|
|
||||||
"\x13_include_attributes\"\xfe\x01\n" +
|
|
||||||
"\x13BrowseChildrenReply\x12>\n" +
|
|
||||||
"\bchildren\x18\x01 \x03(\v2\".galaxy_repository.v1.GalaxyObjectR\bchildren\x12&\n" +
|
|
||||||
"\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\x12*\n" +
|
|
||||||
"\x11total_child_count\x18\x03 \x01(\x05R\x0ftotalChildCount\x12,\n" +
|
|
||||||
"\x12child_has_children\x18\x04 \x03(\bR\x10childHasChildren\x12%\n" +
|
|
||||||
"\x0ecache_sequence\x18\x05 \x01(\x04R\rcacheSequence2\xb6\x04\n" +
|
|
||||||
"\x10GalaxyRepository\x12h\n" +
|
"\x10GalaxyRepository\x12h\n" +
|
||||||
"\x0eTestConnection\x12+.galaxy_repository.v1.TestConnectionRequest\x1a).galaxy_repository.v1.TestConnectionReply\x12q\n" +
|
"\x0eTestConnection\x12+.galaxy_repository.v1.TestConnectionRequest\x1a).galaxy_repository.v1.TestConnectionReply\x12q\n" +
|
||||||
"\x11GetLastDeployTime\x12..galaxy_repository.v1.GetLastDeployTimeRequest\x1a,.galaxy_repository.v1.GetLastDeployTimeReply\x12q\n" +
|
"\x11GetLastDeployTime\x12..galaxy_repository.v1.GetLastDeployTimeRequest\x1a,.galaxy_repository.v1.GetLastDeployTimeReply\x12q\n" +
|
||||||
"\x11DiscoverHierarchy\x12..galaxy_repository.v1.DiscoverHierarchyRequest\x1a,.galaxy_repository.v1.DiscoverHierarchyReply\x12h\n" +
|
"\x11DiscoverHierarchy\x12..galaxy_repository.v1.DiscoverHierarchyRequest\x1a,.galaxy_repository.v1.DiscoverHierarchyReply\x12h\n" +
|
||||||
"\x11WatchDeployEvents\x12..galaxy_repository.v1.WatchDeployEventsRequest\x1a!.galaxy_repository.v1.DeployEvent0\x01\x12h\n" +
|
"\x11WatchDeployEvents\x12..galaxy_repository.v1.WatchDeployEventsRequest\x1a!.galaxy_repository.v1.DeployEvent0\x01B#\xaa\x02 MxGateway.Contracts.Proto.Galaxyb\x06proto3"
|
||||||
"\x0eBrowseChildren\x12+.galaxy_repository.v1.BrowseChildrenRequest\x1a).galaxy_repository.v1.BrowseChildrenReplyB-\xaa\x02*ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxyb\x06proto3"
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
file_galaxy_repository_proto_rawDescOnce sync.Once
|
file_galaxy_repository_proto_rawDescOnce sync.Once
|
||||||
@@ -1193,7 +916,7 @@ func file_galaxy_repository_proto_rawDescGZIP() []byte {
|
|||||||
return file_galaxy_repository_proto_rawDescData
|
return file_galaxy_repository_proto_rawDescData
|
||||||
}
|
}
|
||||||
|
|
||||||
var file_galaxy_repository_proto_msgTypes = make([]protoimpl.MessageInfo, 12)
|
var file_galaxy_repository_proto_msgTypes = make([]protoimpl.MessageInfo, 10)
|
||||||
var file_galaxy_repository_proto_goTypes = []any{
|
var file_galaxy_repository_proto_goTypes = []any{
|
||||||
(*TestConnectionRequest)(nil), // 0: galaxy_repository.v1.TestConnectionRequest
|
(*TestConnectionRequest)(nil), // 0: galaxy_repository.v1.TestConnectionRequest
|
||||||
(*TestConnectionReply)(nil), // 1: galaxy_repository.v1.TestConnectionReply
|
(*TestConnectionReply)(nil), // 1: galaxy_repository.v1.TestConnectionReply
|
||||||
@@ -1205,35 +928,30 @@ var file_galaxy_repository_proto_goTypes = []any{
|
|||||||
(*DeployEvent)(nil), // 7: galaxy_repository.v1.DeployEvent
|
(*DeployEvent)(nil), // 7: galaxy_repository.v1.DeployEvent
|
||||||
(*GalaxyObject)(nil), // 8: galaxy_repository.v1.GalaxyObject
|
(*GalaxyObject)(nil), // 8: galaxy_repository.v1.GalaxyObject
|
||||||
(*GalaxyAttribute)(nil), // 9: galaxy_repository.v1.GalaxyAttribute
|
(*GalaxyAttribute)(nil), // 9: galaxy_repository.v1.GalaxyAttribute
|
||||||
(*BrowseChildrenRequest)(nil), // 10: galaxy_repository.v1.BrowseChildrenRequest
|
(*timestamppb.Timestamp)(nil), // 10: google.protobuf.Timestamp
|
||||||
(*BrowseChildrenReply)(nil), // 11: galaxy_repository.v1.BrowseChildrenReply
|
(*wrapperspb.Int32Value)(nil), // 11: google.protobuf.Int32Value
|
||||||
(*timestamppb.Timestamp)(nil), // 12: google.protobuf.Timestamp
|
|
||||||
(*wrapperspb.Int32Value)(nil), // 13: google.protobuf.Int32Value
|
|
||||||
}
|
}
|
||||||
var file_galaxy_repository_proto_depIdxs = []int32{
|
var file_galaxy_repository_proto_depIdxs = []int32{
|
||||||
12, // 0: galaxy_repository.v1.GetLastDeployTimeReply.time_of_last_deploy:type_name -> google.protobuf.Timestamp
|
10, // 0: galaxy_repository.v1.GetLastDeployTimeReply.time_of_last_deploy:type_name -> google.protobuf.Timestamp
|
||||||
13, // 1: galaxy_repository.v1.DiscoverHierarchyRequest.max_depth:type_name -> google.protobuf.Int32Value
|
11, // 1: galaxy_repository.v1.DiscoverHierarchyRequest.max_depth:type_name -> google.protobuf.Int32Value
|
||||||
8, // 2: galaxy_repository.v1.DiscoverHierarchyReply.objects:type_name -> galaxy_repository.v1.GalaxyObject
|
8, // 2: galaxy_repository.v1.DiscoverHierarchyReply.objects:type_name -> galaxy_repository.v1.GalaxyObject
|
||||||
12, // 3: galaxy_repository.v1.WatchDeployEventsRequest.last_seen_deploy_time:type_name -> google.protobuf.Timestamp
|
10, // 3: galaxy_repository.v1.WatchDeployEventsRequest.last_seen_deploy_time:type_name -> google.protobuf.Timestamp
|
||||||
12, // 4: galaxy_repository.v1.DeployEvent.observed_at:type_name -> google.protobuf.Timestamp
|
10, // 4: galaxy_repository.v1.DeployEvent.observed_at:type_name -> google.protobuf.Timestamp
|
||||||
12, // 5: galaxy_repository.v1.DeployEvent.time_of_last_deploy:type_name -> google.protobuf.Timestamp
|
10, // 5: galaxy_repository.v1.DeployEvent.time_of_last_deploy:type_name -> google.protobuf.Timestamp
|
||||||
9, // 6: galaxy_repository.v1.GalaxyObject.attributes:type_name -> galaxy_repository.v1.GalaxyAttribute
|
9, // 6: galaxy_repository.v1.GalaxyObject.attributes:type_name -> galaxy_repository.v1.GalaxyAttribute
|
||||||
8, // 7: galaxy_repository.v1.BrowseChildrenReply.children:type_name -> galaxy_repository.v1.GalaxyObject
|
0, // 7: galaxy_repository.v1.GalaxyRepository.TestConnection:input_type -> galaxy_repository.v1.TestConnectionRequest
|
||||||
0, // 8: galaxy_repository.v1.GalaxyRepository.TestConnection:input_type -> galaxy_repository.v1.TestConnectionRequest
|
2, // 8: galaxy_repository.v1.GalaxyRepository.GetLastDeployTime:input_type -> galaxy_repository.v1.GetLastDeployTimeRequest
|
||||||
2, // 9: galaxy_repository.v1.GalaxyRepository.GetLastDeployTime:input_type -> galaxy_repository.v1.GetLastDeployTimeRequest
|
4, // 9: galaxy_repository.v1.GalaxyRepository.DiscoverHierarchy:input_type -> galaxy_repository.v1.DiscoverHierarchyRequest
|
||||||
4, // 10: galaxy_repository.v1.GalaxyRepository.DiscoverHierarchy:input_type -> galaxy_repository.v1.DiscoverHierarchyRequest
|
6, // 10: galaxy_repository.v1.GalaxyRepository.WatchDeployEvents:input_type -> galaxy_repository.v1.WatchDeployEventsRequest
|
||||||
6, // 11: galaxy_repository.v1.GalaxyRepository.WatchDeployEvents:input_type -> galaxy_repository.v1.WatchDeployEventsRequest
|
1, // 11: galaxy_repository.v1.GalaxyRepository.TestConnection:output_type -> galaxy_repository.v1.TestConnectionReply
|
||||||
10, // 12: galaxy_repository.v1.GalaxyRepository.BrowseChildren:input_type -> galaxy_repository.v1.BrowseChildrenRequest
|
3, // 12: galaxy_repository.v1.GalaxyRepository.GetLastDeployTime:output_type -> galaxy_repository.v1.GetLastDeployTimeReply
|
||||||
1, // 13: galaxy_repository.v1.GalaxyRepository.TestConnection:output_type -> galaxy_repository.v1.TestConnectionReply
|
5, // 13: galaxy_repository.v1.GalaxyRepository.DiscoverHierarchy:output_type -> galaxy_repository.v1.DiscoverHierarchyReply
|
||||||
3, // 14: galaxy_repository.v1.GalaxyRepository.GetLastDeployTime:output_type -> galaxy_repository.v1.GetLastDeployTimeReply
|
7, // 14: galaxy_repository.v1.GalaxyRepository.WatchDeployEvents:output_type -> galaxy_repository.v1.DeployEvent
|
||||||
5, // 15: galaxy_repository.v1.GalaxyRepository.DiscoverHierarchy:output_type -> galaxy_repository.v1.DiscoverHierarchyReply
|
11, // [11:15] is the sub-list for method output_type
|
||||||
7, // 16: galaxy_repository.v1.GalaxyRepository.WatchDeployEvents:output_type -> galaxy_repository.v1.DeployEvent
|
7, // [7:11] is the sub-list for method input_type
|
||||||
11, // 17: galaxy_repository.v1.GalaxyRepository.BrowseChildren:output_type -> galaxy_repository.v1.BrowseChildrenReply
|
7, // [7:7] is the sub-list for extension type_name
|
||||||
13, // [13:18] is the sub-list for method output_type
|
7, // [7:7] is the sub-list for extension extendee
|
||||||
8, // [8:13] is the sub-list for method input_type
|
0, // [0:7] is the sub-list for field type_name
|
||||||
8, // [8:8] is the sub-list for extension type_name
|
|
||||||
8, // [8:8] is the sub-list for extension extendee
|
|
||||||
0, // [0:8] is the sub-list for field type_name
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() { file_galaxy_repository_proto_init() }
|
func init() { file_galaxy_repository_proto_init() }
|
||||||
@@ -1246,18 +964,13 @@ func file_galaxy_repository_proto_init() {
|
|||||||
(*DiscoverHierarchyRequest_RootTagName)(nil),
|
(*DiscoverHierarchyRequest_RootTagName)(nil),
|
||||||
(*DiscoverHierarchyRequest_RootContainedPath)(nil),
|
(*DiscoverHierarchyRequest_RootContainedPath)(nil),
|
||||||
}
|
}
|
||||||
file_galaxy_repository_proto_msgTypes[10].OneofWrappers = []any{
|
|
||||||
(*BrowseChildrenRequest_ParentGobjectId)(nil),
|
|
||||||
(*BrowseChildrenRequest_ParentTagName)(nil),
|
|
||||||
(*BrowseChildrenRequest_ParentContainedPath)(nil),
|
|
||||||
}
|
|
||||||
type x struct{}
|
type x struct{}
|
||||||
out := protoimpl.TypeBuilder{
|
out := protoimpl.TypeBuilder{
|
||||||
File: protoimpl.DescBuilder{
|
File: protoimpl.DescBuilder{
|
||||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_galaxy_repository_proto_rawDesc), len(file_galaxy_repository_proto_rawDesc)),
|
RawDescriptor: unsafe.Slice(unsafe.StringData(file_galaxy_repository_proto_rawDesc), len(file_galaxy_repository_proto_rawDesc)),
|
||||||
NumEnums: 0,
|
NumEnums: 0,
|
||||||
NumMessages: 12,
|
NumMessages: 10,
|
||||||
NumExtensions: 0,
|
NumExtensions: 0,
|
||||||
NumServices: 1,
|
NumServices: 1,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// - protoc-gen-go-grpc v1.6.2
|
// - protoc-gen-go-grpc v1.6.1
|
||||||
// - protoc v7.34.1
|
// - protoc v7.34.1
|
||||||
// source: galaxy_repository.proto
|
// source: galaxy_repository.proto
|
||||||
|
|
||||||
@@ -23,7 +23,6 @@ const (
|
|||||||
GalaxyRepository_GetLastDeployTime_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/GetLastDeployTime"
|
GalaxyRepository_GetLastDeployTime_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/GetLastDeployTime"
|
||||||
GalaxyRepository_DiscoverHierarchy_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/DiscoverHierarchy"
|
GalaxyRepository_DiscoverHierarchy_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/DiscoverHierarchy"
|
||||||
GalaxyRepository_WatchDeployEvents_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/WatchDeployEvents"
|
GalaxyRepository_WatchDeployEvents_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/WatchDeployEvents"
|
||||||
GalaxyRepository_BrowseChildren_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/BrowseChildren"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// GalaxyRepositoryClient is the client API for GalaxyRepository service.
|
// GalaxyRepositoryClient is the client API for GalaxyRepository service.
|
||||||
@@ -45,11 +44,6 @@ type GalaxyRepositoryClient interface {
|
|||||||
// increasing per server start; gaps indicate the per-subscriber buffer dropped
|
// increasing per server start; gaps indicate the per-subscriber buffer dropped
|
||||||
// older events because the client was too slow.
|
// older events because the client was too slow.
|
||||||
WatchDeployEvents(ctx context.Context, in *WatchDeployEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[DeployEvent], error)
|
WatchDeployEvents(ctx context.Context, in *WatchDeployEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[DeployEvent], error)
|
||||||
// Returns the direct children of a parent object (or the root objects when
|
|
||||||
// `parent` is unset). Designed for OPC UA-style lazy expand: clients walk
|
|
||||||
// one level at a time instead of paging the full hierarchy. Filters mirror
|
|
||||||
// DiscoverHierarchy exactly. Backed by the same shared hierarchy cache.
|
|
||||||
BrowseChildren(ctx context.Context, in *BrowseChildrenRequest, opts ...grpc.CallOption) (*BrowseChildrenReply, error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type galaxyRepositoryClient struct {
|
type galaxyRepositoryClient struct {
|
||||||
@@ -109,16 +103,6 @@ func (c *galaxyRepositoryClient) WatchDeployEvents(ctx context.Context, in *Watc
|
|||||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||||
type GalaxyRepository_WatchDeployEventsClient = grpc.ServerStreamingClient[DeployEvent]
|
type GalaxyRepository_WatchDeployEventsClient = grpc.ServerStreamingClient[DeployEvent]
|
||||||
|
|
||||||
func (c *galaxyRepositoryClient) BrowseChildren(ctx context.Context, in *BrowseChildrenRequest, opts ...grpc.CallOption) (*BrowseChildrenReply, error) {
|
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
|
||||||
out := new(BrowseChildrenReply)
|
|
||||||
err := c.cc.Invoke(ctx, GalaxyRepository_BrowseChildren_FullMethodName, in, out, cOpts...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GalaxyRepositoryServer is the server API for GalaxyRepository service.
|
// GalaxyRepositoryServer is the server API for GalaxyRepository service.
|
||||||
// All implementations must embed UnimplementedGalaxyRepositoryServer
|
// All implementations must embed UnimplementedGalaxyRepositoryServer
|
||||||
// for forward compatibility.
|
// for forward compatibility.
|
||||||
@@ -138,11 +122,6 @@ type GalaxyRepositoryServer interface {
|
|||||||
// increasing per server start; gaps indicate the per-subscriber buffer dropped
|
// increasing per server start; gaps indicate the per-subscriber buffer dropped
|
||||||
// older events because the client was too slow.
|
// older events because the client was too slow.
|
||||||
WatchDeployEvents(*WatchDeployEventsRequest, grpc.ServerStreamingServer[DeployEvent]) error
|
WatchDeployEvents(*WatchDeployEventsRequest, grpc.ServerStreamingServer[DeployEvent]) error
|
||||||
// Returns the direct children of a parent object (or the root objects when
|
|
||||||
// `parent` is unset). Designed for OPC UA-style lazy expand: clients walk
|
|
||||||
// one level at a time instead of paging the full hierarchy. Filters mirror
|
|
||||||
// DiscoverHierarchy exactly. Backed by the same shared hierarchy cache.
|
|
||||||
BrowseChildren(context.Context, *BrowseChildrenRequest) (*BrowseChildrenReply, error)
|
|
||||||
mustEmbedUnimplementedGalaxyRepositoryServer()
|
mustEmbedUnimplementedGalaxyRepositoryServer()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,9 +144,6 @@ func (UnimplementedGalaxyRepositoryServer) DiscoverHierarchy(context.Context, *D
|
|||||||
func (UnimplementedGalaxyRepositoryServer) WatchDeployEvents(*WatchDeployEventsRequest, grpc.ServerStreamingServer[DeployEvent]) error {
|
func (UnimplementedGalaxyRepositoryServer) WatchDeployEvents(*WatchDeployEventsRequest, grpc.ServerStreamingServer[DeployEvent]) error {
|
||||||
return status.Error(codes.Unimplemented, "method WatchDeployEvents not implemented")
|
return status.Error(codes.Unimplemented, "method WatchDeployEvents not implemented")
|
||||||
}
|
}
|
||||||
func (UnimplementedGalaxyRepositoryServer) BrowseChildren(context.Context, *BrowseChildrenRequest) (*BrowseChildrenReply, error) {
|
|
||||||
return nil, status.Error(codes.Unimplemented, "method BrowseChildren not implemented")
|
|
||||||
}
|
|
||||||
func (UnimplementedGalaxyRepositoryServer) mustEmbedUnimplementedGalaxyRepositoryServer() {}
|
func (UnimplementedGalaxyRepositoryServer) mustEmbedUnimplementedGalaxyRepositoryServer() {}
|
||||||
func (UnimplementedGalaxyRepositoryServer) testEmbeddedByValue() {}
|
func (UnimplementedGalaxyRepositoryServer) testEmbeddedByValue() {}
|
||||||
|
|
||||||
@@ -254,24 +230,6 @@ func _GalaxyRepository_WatchDeployEvents_Handler(srv interface{}, stream grpc.Se
|
|||||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||||
type GalaxyRepository_WatchDeployEventsServer = grpc.ServerStreamingServer[DeployEvent]
|
type GalaxyRepository_WatchDeployEventsServer = grpc.ServerStreamingServer[DeployEvent]
|
||||||
|
|
||||||
func _GalaxyRepository_BrowseChildren_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
|
||||||
in := new(BrowseChildrenRequest)
|
|
||||||
if err := dec(in); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if interceptor == nil {
|
|
||||||
return srv.(GalaxyRepositoryServer).BrowseChildren(ctx, in)
|
|
||||||
}
|
|
||||||
info := &grpc.UnaryServerInfo{
|
|
||||||
Server: srv,
|
|
||||||
FullMethod: GalaxyRepository_BrowseChildren_FullMethodName,
|
|
||||||
}
|
|
||||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
|
||||||
return srv.(GalaxyRepositoryServer).BrowseChildren(ctx, req.(*BrowseChildrenRequest))
|
|
||||||
}
|
|
||||||
return interceptor(ctx, in, info, handler)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GalaxyRepository_ServiceDesc is the grpc.ServiceDesc for GalaxyRepository service.
|
// GalaxyRepository_ServiceDesc is the grpc.ServiceDesc for GalaxyRepository service.
|
||||||
// It's only intended for direct use with grpc.RegisterService,
|
// It's only intended for direct use with grpc.RegisterService,
|
||||||
// and not to be introspected or modified (even as a copy)
|
// and not to be introspected or modified (even as a copy)
|
||||||
@@ -291,10 +249,6 @@ var GalaxyRepository_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "DiscoverHierarchy",
|
MethodName: "DiscoverHierarchy",
|
||||||
Handler: _GalaxyRepository_DiscoverHierarchy_Handler,
|
Handler: _GalaxyRepository_DiscoverHierarchy_Handler,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
MethodName: "BrowseChildren",
|
|
||||||
Handler: _GalaxyRepository_BrowseChildren_Handler,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
Streams: []grpc.StreamDesc{
|
Streams: []grpc.StreamDesc{
|
||||||
{
|
{
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// - protoc-gen-go-grpc v1.6.2
|
// - protoc-gen-go-grpc v1.6.1
|
||||||
// - protoc v7.34.1
|
// - protoc v7.34.1
|
||||||
// source: mxaccess_gateway.proto
|
// source: mxaccess_gateway.proto
|
||||||
|
|
||||||
@@ -25,7 +25,6 @@ const (
|
|||||||
MxAccessGateway_StreamEvents_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/StreamEvents"
|
MxAccessGateway_StreamEvents_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/StreamEvents"
|
||||||
MxAccessGateway_AcknowledgeAlarm_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/AcknowledgeAlarm"
|
MxAccessGateway_AcknowledgeAlarm_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/AcknowledgeAlarm"
|
||||||
MxAccessGateway_StreamAlarms_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/StreamAlarms"
|
MxAccessGateway_StreamAlarms_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/StreamAlarms"
|
||||||
MxAccessGateway_QueryActiveAlarms_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/QueryActiveAlarms"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// MxAccessGatewayClient is the client API for MxAccessGateway service.
|
// MxAccessGatewayClient is the client API for MxAccessGateway service.
|
||||||
@@ -45,15 +44,6 @@ type MxAccessGatewayClient interface {
|
|||||||
// Served by the gateway's always-on alarm monitor; any number of clients
|
// Served by the gateway's always-on alarm monitor; any number of clients
|
||||||
// fan out from the single monitor without opening a worker session.
|
// fan out from the single monitor without opening a worker session.
|
||||||
StreamAlarms(ctx context.Context, in *StreamAlarmsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[AlarmFeedMessage], error)
|
StreamAlarms(ctx context.Context, in *StreamAlarmsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[AlarmFeedMessage], error)
|
||||||
// Point-in-time snapshot of the currently-active alarm set served from the
|
|
||||||
// gateway's always-on alarm monitor cache (session-less). Used after a
|
|
||||||
// reconnect to seed Part 9 client state, or to reconcile alarms that may
|
|
||||||
// have been missed during a transport blip. Streamed so callers can
|
|
||||||
// begin processing without buffering the full set.
|
|
||||||
// `QueryActiveAlarmsRequest.alarm_filter_prefix` optionally narrows the
|
|
||||||
// snapshot to alarms whose `alarm_full_reference` starts with the given
|
|
||||||
// prefix; an empty prefix returns the full set.
|
|
||||||
QueryActiveAlarms(ctx context.Context, in *QueryActiveAlarmsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ActiveAlarmSnapshot], error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type mxAccessGatewayClient struct {
|
type mxAccessGatewayClient struct {
|
||||||
@@ -142,25 +132,6 @@ func (c *mxAccessGatewayClient) StreamAlarms(ctx context.Context, in *StreamAlar
|
|||||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||||
type MxAccessGateway_StreamAlarmsClient = grpc.ServerStreamingClient[AlarmFeedMessage]
|
type MxAccessGateway_StreamAlarmsClient = grpc.ServerStreamingClient[AlarmFeedMessage]
|
||||||
|
|
||||||
func (c *mxAccessGatewayClient) QueryActiveAlarms(ctx context.Context, in *QueryActiveAlarmsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ActiveAlarmSnapshot], error) {
|
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
|
||||||
stream, err := c.cc.NewStream(ctx, &MxAccessGateway_ServiceDesc.Streams[2], MxAccessGateway_QueryActiveAlarms_FullMethodName, cOpts...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
x := &grpc.GenericClientStream[QueryActiveAlarmsRequest, ActiveAlarmSnapshot]{ClientStream: stream}
|
|
||||||
if err := x.ClientStream.SendMsg(in); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := x.ClientStream.CloseSend(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return x, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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]
|
|
||||||
|
|
||||||
// MxAccessGatewayServer is the server API for MxAccessGateway service.
|
// MxAccessGatewayServer is the server API for MxAccessGateway service.
|
||||||
// All implementations must embed UnimplementedMxAccessGatewayServer
|
// All implementations must embed UnimplementedMxAccessGatewayServer
|
||||||
// for forward compatibility.
|
// for forward compatibility.
|
||||||
@@ -178,15 +149,6 @@ type MxAccessGatewayServer interface {
|
|||||||
// Served by the gateway's always-on alarm monitor; any number of clients
|
// Served by the gateway's always-on alarm monitor; any number of clients
|
||||||
// fan out from the single monitor without opening a worker session.
|
// fan out from the single monitor without opening a worker session.
|
||||||
StreamAlarms(*StreamAlarmsRequest, grpc.ServerStreamingServer[AlarmFeedMessage]) error
|
StreamAlarms(*StreamAlarmsRequest, grpc.ServerStreamingServer[AlarmFeedMessage]) error
|
||||||
// Point-in-time snapshot of the currently-active alarm set served from the
|
|
||||||
// gateway's always-on alarm monitor cache (session-less). Used after a
|
|
||||||
// reconnect to seed Part 9 client state, or to reconcile alarms that may
|
|
||||||
// have been missed during a transport blip. Streamed so callers can
|
|
||||||
// begin processing without buffering the full set.
|
|
||||||
// `QueryActiveAlarmsRequest.alarm_filter_prefix` optionally narrows the
|
|
||||||
// snapshot to alarms whose `alarm_full_reference` starts with the given
|
|
||||||
// prefix; an empty prefix returns the full set.
|
|
||||||
QueryActiveAlarms(*QueryActiveAlarmsRequest, grpc.ServerStreamingServer[ActiveAlarmSnapshot]) error
|
|
||||||
mustEmbedUnimplementedMxAccessGatewayServer()
|
mustEmbedUnimplementedMxAccessGatewayServer()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,9 +177,6 @@ func (UnimplementedMxAccessGatewayServer) AcknowledgeAlarm(context.Context, *Ack
|
|||||||
func (UnimplementedMxAccessGatewayServer) StreamAlarms(*StreamAlarmsRequest, grpc.ServerStreamingServer[AlarmFeedMessage]) error {
|
func (UnimplementedMxAccessGatewayServer) StreamAlarms(*StreamAlarmsRequest, grpc.ServerStreamingServer[AlarmFeedMessage]) error {
|
||||||
return status.Error(codes.Unimplemented, "method StreamAlarms not implemented")
|
return status.Error(codes.Unimplemented, "method StreamAlarms not implemented")
|
||||||
}
|
}
|
||||||
func (UnimplementedMxAccessGatewayServer) QueryActiveAlarms(*QueryActiveAlarmsRequest, grpc.ServerStreamingServer[ActiveAlarmSnapshot]) error {
|
|
||||||
return status.Error(codes.Unimplemented, "method QueryActiveAlarms not implemented")
|
|
||||||
}
|
|
||||||
func (UnimplementedMxAccessGatewayServer) mustEmbedUnimplementedMxAccessGatewayServer() {}
|
func (UnimplementedMxAccessGatewayServer) mustEmbedUnimplementedMxAccessGatewayServer() {}
|
||||||
func (UnimplementedMxAccessGatewayServer) testEmbeddedByValue() {}
|
func (UnimplementedMxAccessGatewayServer) testEmbeddedByValue() {}
|
||||||
|
|
||||||
@@ -333,17 +292,6 @@ func _MxAccessGateway_StreamAlarms_Handler(srv interface{}, stream grpc.ServerSt
|
|||||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||||
type MxAccessGateway_StreamAlarmsServer = grpc.ServerStreamingServer[AlarmFeedMessage]
|
type MxAccessGateway_StreamAlarmsServer = grpc.ServerStreamingServer[AlarmFeedMessage]
|
||||||
|
|
||||||
func _MxAccessGateway_QueryActiveAlarms_Handler(srv interface{}, stream grpc.ServerStream) error {
|
|
||||||
m := new(QueryActiveAlarmsRequest)
|
|
||||||
if err := stream.RecvMsg(m); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return srv.(MxAccessGatewayServer).QueryActiveAlarms(m, &grpc.GenericServerStream[QueryActiveAlarmsRequest, ActiveAlarmSnapshot]{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]
|
|
||||||
|
|
||||||
// MxAccessGateway_ServiceDesc is the grpc.ServiceDesc for MxAccessGateway service.
|
// MxAccessGateway_ServiceDesc is the grpc.ServiceDesc for MxAccessGateway service.
|
||||||
// It's only intended for direct use with grpc.RegisterService,
|
// It's only intended for direct use with grpc.RegisterService,
|
||||||
// and not to be introspected or modified (even as a copy)
|
// and not to be introspected or modified (even as a copy)
|
||||||
@@ -379,11 +327,6 @@ var MxAccessGateway_ServiceDesc = grpc.ServiceDesc{
|
|||||||
Handler: _MxAccessGateway_StreamAlarms_Handler,
|
Handler: _MxAccessGateway_StreamAlarms_Handler,
|
||||||
ServerStreams: true,
|
ServerStreams: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
StreamName: "QueryActiveAlarms",
|
|
||||||
Handler: _MxAccessGateway_QueryActiveAlarms_Handler,
|
|
||||||
ServerStreams: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
Metadata: "mxaccess_gateway.proto",
|
Metadata: "mxaccess_gateway.proto",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1179,7 +1179,7 @@ const file_mxaccess_worker_proto_rawDesc = "" +
|
|||||||
"\x1eWORKER_FAULT_CATEGORY_STA_HUNG\x10\t\x12(\n" +
|
"\x1eWORKER_FAULT_CATEGORY_STA_HUNG\x10\t\x12(\n" +
|
||||||
"$WORKER_FAULT_CATEGORY_QUEUE_OVERFLOW\x10\n" +
|
"$WORKER_FAULT_CATEGORY_QUEUE_OVERFLOW\x10\n" +
|
||||||
"\x12*\n" +
|
"\x12*\n" +
|
||||||
"&WORKER_FAULT_CATEGORY_SHUTDOWN_TIMEOUT\x10\vB&\xaa\x02#ZB.MOM.WW.MxGateway.Contracts.Protob\x06proto3"
|
"&WORKER_FAULT_CATEGORY_SHUTDOWN_TIMEOUT\x10\vB\x1c\xaa\x02\x19MxGateway.Contracts.Protob\x06proto3"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
file_mxaccess_worker_proto_rawDescOnce sync.Once
|
file_mxaccess_worker_proto_rawDescOnce sync.Once
|
||||||
|
|||||||
@@ -31,27 +31,6 @@ func (c *Client) AcknowledgeAlarm(ctx context.Context, req *AcknowledgeAlarmRequ
|
|||||||
return reply, nil
|
return reply, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// QueryActiveAlarms streams a snapshot of all alarms currently Active or
|
|
||||||
// ActiveAcked — the gateway's ConditionRefresh equivalent. Used after reconnect
|
|
||||||
// to seed local Part 9 state, or to reconcile alarms that may have been missed
|
|
||||||
// during a transport blip.
|
|
||||||
//
|
|
||||||
// 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) QueryActiveAlarms(ctx context.Context, req *QueryActiveAlarmsRequest) (QueryActiveAlarmsClient, error) {
|
|
||||||
if req == nil {
|
|
||||||
return nil, errors.New("mxgateway: query active alarms request is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
stream, err := c.raw.QueryActiveAlarms(ctx, req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, &GatewayError{Op: "query active alarms", Err: err}
|
|
||||||
}
|
|
||||||
|
|
||||||
return stream, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// StreamAlarms attaches to the gateway's central alarm feed. The stream opens
|
// StreamAlarms attaches to the gateway's central alarm feed. The stream opens
|
||||||
// with one AlarmFeedMessage per currently-active alarm (the ConditionRefresh
|
// with one AlarmFeedMessage per currently-active alarm (the ConditionRefresh
|
||||||
// snapshot), then a single snapshot-complete sentinel, then a transition for
|
// snapshot), then a single snapshot-complete sentinel, then a transition for
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ import (
|
|||||||
"google.golang.org/grpc/test/bufconn"
|
"google.golang.org/grpc/test/bufconn"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PR E.4 — pins the Go SDK surface for the new alarm RPCs:
|
// Pins the Go SDK surface for the alarm RPCs: AcknowledgeAlarm + StreamAlarms.
|
||||||
// AcknowledgeAlarm + QueryActiveAlarms.
|
|
||||||
|
|
||||||
func TestAcknowledgeAlarmSendsRequestAndReturnsReply(t *testing.T) {
|
func TestAcknowledgeAlarmSendsRequestAndReturnsReply(t *testing.T) {
|
||||||
fake := &fakeGatewayWithAlarms{
|
fake := &fakeGatewayWithAlarms{
|
||||||
@@ -62,10 +61,6 @@ func TestAcknowledgeAlarmRejectsNilRequest(t *testing.T) {
|
|||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
_, err := client.AcknowledgeAlarm(context.Background(), nil)
|
_, err := client.AcknowledgeAlarm(context.Background(), nil)
|
||||||
if err == nil || !errors.Is(err, errors.Unwrap(err)) && err.Error() != "mxgateway: acknowledge alarm request is required" {
|
|
||||||
// Accept either: the helper returned the literal sentinel, or the
|
|
||||||
// generic transport error — both prove nil was rejected.
|
|
||||||
}
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatalf("AcknowledgeAlarm(nil) returned no error")
|
t.Fatalf("AcknowledgeAlarm(nil) returned no error")
|
||||||
}
|
}
|
||||||
@@ -94,7 +89,7 @@ func TestAcknowledgeAlarmMapsUnauthenticated(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryActiveAlarmsStreamsSnapshots(t *testing.T) {
|
func TestStreamAlarmsStreamsSnapshotThenSnapshotComplete(t *testing.T) {
|
||||||
fake := &fakeGatewayWithAlarms{
|
fake := &fakeGatewayWithAlarms{
|
||||||
activeSnapshots: []*pb.ActiveAlarmSnapshot{
|
activeSnapshots: []*pb.ActiveAlarmSnapshot{
|
||||||
{
|
{
|
||||||
@@ -112,86 +107,7 @@ func TestQueryActiveAlarmsStreamsSnapshots(t *testing.T) {
|
|||||||
client, cleanup := newBufconnClientWithAlarms(t, fake)
|
client, cleanup := newBufconnClientWithAlarms(t, fake)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
stream, err := client.QueryActiveAlarms(context.Background(), &pb.QueryActiveAlarmsRequest{
|
stream, err := client.StreamAlarms(context.Background(), &pb.StreamAlarmsRequest{})
|
||||||
SessionId: "session-1",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("QueryActiveAlarms() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var received []*pb.ActiveAlarmSnapshot
|
|
||||||
for {
|
|
||||||
snap, err := stream.Recv()
|
|
||||||
if errors.Is(err, io.EOF) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("stream.Recv() error = %v", err)
|
|
||||||
}
|
|
||||||
received = append(received, snap)
|
|
||||||
}
|
|
||||||
if len(received) != 2 {
|
|
||||||
t.Fatalf("snapshot count = %d, want 2", len(received))
|
|
||||||
}
|
|
||||||
if received[0].GetAlarmFullReference() != "Tank01.Level.HiHi" {
|
|
||||||
t.Fatalf("snapshot[0] ref = %q", received[0].GetAlarmFullReference())
|
|
||||||
}
|
|
||||||
if received[1].GetCurrentState() != pb.AlarmConditionState_ALARM_CONDITION_STATE_ACTIVE_ACKED {
|
|
||||||
t.Fatalf("snapshot[1] state = %v", received[1].GetCurrentState())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestQueryActiveAlarmsPassesFilterPrefix(t *testing.T) {
|
|
||||||
fake := &fakeGatewayWithAlarms{}
|
|
||||||
client, cleanup := newBufconnClientWithAlarms(t, fake)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
stream, err := client.QueryActiveAlarms(context.Background(), &pb.QueryActiveAlarmsRequest{
|
|
||||||
SessionId: "session-1",
|
|
||||||
AlarmFilterPrefix: "Tank01.",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("QueryActiveAlarms() 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.queryRequest.GetAlarmFilterPrefix(); got != "Tank01." {
|
|
||||||
t.Fatalf("captured filter prefix = %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStreamAlarmsPassesFilterPrefixAndReceivesFeedMessages(t *testing.T) {
|
|
||||||
fake := &fakeGatewayWithAlarms{
|
|
||||||
feedMessages: []*pb.AlarmFeedMessage{
|
|
||||||
{
|
|
||||||
Payload: &pb.AlarmFeedMessage_ActiveAlarm{
|
|
||||||
ActiveAlarm: &pb.ActiveAlarmSnapshot{
|
|
||||||
AlarmFullReference: "Tank01.Level.HiHi",
|
|
||||||
CurrentState: pb.AlarmConditionState_ALARM_CONDITION_STATE_ACTIVE,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Payload: &pb.AlarmFeedMessage_SnapshotComplete{
|
|
||||||
SnapshotComplete: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
client, cleanup := newBufconnClientWithAlarms(t, fake)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
stream, err := client.StreamAlarms(context.Background(), &pb.StreamAlarmsRequest{
|
|
||||||
AlarmFilterPrefix: "Tank01.",
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("StreamAlarms() error = %v", err)
|
t.Fatalf("StreamAlarms() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -207,24 +123,43 @@ func TestStreamAlarmsPassesFilterPrefixAndReceivesFeedMessages(t *testing.T) {
|
|||||||
}
|
}
|
||||||
received = append(received, msg)
|
received = append(received, msg)
|
||||||
}
|
}
|
||||||
if len(received) != 2 {
|
if len(received) != 3 {
|
||||||
t.Fatalf("received count = %d, want 2", len(received))
|
t.Fatalf("message count = %d, want 3", len(received))
|
||||||
}
|
}
|
||||||
if got := fake.streamRequest.GetAlarmFilterPrefix(); got != "Tank01." {
|
if received[0].GetActiveAlarm().GetAlarmFullReference() != "Tank01.Level.HiHi" {
|
||||||
t.Fatalf("captured filter prefix = %q", got)
|
t.Fatalf("message[0] ref = %q", received[0].GetActiveAlarm().GetAlarmFullReference())
|
||||||
}
|
}
|
||||||
if got := fake.streamAuth; got != "Bearer test-api-key" {
|
if received[1].GetActiveAlarm().GetCurrentState() != pb.AlarmConditionState_ALARM_CONDITION_STATE_ACTIVE_ACKED {
|
||||||
t.Fatalf("stream authorization metadata = %q", got)
|
t.Fatalf("message[1] state = %v", received[1].GetActiveAlarm().GetCurrentState())
|
||||||
|
}
|
||||||
|
if !received[2].GetSnapshotComplete() {
|
||||||
|
t.Fatalf("final message is not snapshot_complete")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStreamAlarmsRejectsNilRequest(t *testing.T) {
|
func TestStreamAlarmsPassesFilterPrefix(t *testing.T) {
|
||||||
fake := &fakeGatewayWithAlarms{}
|
fake := &fakeGatewayWithAlarms{}
|
||||||
client, cleanup := newBufconnClientWithAlarms(t, fake)
|
client, cleanup := newBufconnClientWithAlarms(t, fake)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
if _, err := client.StreamAlarms(context.Background(), nil); err == nil {
|
stream, err := client.StreamAlarms(context.Background(), &pb.StreamAlarmsRequest{
|
||||||
t.Fatal("StreamAlarms(nil) returned no error")
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,12 +171,8 @@ type fakeGatewayWithAlarms struct {
|
|||||||
acknowledgeError error
|
acknowledgeError error
|
||||||
acknowledgeAuth string
|
acknowledgeAuth string
|
||||||
|
|
||||||
queryRequest *pb.QueryActiveAlarmsRequest
|
|
||||||
activeSnapshots []*pb.ActiveAlarmSnapshot
|
|
||||||
|
|
||||||
streamRequest *pb.StreamAlarmsRequest
|
streamRequest *pb.StreamAlarmsRequest
|
||||||
feedMessages []*pb.AlarmFeedMessage
|
activeSnapshots []*pb.ActiveAlarmSnapshot
|
||||||
streamAuth string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *fakeGatewayWithAlarms) AcknowledgeAlarm(ctx context.Context, req *pb.AcknowledgeAlarmRequest) (*pb.AcknowledgeAlarmReply, error) {
|
func (s *fakeGatewayWithAlarms) AcknowledgeAlarm(ctx context.Context, req *pb.AcknowledgeAlarmRequest) (*pb.AcknowledgeAlarmReply, error) {
|
||||||
@@ -254,32 +185,24 @@ func (s *fakeGatewayWithAlarms) AcknowledgeAlarm(ctx context.Context, req *pb.Ac
|
|||||||
return s.acknowledgeReply, nil
|
return s.acknowledgeReply, nil
|
||||||
}
|
}
|
||||||
return &pb.AcknowledgeAlarmReply{
|
return &pb.AcknowledgeAlarmReply{
|
||||||
CorrelationId: req.GetClientCorrelationId(),
|
|
||||||
ProtocolStatus: &pb.ProtocolStatus{
|
ProtocolStatus: &pb.ProtocolStatus{
|
||||||
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
|
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
|
||||||
},
|
},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *fakeGatewayWithAlarms) QueryActiveAlarms(req *pb.QueryActiveAlarmsRequest, stream grpc.ServerStreamingServer[pb.ActiveAlarmSnapshot]) error {
|
|
||||||
s.queryRequest = req
|
|
||||||
for _, snap := range s.activeSnapshots {
|
|
||||||
if err := stream.Send(snap); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *fakeGatewayWithAlarms) StreamAlarms(req *pb.StreamAlarmsRequest, stream grpc.ServerStreamingServer[pb.AlarmFeedMessage]) error {
|
func (s *fakeGatewayWithAlarms) StreamAlarms(req *pb.StreamAlarmsRequest, stream grpc.ServerStreamingServer[pb.AlarmFeedMessage]) error {
|
||||||
s.streamRequest = req
|
s.streamRequest = req
|
||||||
s.streamAuth = authorizationFromContext(stream.Context())
|
for _, snap := range s.activeSnapshots {
|
||||||
for _, msg := range s.feedMessages {
|
if err := stream.Send(&pb.AlarmFeedMessage{
|
||||||
if err := stream.Send(msg); err != nil {
|
Payload: &pb.AlarmFeedMessage_ActiveAlarm{ActiveAlarm: snap},
|
||||||
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return stream.Send(&pb.AlarmFeedMessage{
|
||||||
|
Payload: &pb.AlarmFeedMessage_SnapshotComplete{SnapshotComplete: true},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func newBufconnClientWithAlarms(t *testing.T, fake *fakeGatewayWithAlarms) (*Client, func()) {
|
func newBufconnClientWithAlarms(t *testing.T, fake *fakeGatewayWithAlarms) (*Client, func()) {
|
||||||
@@ -293,8 +216,10 @@ func newBufconnClientWithAlarms(t *testing.T, fake *fakeGatewayWithAlarms) (*Cli
|
|||||||
dialer := func(ctx context.Context, _ string) (net.Conn, error) {
|
dialer := func(ctx context.Context, _ string) (net.Conn, error) {
|
||||||
return listener.DialContext(ctx)
|
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{
|
client, err := Dial(context.Background(), Options{
|
||||||
Endpoint: "bufnet",
|
Endpoint: "passthrough:///bufnet",
|
||||||
APIKey: "test-api-key",
|
APIKey: "test-api-key",
|
||||||
Plaintext: true,
|
Plaintext: true,
|
||||||
DialOptions: []grpc.DialOption{grpc.WithContextDialer(dialer)},
|
DialOptions: []grpc.DialOption{grpc.WithContextDialer(dialer)},
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
|
|
||||||
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/connectivity"
|
||||||
"google.golang.org/grpc/credentials"
|
"google.golang.org/grpc/credentials"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
"google.golang.org/protobuf/types/known/durationpb"
|
"google.golang.org/protobuf/types/known/durationpb"
|
||||||
@@ -36,22 +37,36 @@ type Client struct {
|
|||||||
opts Options
|
opts Options
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dial opens a gRPC connection to the gateway and configures auth metadata,
|
// Dial opens a gRPC connection to the gateway and configures auth metadata
|
||||||
// transport security, and blocking dial cancellation from ctx.
|
// 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) {
|
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 == "" {
|
if opts.Endpoint == "" {
|
||||||
return nil, errors.New("mxgateway: endpoint is required")
|
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)
|
transportCredentials, err := resolveTransportCredentials(opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -61,16 +76,46 @@ func Dial(ctx context.Context, opts Options) (*Client, error) {
|
|||||||
grpc.WithTransportCredentials(transportCredentials),
|
grpc.WithTransportCredentials(transportCredentials),
|
||||||
grpc.WithUnaryInterceptor(unaryAuthInterceptor(opts.APIKey)),
|
grpc.WithUnaryInterceptor(unaryAuthInterceptor(opts.APIKey)),
|
||||||
grpc.WithStreamInterceptor(streamAuthInterceptor(opts.APIKey)),
|
grpc.WithStreamInterceptor(streamAuthInterceptor(opts.APIKey)),
|
||||||
grpc.WithBlock(),
|
|
||||||
}
|
}
|
||||||
dialOptions = append(dialOptions, opts.DialOptions...)
|
dialOptions = append(dialOptions, opts.DialOptions...)
|
||||||
|
|
||||||
conn, err := grpc.DialContext(dialCtx, opts.Endpoint, dialOptions...)
|
conn, err := grpc.NewClient(opts.Endpoint, dialOptions...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, &GatewayError{Op: "dial", Err: err}
|
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
|
// 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) {
|
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 {
|
if timeout == 0 {
|
||||||
timeout = defaultCallTimeout
|
timeout = defaultCallTimeout
|
||||||
}
|
}
|
||||||
@@ -222,22 +275,10 @@ func resolveTransportCredentials(opts Options) (credentials.TransportCredentials
|
|||||||
return credentials.NewTLS(cfg), nil
|
return credentials.NewTLS(cfg), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return credentials.NewTLS(tlsConfigForOptions(opts)), nil
|
return credentials.NewTLS(&tls.Config{
|
||||||
}
|
|
||||||
|
|
||||||
// tlsConfigForOptions returns the *tls.Config for the no-CA, no-custom-config TLS path.
|
|
||||||
// It returns nil when the caller should use a different credentials path (CA file or custom TLSConfig).
|
|
||||||
// Exposed as an internal helper so unit tests can assert the InsecureSkipVerify posture.
|
|
||||||
func tlsConfigForOptions(opts Options) *tls.Config {
|
|
||||||
// CA file and custom TLSConfig take their own paths in resolveTransportCredentials.
|
|
||||||
if opts.CACertFile != "" || opts.TLSConfig != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &tls.Config{
|
|
||||||
MinVersion: tls.VersionTLS12,
|
MinVersion: tls.VersionTLS12,
|
||||||
ServerName: opts.ServerNameOverride,
|
ServerName: opts.ServerNameOverride,
|
||||||
InsecureSkipVerify: !opts.RequireCertificateValidation, //nolint:gosec // internal tool; self-signed gateway cert expected; opt-in strict via RequireCertificateValidation
|
}), nil
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenSessionOptions describes fields used to create an OpenSessionRequest.
|
// OpenSessionOptions describes fields used to create an OpenSessionRequest.
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ func TestEventsAfterCancelsStreamWhenCompatibilityChannelIsAbandoned(t *testing.
|
|||||||
fake := &fakeGatewayServer{
|
fake := &fakeGatewayServer{
|
||||||
streamStarted: make(chan struct{}),
|
streamStarted: make(chan struct{}),
|
||||||
streamDone: make(chan struct{}),
|
streamDone: make(chan struct{}),
|
||||||
streamEventCount: 64,
|
streamEventCount: 256,
|
||||||
}
|
}
|
||||||
client, cleanup := newBufconnClient(t, fake)
|
client, cleanup := newBufconnClient(t, fake)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
@@ -135,12 +135,25 @@ func TestEventsAfterCancelsStreamWhenCompatibilityChannelIsAbandoned(t *testing.
|
|||||||
t.Fatal("compatibility event stream did not stop after result channel filled")
|
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 {
|
for {
|
||||||
select {
|
select {
|
||||||
case _, ok := <-events:
|
case result, ok := <-events:
|
||||||
if !ok {
|
if !ok {
|
||||||
|
if !sawOverflow {
|
||||||
|
t.Fatal("compatibility event channel closed without an ErrEventBufferOverflow result")
|
||||||
|
}
|
||||||
return
|
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):
|
case <-time.After(2 * time.Second):
|
||||||
t.Fatal("compatibility event channel did not close")
|
t.Fatal("compatibility event channel did not close")
|
||||||
}
|
}
|
||||||
@@ -241,8 +254,8 @@ func TestWriteBulkBuildsOneBulkCommandAndReturnsPerEntryResults(t *testing.T) {
|
|||||||
Payload: &pb.MxCommandReply_WriteBulk{
|
Payload: &pb.MxCommandReply_WriteBulk{
|
||||||
WriteBulk: &pb.BulkWriteReply{
|
WriteBulk: &pb.BulkWriteReply{
|
||||||
Results: []*pb.BulkWriteResult{
|
Results: []*pb.BulkWriteResult{
|
||||||
{ItemHandle: 10, WasSuccessful: true},
|
{ServerHandle: 12, ItemHandle: 901, WasSuccessful: true},
|
||||||
{ItemHandle: 11, WasSuccessful: true},
|
{ServerHandle: 12, ItemHandle: 902, WasSuccessful: false, ErrorMessage: "invalid handle"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -252,114 +265,22 @@ func TestWriteBulkBuildsOneBulkCommandAndReturnsPerEntryResults(t *testing.T) {
|
|||||||
defer cleanup()
|
defer cleanup()
|
||||||
session := NewSessionForID(client, "session-1")
|
session := NewSessionForID(client, "session-1")
|
||||||
|
|
||||||
entries := []*WriteBulkEntry{
|
results, err := session.WriteBulk(context.Background(), 12, []*pb.WriteBulkEntry{
|
||||||
{ItemHandle: 10, Value: Int32Value(7), UserId: 100},
|
{ItemHandle: 901, UserId: 5, Value: &pb.MxValue{DataType: pb.MxDataType_MX_DATA_TYPE_INTEGER, Kind: &pb.MxValue_Int32Value{Int32Value: 11}}},
|
||||||
{ItemHandle: 11, Value: Int32Value(8), UserId: 100},
|
{ItemHandle: 902, UserId: 5, Value: &pb.MxValue{DataType: pb.MxDataType_MX_DATA_TYPE_INTEGER, Kind: &pb.MxValue_Int32Value{Int32Value: 22}}},
|
||||||
}
|
})
|
||||||
results, err := session.WriteBulk(context.Background(), 12, entries)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("WriteBulk() error = %v", err)
|
t.Fatalf("WriteBulk() error = %v", err)
|
||||||
}
|
}
|
||||||
if len(results) != 2 {
|
if len(results) != 2 || !results[0].GetWasSuccessful() || results[1].GetWasSuccessful() {
|
||||||
t.Fatalf("results len = %d, want 2", len(results))
|
t.Fatalf("results = %#v, want [success, failure]", results)
|
||||||
}
|
}
|
||||||
req := fake.invokeRequest
|
req := fake.invokeRequest
|
||||||
if req.GetCommand().GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_WRITE_BULK {
|
if req.GetCommand().GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_WRITE_BULK {
|
||||||
t.Fatalf("command kind = %s", req.GetCommand().GetKind())
|
t.Fatalf("command kind = %s", req.GetCommand().GetKind())
|
||||||
}
|
}
|
||||||
if got := req.GetCommand().GetWriteBulk().GetEntries(); len(got) != 2 {
|
if got := req.GetCommand().GetWriteBulk().GetEntries(); len(got) != 2 {
|
||||||
t.Fatalf("entry count = %d, want 2", len(got))
|
t.Fatalf("entries = %#v, want 2", got)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWriteBulkRejectsNilEntries(t *testing.T) {
|
|
||||||
fake := &fakeGatewayServer{}
|
|
||||||
client, cleanup := newBufconnClient(t, fake)
|
|
||||||
defer cleanup()
|
|
||||||
session := NewSessionForID(client, "session-1")
|
|
||||||
|
|
||||||
if _, err := session.WriteBulk(context.Background(), 12, nil); err == nil {
|
|
||||||
t.Fatal("WriteBulk(nil) returned no error")
|
|
||||||
}
|
|
||||||
if _, err := session.Write2Bulk(context.Background(), 12, nil); err == nil {
|
|
||||||
t.Fatal("Write2Bulk(nil) returned no error")
|
|
||||||
}
|
|
||||||
if _, err := session.WriteSecuredBulk(context.Background(), 12, nil); err == nil {
|
|
||||||
t.Fatal("WriteSecuredBulk(nil) returned no error")
|
|
||||||
}
|
|
||||||
if _, err := session.WriteSecured2Bulk(context.Background(), 12, nil); err == nil {
|
|
||||||
t.Fatal("WriteSecured2Bulk(nil) returned no error")
|
|
||||||
}
|
|
||||||
if _, err := session.ReadBulk(context.Background(), 12, nil, 0); err == nil {
|
|
||||||
t.Fatal("ReadBulk(nil) returned no error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBulkMethodsShortCircuitOnEmptySliceWithoutRoundTrip(t *testing.T) {
|
|
||||||
fake := &fakeGatewayServer{
|
|
||||||
invokeReply: &pb.MxCommandReply{
|
|
||||||
ProtocolStatus: &pb.ProtocolStatus{
|
|
||||||
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
client, cleanup := newBufconnClient(t, fake)
|
|
||||||
defer cleanup()
|
|
||||||
session := NewSessionForID(client, "session-1")
|
|
||||||
|
|
||||||
results, err := session.WriteBulk(context.Background(), 12, []*WriteBulkEntry{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("WriteBulk(empty) error = %v", err)
|
|
||||||
}
|
|
||||||
if len(results) != 0 {
|
|
||||||
t.Fatalf("WriteBulk(empty) results len = %d, want 0", len(results))
|
|
||||||
}
|
|
||||||
if fake.invokeRequest != nil {
|
|
||||||
t.Fatal("WriteBulk(empty) sent a round trip; expected short-circuit")
|
|
||||||
}
|
|
||||||
|
|
||||||
results2, err := session.Write2Bulk(context.Background(), 12, []*Write2BulkEntry{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Write2Bulk(empty) error = %v", err)
|
|
||||||
}
|
|
||||||
if len(results2) != 0 {
|
|
||||||
t.Fatalf("Write2Bulk(empty) results len = %d, want 0", len(results2))
|
|
||||||
}
|
|
||||||
if fake.invokeRequest != nil {
|
|
||||||
t.Fatal("Write2Bulk(empty) sent a round trip; expected short-circuit")
|
|
||||||
}
|
|
||||||
|
|
||||||
results3, err := session.WriteSecuredBulk(context.Background(), 12, []*WriteSecuredBulkEntry{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("WriteSecuredBulk(empty) error = %v", err)
|
|
||||||
}
|
|
||||||
if len(results3) != 0 {
|
|
||||||
t.Fatalf("WriteSecuredBulk(empty) results len = %d, want 0", len(results3))
|
|
||||||
}
|
|
||||||
if fake.invokeRequest != nil {
|
|
||||||
t.Fatal("WriteSecuredBulk(empty) sent a round trip; expected short-circuit")
|
|
||||||
}
|
|
||||||
|
|
||||||
results4, err := session.WriteSecured2Bulk(context.Background(), 12, []*WriteSecured2BulkEntry{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("WriteSecured2Bulk(empty) error = %v", err)
|
|
||||||
}
|
|
||||||
if len(results4) != 0 {
|
|
||||||
t.Fatalf("WriteSecured2Bulk(empty) results len = %d, want 0", len(results4))
|
|
||||||
}
|
|
||||||
if fake.invokeRequest != nil {
|
|
||||||
t.Fatal("WriteSecured2Bulk(empty) sent a round trip; expected short-circuit")
|
|
||||||
}
|
|
||||||
|
|
||||||
readResults, err := session.ReadBulk(context.Background(), 12, []string{}, 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ReadBulk(empty) error = %v", err)
|
|
||||||
}
|
|
||||||
if len(readResults) != 0 {
|
|
||||||
t.Fatalf("ReadBulk(empty) results len = %d, want 0", len(readResults))
|
|
||||||
}
|
|
||||||
if fake.invokeRequest != nil {
|
|
||||||
t.Fatal("ReadBulk(empty) sent a round trip; expected short-circuit")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,8 +295,14 @@ func TestReadBulkForwardsTimeoutAndUnpacksCachedFlag(t *testing.T) {
|
|||||||
Payload: &pb.MxCommandReply_ReadBulk{
|
Payload: &pb.MxCommandReply_ReadBulk{
|
||||||
ReadBulk: &pb.BulkReadReply{
|
ReadBulk: &pb.BulkReadReply{
|
||||||
Results: []*pb.BulkReadResult{
|
Results: []*pb.BulkReadResult{
|
||||||
{TagAddress: "Tank01.Level", WasSuccessful: true, WasCached: true},
|
{
|
||||||
{TagAddress: "Tank02.Level", WasSuccessful: true, WasCached: false},
|
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}},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -385,48 +312,15 @@ func TestReadBulkForwardsTimeoutAndUnpacksCachedFlag(t *testing.T) {
|
|||||||
defer cleanup()
|
defer cleanup()
|
||||||
session := NewSessionForID(client, "session-1")
|
session := NewSessionForID(client, "session-1")
|
||||||
|
|
||||||
results, err := session.ReadBulk(context.Background(), 12, []string{"Tank01.Level", "Tank02.Level"}, 250*time.Millisecond)
|
results, err := session.ReadBulk(context.Background(), 12, []string{"Area001.Pump001.Speed"}, 750*time.Millisecond)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ReadBulk() error = %v", err)
|
t.Fatalf("ReadBulk() error = %v", err)
|
||||||
}
|
}
|
||||||
if len(results) != 2 {
|
if len(results) != 1 || !results[0].GetWasCached() || results[0].GetValue().GetInt32Value() != 99 {
|
||||||
t.Fatalf("results len = %d, want 2", len(results))
|
t.Fatalf("results = %#v", results)
|
||||||
}
|
}
|
||||||
if !results[0].GetWasCached() || results[1].GetWasCached() {
|
if got := fake.invokeRequest.GetCommand().GetReadBulk().GetTimeoutMs(); got != 750 {
|
||||||
t.Fatalf("WasCached flags = [%v %v], want [true false]", results[0].GetWasCached(), results[1].GetWasCached())
|
t.Fatalf("timeout_ms = %d, want 750", got)
|
||||||
}
|
|
||||||
req := fake.invokeRequest
|
|
||||||
if req.GetCommand().GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_READ_BULK {
|
|
||||||
t.Fatalf("command kind = %s", req.GetCommand().GetKind())
|
|
||||||
}
|
|
||||||
if got := req.GetCommand().GetReadBulk().GetTimeoutMs(); got != 250 {
|
|
||||||
t.Fatalf("timeout ms = %d, want 250", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReadBulkSaturatesTimeoutAboveMaxUint32(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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
client, cleanup := newBufconnClient(t, fake)
|
|
||||||
defer cleanup()
|
|
||||||
session := NewSessionForID(client, "session-1")
|
|
||||||
|
|
||||||
// 100 days in milliseconds exceeds MaxUint32 (~49.7 days).
|
|
||||||
hugeTimeout := 100 * 24 * time.Hour
|
|
||||||
_, err := session.ReadBulk(context.Background(), 12, []string{"Tank01.Level"}, hugeTimeout)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ReadBulk() error = %v", err)
|
|
||||||
}
|
|
||||||
got := fake.invokeRequest.GetCommand().GetReadBulk().GetTimeoutMs()
|
|
||||||
if got != ^uint32(0) {
|
|
||||||
t.Fatalf("timeout ms = %d, want %d (MaxUint32)", got, ^uint32(0))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,8 +373,11 @@ func newBufconnClient(t *testing.T, fake *fakeGatewayServer) (*Client, func()) {
|
|||||||
dialer := func(ctx context.Context, _ string) (net.Conn, error) {
|
dialer := func(ctx context.Context, _ string) (net.Conn, error) {
|
||||||
return listener.DialContext(ctx)
|
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{
|
client, err := Dial(context.Background(), Options{
|
||||||
Endpoint: "bufnet",
|
Endpoint: "passthrough:///bufnet",
|
||||||
APIKey: "test-api-key",
|
APIKey: "test-api-key",
|
||||||
Plaintext: true,
|
Plaintext: true,
|
||||||
DialOptions: []grpc.DialOption{
|
DialOptions: []grpc.DialOption{
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
package mxgateway
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/tls"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// tlsConfigFromOptions is the internal helper under test.
|
|
||||||
// It extracts the *tls.Config from the no-CA TLS path of resolveTransportCredentials.
|
|
||||||
// We exercise it directly to avoid needing a real dial target.
|
|
||||||
|
|
||||||
func TestTLSInsecureSkipVerify_DefaultTrue(t *testing.T) {
|
|
||||||
cfg := tlsConfigForOptions(Options{
|
|
||||||
Endpoint: "localhost:5120",
|
|
||||||
})
|
|
||||||
if cfg == nil {
|
|
||||||
t.Fatal("expected non-nil tls.Config")
|
|
||||||
}
|
|
||||||
if !cfg.InsecureSkipVerify {
|
|
||||||
t.Error("InsecureSkipVerify should be true by default when no CA is pinned")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTLSInsecureSkipVerify_FalseWhenRequireCertificateValidation(t *testing.T) {
|
|
||||||
cfg := tlsConfigForOptions(Options{
|
|
||||||
Endpoint: "localhost:5120",
|
|
||||||
RequireCertificateValidation: true,
|
|
||||||
})
|
|
||||||
if cfg == nil {
|
|
||||||
t.Fatal("expected non-nil tls.Config")
|
|
||||||
}
|
|
||||||
if cfg.InsecureSkipVerify {
|
|
||||||
t.Error("InsecureSkipVerify should be false when RequireCertificateValidation is true")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTLSInsecureSkipVerify_FalseWhenCACertFileSet(t *testing.T) {
|
|
||||||
// When a CA file is pinned, the CA-verification path is taken instead.
|
|
||||||
// tlsConfigForOptions should return nil (the CA path does not use our helper).
|
|
||||||
cfg := tlsConfigForOptions(Options{
|
|
||||||
Endpoint: "localhost:5120",
|
|
||||||
CACertFile: "/some/ca.pem",
|
|
||||||
})
|
|
||||||
if cfg != nil {
|
|
||||||
t.Error("expected nil tls.Config when CACertFile is set (CA path taken)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTLSInsecureSkipVerify_FalseWhenCustomTLSConfig(t *testing.T) {
|
|
||||||
// When TLSConfig is supplied explicitly, our default skip-verify must not overwrite it.
|
|
||||||
custom := &tls.Config{MinVersion: tls.VersionTLS13}
|
|
||||||
cfg := tlsConfigForOptions(Options{
|
|
||||||
Endpoint: "localhost:5120",
|
|
||||||
TLSConfig: custom,
|
|
||||||
})
|
|
||||||
if cfg != nil {
|
|
||||||
t.Error("expected nil tls.Config when TLSConfig is already set (custom config path taken)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
package mxgateway
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
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.
|
// GatewayError wraps transport-level gRPC failures.
|
||||||
type GatewayError struct {
|
type GatewayError struct {
|
||||||
// Op names the operation that failed (for example "dial" or "invoke").
|
// Op names the operation that failed (for example "dial" or "invoke").
|
||||||
@@ -33,6 +44,45 @@ func (e *GatewayError) Unwrap() error {
|
|||||||
return e.Err
|
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
|
// CommandError reports a non-OK gateway protocol status and keeps the raw
|
||||||
// command reply when one exists.
|
// command reply when one exists.
|
||||||
type CommandError struct {
|
type CommandError struct {
|
||||||
@@ -85,8 +135,12 @@ func (e *MxAccessError) Error() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Unwrap returns the wrapped CommandError, when one is present.
|
// 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 {
|
func (e *MxAccessError) Unwrap() error {
|
||||||
if e == nil {
|
if e == nil || e.Command == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return e.Command
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,7 @@ package mxgateway
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||||
@@ -15,14 +13,6 @@ import (
|
|||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
)
|
)
|
||||||
|
|
||||||
// browseChildrenPageSize is the per-request page size used by the lazy walker.
|
|
||||||
const browseChildrenPageSize = 500
|
|
||||||
|
|
||||||
// discoverHierarchyPageSize is the per-request page size used by DiscoverHierarchy.
|
|
||||||
// Mirrors the .NET client constant so large galaxies are not silently truncated
|
|
||||||
// by the server's default page cap.
|
|
||||||
const discoverHierarchyPageSize = 5000
|
|
||||||
|
|
||||||
// RawGalaxyRepositoryClient is the generated gRPC client interface for the
|
// RawGalaxyRepositoryClient is the generated gRPC client interface for the
|
||||||
// Galaxy Repository service exposed for callers that need direct contract
|
// Galaxy Repository service exposed for callers that need direct contract
|
||||||
// access.
|
// access.
|
||||||
@@ -50,10 +40,6 @@ type (
|
|||||||
WatchDeployEventsRequest = pb.WatchDeployEventsRequest
|
WatchDeployEventsRequest = pb.WatchDeployEventsRequest
|
||||||
// DeployEvent is one Galaxy Repository deploy event.
|
// DeployEvent is one Galaxy Repository deploy event.
|
||||||
DeployEvent = pb.DeployEvent
|
DeployEvent = pb.DeployEvent
|
||||||
// BrowseChildrenRequest is the request for BrowseChildren.
|
|
||||||
BrowseChildrenRequest = pb.BrowseChildrenRequest
|
|
||||||
// BrowseChildrenReply is the reply for BrowseChildren.
|
|
||||||
BrowseChildrenReply = pb.BrowseChildrenReply
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// RawDeployEventStream is the generated WatchDeployEvents client stream.
|
// RawDeployEventStream is the generated WatchDeployEvents client stream.
|
||||||
@@ -70,39 +56,13 @@ type GalaxyClient struct {
|
|||||||
|
|
||||||
// DialGalaxy opens a gRPC connection to the gateway for the Galaxy Repository
|
// DialGalaxy opens a gRPC connection to the gateway for the Galaxy Repository
|
||||||
// service. It applies the same authentication metadata, transport security,
|
// 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) {
|
func DialGalaxy(ctx context.Context, opts Options) (*GalaxyClient, error) {
|
||||||
if opts.Endpoint == "" {
|
conn, err := dial(ctx, opts)
|
||||||
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 {
|
if err != nil {
|
||||||
return nil, err
|
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
|
return NewGalaxyClient(conn, opts), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,35 +120,16 @@ func (c *GalaxyClient) GetLastDeployTime(ctx context.Context) (time.Time, bool,
|
|||||||
|
|
||||||
// DiscoverHierarchy returns the deployed Galaxy object hierarchy with each
|
// DiscoverHierarchy returns the deployed Galaxy object hierarchy with each
|
||||||
// object's dynamic attributes. The objects are returned in the order supplied
|
// object's dynamic attributes. The objects are returned in the order supplied
|
||||||
// by the server. The call pages over the server's NextPageToken until the
|
// by the server.
|
||||||
// server signals it has no more results, matching the .NET client.
|
|
||||||
func (c *GalaxyClient) DiscoverHierarchy(ctx context.Context) ([]*GalaxyObject, error) {
|
func (c *GalaxyClient) DiscoverHierarchy(ctx context.Context) ([]*GalaxyObject, error) {
|
||||||
var objects []*GalaxyObject
|
|
||||||
pageToken := ""
|
|
||||||
seen := map[string]struct{}{}
|
|
||||||
for {
|
|
||||||
callCtx, cancel := c.callContext(ctx)
|
callCtx, cancel := c.callContext(ctx)
|
||||||
reply, err := c.raw.DiscoverHierarchy(callCtx, &pb.DiscoverHierarchyRequest{
|
defer cancel()
|
||||||
PageSize: discoverHierarchyPageSize,
|
|
||||||
PageToken: pageToken,
|
reply, err := c.raw.DiscoverHierarchy(callCtx, &pb.DiscoverHierarchyRequest{})
|
||||||
})
|
|
||||||
cancel()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, &GatewayError{Op: "galaxy discover hierarchy", Err: err}
|
return nil, &GatewayError{Op: "galaxy discover hierarchy", Err: err}
|
||||||
}
|
}
|
||||||
objects = append(objects, reply.GetObjects()...)
|
return reply.GetObjects(), nil
|
||||||
pageToken = reply.GetNextPageToken()
|
|
||||||
if pageToken == "" {
|
|
||||||
return objects, nil
|
|
||||||
}
|
|
||||||
if _, dup := seen[pageToken]; dup {
|
|
||||||
return nil, &GatewayError{
|
|
||||||
Op: "galaxy discover hierarchy",
|
|
||||||
Err: fmt.Errorf("repeated page token %q", pageToken),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
seen[pageToken] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WatchDeployEventsRaw starts the generated WatchDeployEvents stream for callers
|
// WatchDeployEventsRaw starts the generated WatchDeployEvents stream for callers
|
||||||
@@ -246,7 +187,7 @@ func (c *GalaxyClient) WatchDeployEvents(
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if recvErr == io.EOF {
|
if errors.Is(recvErr, io.EOF) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if status.Code(recvErr) == codes.Canceled || ctx.Err() != nil {
|
if status.Code(recvErr) == codes.Canceled || ctx.Err() != nil {
|
||||||
@@ -271,219 +212,6 @@ func (c *GalaxyClient) Close() error {
|
|||||||
return c.conn.Close()
|
return c.conn.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// LazyBrowseNode is one node in a lazy Galaxy hierarchy walk produced by
|
|
||||||
// (*GalaxyClient).Browse. Children are not fetched until Expand is called.
|
|
||||||
// The node is safe for concurrent use; concurrent Expand calls coalesce onto
|
|
||||||
// a single in-flight RPC and do not block snapshot accessors.
|
|
||||||
type LazyBrowseNode struct {
|
|
||||||
client *GalaxyClient
|
|
||||||
object *pb.GalaxyObject
|
|
||||||
hasChildrenHint bool
|
|
||||||
options BrowseChildrenOptions
|
|
||||||
|
|
||||||
// expandLock gates inspection and mutation of expand-coordination state
|
|
||||||
// (expanding, expandDone, expandErr). It is held only briefly; the BrowseChildren
|
|
||||||
// RPC itself runs outside this lock so concurrent readers and waiters are not blocked.
|
|
||||||
expandLock sync.Mutex
|
|
||||||
expanding bool
|
|
||||||
expandDone chan struct{}
|
|
||||||
expandErr error
|
|
||||||
|
|
||||||
// mu protects the children snapshot and isExpanded flag for concurrent
|
|
||||||
// Children() / IsExpanded() readers.
|
|
||||||
mu sync.RWMutex
|
|
||||||
children []*LazyBrowseNode
|
|
||||||
isExpanded bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// Object returns the underlying GalaxyObject describing this node.
|
|
||||||
func (n *LazyBrowseNode) Object() *pb.GalaxyObject { return n.object }
|
|
||||||
|
|
||||||
// HasChildrenHint reports the server-supplied hint on whether this node has
|
|
||||||
// matching descendants under the current filter set.
|
|
||||||
func (n *LazyBrowseNode) HasChildrenHint() bool { return n.hasChildrenHint }
|
|
||||||
|
|
||||||
// Children returns a snapshot copy of the currently-loaded child nodes. Returns
|
|
||||||
// an empty slice when Expand has not yet been called.
|
|
||||||
func (n *LazyBrowseNode) Children() []*LazyBrowseNode {
|
|
||||||
n.mu.RLock()
|
|
||||||
defer n.mu.RUnlock()
|
|
||||||
out := make([]*LazyBrowseNode, len(n.children))
|
|
||||||
copy(out, n.children)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsExpanded reports whether Expand has completed successfully on this node.
|
|
||||||
func (n *LazyBrowseNode) IsExpanded() bool {
|
|
||||||
n.mu.RLock()
|
|
||||||
defer n.mu.RUnlock()
|
|
||||||
return n.isExpanded
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expand fetches this node's direct children via BrowseChildren when they have
|
|
||||||
// not yet been loaded. Subsequent calls after a successful Expand are a no-op
|
|
||||||
// and do not issue another RPC.
|
|
||||||
//
|
|
||||||
// Expand is safe to call concurrently from multiple goroutines: callers that
|
|
||||||
// arrive while an expansion is in flight wait on the active RPC and share its
|
|
||||||
// result instead of issuing a second RPC. The RPC itself runs without holding
|
|
||||||
// the snapshot mutex, so concurrent Children() and IsExpanded() callers are
|
|
||||||
// not blocked for the duration of the network round trip.
|
|
||||||
//
|
|
||||||
// Failure semantics: a failed expansion surfaces the same error to every
|
|
||||||
// in-flight waiter, but the node is left in its pre-call state (isExpanded =
|
|
||||||
// false, no in-flight expansion). The next Expand call therefore retries with
|
|
||||||
// a fresh RPC; failures are not sticky.
|
|
||||||
func (n *LazyBrowseNode) Expand(ctx context.Context) error {
|
|
||||||
// Fast path: already expanded.
|
|
||||||
n.mu.RLock()
|
|
||||||
if n.isExpanded {
|
|
||||||
n.mu.RUnlock()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
n.mu.RUnlock()
|
|
||||||
|
|
||||||
// Either start a new expansion or wait on an existing one.
|
|
||||||
n.expandLock.Lock()
|
|
||||||
n.mu.RLock()
|
|
||||||
alreadyExpanded := n.isExpanded
|
|
||||||
n.mu.RUnlock()
|
|
||||||
if alreadyExpanded {
|
|
||||||
n.expandLock.Unlock()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if n.expanding {
|
|
||||||
done := n.expandDone
|
|
||||||
n.expandLock.Unlock()
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
n.expandLock.Lock()
|
|
||||||
err := n.expandErr
|
|
||||||
n.expandLock.Unlock()
|
|
||||||
return err
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
n.expanding = true
|
|
||||||
n.expandDone = make(chan struct{})
|
|
||||||
done := n.expandDone
|
|
||||||
n.expandLock.Unlock()
|
|
||||||
|
|
||||||
// Issue the RPC outside any lock so concurrent readers/waiters are not blocked.
|
|
||||||
parentID := n.object.GetGobjectId()
|
|
||||||
children, err := n.client.browseChildrenInner(ctx, &parentID, n.options)
|
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
n.mu.Lock()
|
|
||||||
n.children = children
|
|
||||||
n.isExpanded = true
|
|
||||||
n.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Publish result to waiters and clear the in-flight marker so a failed
|
|
||||||
// expansion can be retried by the next Expand call.
|
|
||||||
n.expandLock.Lock()
|
|
||||||
n.expandErr = err
|
|
||||||
n.expanding = false
|
|
||||||
close(done)
|
|
||||||
n.expandLock.Unlock()
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Browse returns the root nodes of the Galaxy hierarchy. The returned nodes
|
|
||||||
// have only their server-supplied hints populated; call Expand on each node to
|
|
||||||
// fetch its direct children. When opts is nil the server defaults apply.
|
|
||||||
func (c *GalaxyClient) Browse(ctx context.Context, opts *BrowseChildrenOptions) ([]*LazyBrowseNode, error) {
|
|
||||||
effective := BrowseChildrenOptions{}
|
|
||||||
if opts != nil {
|
|
||||||
effective = *opts
|
|
||||||
}
|
|
||||||
return c.browseChildrenInner(ctx, nil, effective)
|
|
||||||
}
|
|
||||||
|
|
||||||
// BrowseChildrenRaw issues a single BrowseChildren RPC and returns the raw
|
|
||||||
// reply for callers that need direct page-token control. Transport-level
|
|
||||||
// failures are wrapped in *GatewayError to match the rest of the client.
|
|
||||||
func (c *GalaxyClient) BrowseChildrenRaw(ctx context.Context, req *pb.BrowseChildrenRequest) (*pb.BrowseChildrenReply, error) {
|
|
||||||
callCtx, cancel := c.callContext(ctx)
|
|
||||||
defer cancel()
|
|
||||||
reply, err := c.raw.BrowseChildren(callCtx, req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, &GatewayError{Op: "galaxy browse children", Err: err}
|
|
||||||
}
|
|
||||||
return reply, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *GalaxyClient) browseChildrenInner(
|
|
||||||
ctx context.Context,
|
|
||||||
parentGobjectID *int32,
|
|
||||||
opts BrowseChildrenOptions,
|
|
||||||
) ([]*LazyBrowseNode, error) {
|
|
||||||
var nodes []*LazyBrowseNode
|
|
||||||
pageToken := ""
|
|
||||||
seen := map[string]struct{}{}
|
|
||||||
for {
|
|
||||||
req := &pb.BrowseChildrenRequest{
|
|
||||||
PageSize: browseChildrenPageSize,
|
|
||||||
PageToken: pageToken,
|
|
||||||
CategoryIds: opts.CategoryIds,
|
|
||||||
TemplateChainContains: opts.TemplateChainContains,
|
|
||||||
TagNameGlob: opts.TagNameGlob,
|
|
||||||
AlarmBearingOnly: opts.AlarmBearingOnly,
|
|
||||||
HistorizedOnly: opts.HistorizedOnly,
|
|
||||||
}
|
|
||||||
if parentGobjectID != nil {
|
|
||||||
req.Parent = &pb.BrowseChildrenRequest_ParentGobjectId{ParentGobjectId: *parentGobjectID}
|
|
||||||
}
|
|
||||||
if opts.IncludeAttributes != nil {
|
|
||||||
req.IncludeAttributes = opts.IncludeAttributes
|
|
||||||
}
|
|
||||||
|
|
||||||
reply, err := c.BrowseChildrenRaw(ctx, req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, child := range reply.GetChildren() {
|
|
||||||
hasChildren := reply.GetChildHasChildren()
|
|
||||||
hint := i < len(hasChildren) && hasChildren[i]
|
|
||||||
nodes = append(nodes, &LazyBrowseNode{
|
|
||||||
client: c,
|
|
||||||
object: child,
|
|
||||||
hasChildrenHint: hint,
|
|
||||||
options: opts,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pageToken = reply.GetNextPageToken()
|
|
||||||
if pageToken == "" {
|
|
||||||
return nodes, nil
|
|
||||||
}
|
|
||||||
if _, dup := seen[pageToken]; dup {
|
|
||||||
return nil, &GatewayError{
|
|
||||||
Op: "galaxy browse children",
|
|
||||||
Err: fmt.Errorf("repeated page token %q", pageToken),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
seen[pageToken] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *GalaxyClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
func (c *GalaxyClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||||
timeout := c.opts.CallTimeout
|
return callContext(ctx, 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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,11 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
"sync"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/codes"
|
|
||||||
"google.golang.org/grpc/status"
|
|
||||||
"google.golang.org/grpc/test/bufconn"
|
"google.golang.org/grpc/test/bufconn"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
)
|
)
|
||||||
@@ -147,47 +144,6 @@ func TestGalaxyDiscoverHierarchyReturnsObjects(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGalaxyDiscoverHierarchyPaginatesAcrossMultiplePages(t *testing.T) {
|
|
||||||
page1 := &pb.DiscoverHierarchyReply{
|
|
||||||
Objects: []*pb.GalaxyObject{
|
|
||||||
{GobjectId: 1, TagName: "A"},
|
|
||||||
{GobjectId: 2, TagName: "B"},
|
|
||||||
},
|
|
||||||
NextPageToken: "page-2",
|
|
||||||
TotalObjectCount: 3,
|
|
||||||
}
|
|
||||||
page2 := &pb.DiscoverHierarchyReply{
|
|
||||||
Objects: []*pb.GalaxyObject{
|
|
||||||
{GobjectId: 3, TagName: "C"},
|
|
||||||
},
|
|
||||||
TotalObjectCount: 3,
|
|
||||||
}
|
|
||||||
fake := &fakeGalaxyServer{
|
|
||||||
discoverHierarchyReplies: []*pb.DiscoverHierarchyReply{page1, page2},
|
|
||||||
}
|
|
||||||
client, cleanup := newGalaxyBufconnClient(t, fake)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
objs, err := client.DiscoverHierarchy(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("DiscoverHierarchy: %v", err)
|
|
||||||
}
|
|
||||||
if got, want := len(objs), 3; got != want {
|
|
||||||
t.Fatalf("len(objs) = %d, want %d", got, want)
|
|
||||||
}
|
|
||||||
if len(fake.discoverHierarchyCalls) != 2 {
|
|
||||||
t.Fatalf("expected 2 RPC calls, got %d", len(fake.discoverHierarchyCalls))
|
|
||||||
}
|
|
||||||
if fake.discoverHierarchyCalls[0].GetPageSize() != discoverHierarchyPageSize {
|
|
||||||
t.Fatalf("first call PageSize = %d, want %d",
|
|
||||||
fake.discoverHierarchyCalls[0].GetPageSize(), discoverHierarchyPageSize)
|
|
||||||
}
|
|
||||||
if fake.discoverHierarchyCalls[1].GetPageToken() != "page-2" {
|
|
||||||
t.Fatalf("second call page token = %q, want %q",
|
|
||||||
fake.discoverHierarchyCalls[1].GetPageToken(), "page-2")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGalaxyDialReturnsGatewayErrorOnRpcFailure(t *testing.T) {
|
func TestGalaxyDialReturnsGatewayErrorOnRpcFailure(t *testing.T) {
|
||||||
fake := &fakeGalaxyServer{failTest: true}
|
fake := &fakeGalaxyServer{failTest: true}
|
||||||
client, cleanup := newGalaxyBufconnClient(t, fake)
|
client, cleanup := newGalaxyBufconnClient(t, fake)
|
||||||
@@ -392,8 +348,10 @@ func newGalaxyBufconnClient(t *testing.T, fake *fakeGalaxyServer) (*GalaxyClient
|
|||||||
dialer := func(ctx context.Context, _ string) (net.Conn, error) {
|
dialer := func(ctx context.Context, _ string) (net.Conn, error) {
|
||||||
return listener.DialContext(ctx)
|
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{
|
client, err := DialGalaxy(context.Background(), Options{
|
||||||
Endpoint: "bufnet",
|
Endpoint: "passthrough:///bufnet",
|
||||||
APIKey: "test-api-key",
|
APIKey: "test-api-key",
|
||||||
Plaintext: true,
|
Plaintext: true,
|
||||||
DialOptions: []grpc.DialOption{
|
DialOptions: []grpc.DialOption{
|
||||||
@@ -419,15 +377,9 @@ type fakeGalaxyServer struct {
|
|||||||
failTest bool
|
failTest bool
|
||||||
deployReply *pb.GetLastDeployTimeReply
|
deployReply *pb.GetLastDeployTimeReply
|
||||||
discoverReply *pb.DiscoverHierarchyReply
|
discoverReply *pb.DiscoverHierarchyReply
|
||||||
discoverHierarchyCalls []*pb.DiscoverHierarchyRequest
|
|
||||||
discoverHierarchyReplies []*pb.DiscoverHierarchyReply
|
|
||||||
watchEvents []*pb.DeployEvent
|
watchEvents []*pb.DeployEvent
|
||||||
watchRequest *pb.WatchDeployEventsRequest
|
watchRequest *pb.WatchDeployEventsRequest
|
||||||
watchSendInterval time.Duration
|
|
||||||
watchHoldOpen bool
|
watchHoldOpen bool
|
||||||
browseChildrenCalls []*pb.BrowseChildrenRequest
|
|
||||||
browseChildrenReplies []*pb.BrowseChildrenReply
|
|
||||||
browseChildrenError error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *fakeGalaxyServer) TestConnection(ctx context.Context, req *pb.TestConnectionRequest) (*pb.TestConnectionReply, error) {
|
func (s *fakeGalaxyServer) TestConnection(ctx context.Context, req *pb.TestConnectionRequest) (*pb.TestConnectionReply, error) {
|
||||||
@@ -449,12 +401,6 @@ func (s *fakeGalaxyServer) GetLastDeployTime(ctx context.Context, req *pb.GetLas
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *fakeGalaxyServer) DiscoverHierarchy(ctx context.Context, req *pb.DiscoverHierarchyRequest) (*pb.DiscoverHierarchyReply, error) {
|
func (s *fakeGalaxyServer) DiscoverHierarchy(ctx context.Context, req *pb.DiscoverHierarchyRequest) (*pb.DiscoverHierarchyReply, error) {
|
||||||
s.discoverHierarchyCalls = append(s.discoverHierarchyCalls, req)
|
|
||||||
if len(s.discoverHierarchyReplies) > 0 {
|
|
||||||
reply := s.discoverHierarchyReplies[0]
|
|
||||||
s.discoverHierarchyReplies = s.discoverHierarchyReplies[1:]
|
|
||||||
return reply, nil
|
|
||||||
}
|
|
||||||
if s.discoverReply != nil {
|
if s.discoverReply != nil {
|
||||||
return s.discoverReply, nil
|
return s.discoverReply, nil
|
||||||
}
|
}
|
||||||
@@ -467,398 +413,9 @@ func (s *fakeGalaxyServer) WatchDeployEvents(req *pb.WatchDeployEventsRequest, s
|
|||||||
if err := stream.Send(event); err != nil {
|
if err := stream.Send(event); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if s.watchSendInterval > 0 {
|
|
||||||
select {
|
|
||||||
case <-time.After(s.watchSendInterval):
|
|
||||||
case <-stream.Context().Done():
|
|
||||||
return stream.Context().Err()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if s.watchHoldOpen {
|
if s.watchHoldOpen {
|
||||||
<-stream.Context().Done()
|
<-stream.Context().Done()
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *fakeGalaxyServer) BrowseChildren(ctx context.Context, req *pb.BrowseChildrenRequest) (*pb.BrowseChildrenReply, error) {
|
|
||||||
s.browseChildrenCalls = append(s.browseChildrenCalls, req)
|
|
||||||
if s.browseChildrenError != nil {
|
|
||||||
err := s.browseChildrenError
|
|
||||||
s.browseChildrenError = nil
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(s.browseChildrenReplies) == 0 {
|
|
||||||
return &pb.BrowseChildrenReply{}, nil
|
|
||||||
}
|
|
||||||
reply := s.browseChildrenReplies[0]
|
|
||||||
s.browseChildrenReplies = s.browseChildrenReplies[1:]
|
|
||||||
return reply, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func obj(id int32, tag string, isArea bool) *pb.GalaxyObject {
|
|
||||||
return &pb.GalaxyObject{
|
|
||||||
GobjectId: id,
|
|
||||||
TagName: tag,
|
|
||||||
BrowseName: tag,
|
|
||||||
IsArea: isArea,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildBrowseReply(children []*pb.GalaxyObject, hasChildren []bool, seq uint64) *pb.BrowseChildrenReply {
|
|
||||||
return &pb.BrowseChildrenReply{
|
|
||||||
TotalChildCount: int32(len(children)),
|
|
||||||
CacheSequence: seq,
|
|
||||||
Children: children,
|
|
||||||
ChildHasChildren: hasChildren,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGalaxyBrowseNoParentReturnsRoots(t *testing.T) {
|
|
||||||
fake := &fakeGalaxyServer{
|
|
||||||
browseChildrenReplies: []*pb.BrowseChildrenReply{
|
|
||||||
buildBrowseReply(
|
|
||||||
[]*pb.GalaxyObject{obj(1, "Plant", true), obj(99, "Other", false)},
|
|
||||||
[]bool{true, false},
|
|
||||||
7,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
client, cleanup := newGalaxyBufconnClient(t, fake)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
roots, err := client.Browse(context.Background(), nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Browse: %v", err)
|
|
||||||
}
|
|
||||||
if got, want := len(roots), 2; got != want {
|
|
||||||
t.Fatalf("len(roots) = %d, want %d", got, want)
|
|
||||||
}
|
|
||||||
if roots[0].Object().GetTagName() != "Plant" {
|
|
||||||
t.Fatalf("roots[0].TagName = %q", roots[0].Object().GetTagName())
|
|
||||||
}
|
|
||||||
if !roots[0].HasChildrenHint() {
|
|
||||||
t.Fatal("roots[0].HasChildrenHint = false, want true")
|
|
||||||
}
|
|
||||||
if roots[0].IsExpanded() {
|
|
||||||
t.Fatal("roots[0].IsExpanded = true, want false")
|
|
||||||
}
|
|
||||||
if roots[1].HasChildrenHint() {
|
|
||||||
t.Fatal("roots[1].HasChildrenHint = true, want false")
|
|
||||||
}
|
|
||||||
if len(fake.browseChildrenCalls) != 1 {
|
|
||||||
t.Fatalf("BrowseChildren calls = %d, want 1", len(fake.browseChildrenCalls))
|
|
||||||
}
|
|
||||||
if fake.browseChildrenCalls[0].GetParent() != nil {
|
|
||||||
t.Fatalf("root browse should not set Parent oneof, got %T", fake.browseChildrenCalls[0].GetParent())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGalaxyBrowseExpandPopulatesChildrenAndMarksExpanded(t *testing.T) {
|
|
||||||
fake := &fakeGalaxyServer{
|
|
||||||
browseChildrenReplies: []*pb.BrowseChildrenReply{
|
|
||||||
buildBrowseReply(
|
|
||||||
[]*pb.GalaxyObject{obj(1, "Plant", true)},
|
|
||||||
[]bool{true},
|
|
||||||
1,
|
|
||||||
),
|
|
||||||
buildBrowseReply(
|
|
||||||
[]*pb.GalaxyObject{obj(10, "Area1", true), obj(11, "Tank1", false)},
|
|
||||||
[]bool{true, false},
|
|
||||||
1,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
client, cleanup := newGalaxyBufconnClient(t, fake)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
roots, err := client.Browse(context.Background(), nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Browse: %v", err)
|
|
||||||
}
|
|
||||||
if len(roots) != 1 {
|
|
||||||
t.Fatalf("len(roots) = %d, want 1", len(roots))
|
|
||||||
}
|
|
||||||
plant := roots[0]
|
|
||||||
if plant.IsExpanded() {
|
|
||||||
t.Fatal("plant.IsExpanded = true before Expand, want false")
|
|
||||||
}
|
|
||||||
if err := plant.Expand(context.Background()); err != nil {
|
|
||||||
t.Fatalf("Expand: %v", err)
|
|
||||||
}
|
|
||||||
if !plant.IsExpanded() {
|
|
||||||
t.Fatal("plant.IsExpanded = false after Expand, want true")
|
|
||||||
}
|
|
||||||
children := plant.Children()
|
|
||||||
if len(children) != 2 {
|
|
||||||
t.Fatalf("len(children) = %d, want 2", len(children))
|
|
||||||
}
|
|
||||||
if children[0].Object().GetTagName() != "Area1" {
|
|
||||||
t.Fatalf("children[0].TagName = %q, want Area1", children[0].Object().GetTagName())
|
|
||||||
}
|
|
||||||
if !children[0].HasChildrenHint() {
|
|
||||||
t.Fatal("children[0].HasChildrenHint = false, want true")
|
|
||||||
}
|
|
||||||
if children[1].HasChildrenHint() {
|
|
||||||
t.Fatal("children[1].HasChildrenHint = true, want false")
|
|
||||||
}
|
|
||||||
if len(fake.browseChildrenCalls) != 2 {
|
|
||||||
t.Fatalf("BrowseChildren calls = %d, want 2", len(fake.browseChildrenCalls))
|
|
||||||
}
|
|
||||||
parent := fake.browseChildrenCalls[1].GetParent()
|
|
||||||
parentGobj, ok := parent.(*pb.BrowseChildrenRequest_ParentGobjectId)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("Parent oneof = %T, want *BrowseChildrenRequest_ParentGobjectId", parent)
|
|
||||||
}
|
|
||||||
if parentGobj.ParentGobjectId != 1 {
|
|
||||||
t.Fatalf("ParentGobjectId = %d, want 1", parentGobj.ParentGobjectId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGalaxyBrowseExpandIdempotentNoSecondRpc(t *testing.T) {
|
|
||||||
fake := &fakeGalaxyServer{
|
|
||||||
browseChildrenReplies: []*pb.BrowseChildrenReply{
|
|
||||||
buildBrowseReply(
|
|
||||||
[]*pb.GalaxyObject{obj(1, "Plant", true)},
|
|
||||||
[]bool{true},
|
|
||||||
1,
|
|
||||||
),
|
|
||||||
buildBrowseReply(
|
|
||||||
[]*pb.GalaxyObject{obj(10, "Area1", true)},
|
|
||||||
[]bool{false},
|
|
||||||
1,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
client, cleanup := newGalaxyBufconnClient(t, fake)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
roots, err := client.Browse(context.Background(), nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Browse: %v", err)
|
|
||||||
}
|
|
||||||
plant := roots[0]
|
|
||||||
if err := plant.Expand(context.Background()); err != nil {
|
|
||||||
t.Fatalf("Expand #1: %v", err)
|
|
||||||
}
|
|
||||||
callsAfterFirst := len(fake.browseChildrenCalls)
|
|
||||||
if callsAfterFirst != 2 {
|
|
||||||
t.Fatalf("BrowseChildren calls after first Expand = %d, want 2", callsAfterFirst)
|
|
||||||
}
|
|
||||||
if err := plant.Expand(context.Background()); err != nil {
|
|
||||||
t.Fatalf("Expand #2: %v", err)
|
|
||||||
}
|
|
||||||
if got := len(fake.browseChildrenCalls); got != callsAfterFirst {
|
|
||||||
t.Fatalf("BrowseChildren calls after second Expand = %d, want %d (no extra RPC)", got, callsAfterFirst)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGalaxyBrowseExpandUnknownParentReturnsNotFoundError(t *testing.T) {
|
|
||||||
fake := &fakeGalaxyServer{
|
|
||||||
browseChildrenReplies: []*pb.BrowseChildrenReply{
|
|
||||||
buildBrowseReply(
|
|
||||||
[]*pb.GalaxyObject{obj(1, "Plant", true)},
|
|
||||||
[]bool{true},
|
|
||||||
1,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
browseChildrenError: status.Error(codes.NotFound, "parent not found"),
|
|
||||||
}
|
|
||||||
// The first Browse() consumes the first reply; the next call (Expand) will
|
|
||||||
// then hit browseChildrenError. We need the error to fire only on the second
|
|
||||||
// call, so seed the reply first and let the call sequence consume them in
|
|
||||||
// order. Because BrowseChildren in the fake consumes browseChildrenError
|
|
||||||
// before falling through to replies, swap the strategy: keep the root reply
|
|
||||||
// but have BrowseChildren return the error on the second call. We do this by
|
|
||||||
// emptying the reply list after the first Browse.
|
|
||||||
client, cleanup := newGalaxyBufconnClient(t, fake)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
// First call returns the error (because browseChildrenError takes precedence).
|
|
||||||
// To avoid that, clear it for the root call by performing a manual setup: we
|
|
||||||
// pre-stage replies first, then set the error after the first call. Easiest:
|
|
||||||
// pre-Browse() with error=nil, then set error before Expand.
|
|
||||||
fake.browseChildrenError = nil
|
|
||||||
roots, err := client.Browse(context.Background(), nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Browse: %v", err)
|
|
||||||
}
|
|
||||||
if len(roots) != 1 {
|
|
||||||
t.Fatalf("len(roots) = %d, want 1", len(roots))
|
|
||||||
}
|
|
||||||
fake.browseChildrenError = status.Error(codes.NotFound, "parent not found")
|
|
||||||
|
|
||||||
err = roots[0].Expand(context.Background())
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("Expand: error = nil, want NotFound")
|
|
||||||
}
|
|
||||||
if status.Code(err) != codes.NotFound {
|
|
||||||
t.Fatalf("status.Code = %s, want NotFound", status.Code(err))
|
|
||||||
}
|
|
||||||
if roots[0].IsExpanded() {
|
|
||||||
t.Fatal("roots[0].IsExpanded = true after failed Expand, want false")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGalaxyBrowseExpandMultiPageGathersAllPages(t *testing.T) {
|
|
||||||
firstPage := buildBrowseReply(
|
|
||||||
[]*pb.GalaxyObject{obj(1, "Plant", true)},
|
|
||||||
[]bool{true},
|
|
||||||
7,
|
|
||||||
)
|
|
||||||
|
|
||||||
pageA := buildBrowseReply(
|
|
||||||
[]*pb.GalaxyObject{obj(10, "Child1", false), obj(11, "Child2", false)},
|
|
||||||
[]bool{false, false},
|
|
||||||
7,
|
|
||||||
)
|
|
||||||
pageA.NextPageToken = "7:abc:2"
|
|
||||||
pageB := buildBrowseReply(
|
|
||||||
[]*pb.GalaxyObject{obj(12, "Child3", false)},
|
|
||||||
[]bool{false},
|
|
||||||
7,
|
|
||||||
)
|
|
||||||
|
|
||||||
fake := &fakeGalaxyServer{
|
|
||||||
browseChildrenReplies: []*pb.BrowseChildrenReply{firstPage, pageA, pageB},
|
|
||||||
}
|
|
||||||
client, cleanup := newGalaxyBufconnClient(t, fake)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
roots, err := client.Browse(context.Background(), nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Browse: %v", err)
|
|
||||||
}
|
|
||||||
if err := roots[0].Expand(context.Background()); err != nil {
|
|
||||||
t.Fatalf("Expand: %v", err)
|
|
||||||
}
|
|
||||||
children := roots[0].Children()
|
|
||||||
if len(children) != 3 {
|
|
||||||
t.Fatalf("len(children) = %d, want 3", len(children))
|
|
||||||
}
|
|
||||||
if len(fake.browseChildrenCalls) != 3 {
|
|
||||||
t.Fatalf("BrowseChildren calls = %d, want 3", len(fake.browseChildrenCalls))
|
|
||||||
}
|
|
||||||
if got := fake.browseChildrenCalls[2].GetPageToken(); got != "7:abc:2" {
|
|
||||||
t.Fatalf("third call PageToken = %q, want %q", got, "7:abc:2")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGalaxyBrowseWithFilterForwardsToRequest(t *testing.T) {
|
|
||||||
fake := &fakeGalaxyServer{
|
|
||||||
browseChildrenReplies: []*pb.BrowseChildrenReply{
|
|
||||||
buildBrowseReply(nil, nil, 1),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
client, cleanup := newGalaxyBufconnClient(t, fake)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
include := true
|
|
||||||
opts := &BrowseChildrenOptions{
|
|
||||||
CategoryIds: []int32{7, 9},
|
|
||||||
TemplateChainContains: []string{"$AppObject"},
|
|
||||||
TagNameGlob: "Tank*",
|
|
||||||
IncludeAttributes: &include,
|
|
||||||
AlarmBearingOnly: true,
|
|
||||||
HistorizedOnly: true,
|
|
||||||
}
|
|
||||||
if _, err := client.Browse(context.Background(), opts); err != nil {
|
|
||||||
t.Fatalf("Browse: %v", err)
|
|
||||||
}
|
|
||||||
if len(fake.browseChildrenCalls) != 1 {
|
|
||||||
t.Fatalf("BrowseChildren calls = %d, want 1", len(fake.browseChildrenCalls))
|
|
||||||
}
|
|
||||||
got := fake.browseChildrenCalls[0]
|
|
||||||
if want := []int32{7, 9}; len(got.GetCategoryIds()) != 2 || got.GetCategoryIds()[0] != want[0] || got.GetCategoryIds()[1] != want[1] {
|
|
||||||
t.Fatalf("CategoryIds = %v, want %v", got.GetCategoryIds(), want)
|
|
||||||
}
|
|
||||||
if want := []string{"$AppObject"}; len(got.GetTemplateChainContains()) != 1 || got.GetTemplateChainContains()[0] != want[0] {
|
|
||||||
t.Fatalf("TemplateChainContains = %v, want %v", got.GetTemplateChainContains(), want)
|
|
||||||
}
|
|
||||||
if got.GetTagNameGlob() != "Tank*" {
|
|
||||||
t.Fatalf("TagNameGlob = %q, want %q", got.GetTagNameGlob(), "Tank*")
|
|
||||||
}
|
|
||||||
if !got.GetIncludeAttributes() {
|
|
||||||
t.Fatal("IncludeAttributes = false, want true")
|
|
||||||
}
|
|
||||||
if !got.GetAlarmBearingOnly() {
|
|
||||||
t.Fatal("AlarmBearingOnly = false, want true")
|
|
||||||
}
|
|
||||||
if !got.GetHistorizedOnly() {
|
|
||||||
t.Fatal("HistorizedOnly = false, want true")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGalaxyBrowseExpandConcurrentCallersOnlyFireOneRpc(t *testing.T) {
|
|
||||||
fake := &fakeGalaxyServer{
|
|
||||||
browseChildrenReplies: []*pb.BrowseChildrenReply{
|
|
||||||
// roots
|
|
||||||
buildBrowseReply([]*pb.GalaxyObject{obj(1, "Plant", true)}, []bool{true}, 7),
|
|
||||||
// one expand: one child
|
|
||||||
buildBrowseReply([]*pb.GalaxyObject{obj(2, "Mixer", false)}, []bool{false}, 7),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
client, cleanup := newGalaxyBufconnClient(t, fake)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
roots, err := client.Browse(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Browse: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
errs := make(chan error, 10)
|
|
||||||
for i := 0; i < 10; i++ {
|
|
||||||
wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
errs <- roots[0].Expand(ctx)
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
close(errs)
|
|
||||||
for err := range errs {
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("concurrent Expand: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !roots[0].IsExpanded() {
|
|
||||||
t.Fatal("IsExpanded() = false after 10 concurrent expands")
|
|
||||||
}
|
|
||||||
if got, want := len(roots[0].Children()), 1; got != want {
|
|
||||||
t.Fatalf("len(children) = %d, want %d", got, want)
|
|
||||||
}
|
|
||||||
// 1 roots fetch + exactly 1 expand fetch.
|
|
||||||
if got, want := len(fake.browseChildrenCalls), 2; got != want {
|
|
||||||
t.Fatalf("RPC count = %d, want %d", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGalaxyBrowseChildrenRejectsRepeatedPageToken(t *testing.T) {
|
|
||||||
// Build a reply that carries a non-empty NextPageToken so browseChildrenInner
|
|
||||||
// will request a second page. Queue the same reply twice so the second response
|
|
||||||
// returns the same page token, triggering the duplicate-token guard.
|
|
||||||
page := buildBrowseReply(
|
|
||||||
[]*pb.GalaxyObject{obj(1, "Plant", true)},
|
|
||||||
[]bool{true},
|
|
||||||
1,
|
|
||||||
)
|
|
||||||
page.NextPageToken = "1:abc:1"
|
|
||||||
|
|
||||||
fake := &fakeGalaxyServer{
|
|
||||||
browseChildrenReplies: []*pb.BrowseChildrenReply{page, page},
|
|
||||||
}
|
|
||||||
client, cleanup := newGalaxyBufconnClient(t, fake)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
_, err := client.Browse(context.Background(), nil)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("Browse: error = nil, want repeated-page-token error")
|
|
||||||
}
|
|
||||||
var gwErr *GatewayError
|
|
||||||
if !errors.As(err, &gwErr) {
|
|
||||||
t.Fatalf("error type = %T, want *GatewayError; err = %v", err, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -34,32 +34,6 @@ type Options struct {
|
|||||||
TransportCredentials credentials.TransportCredentials
|
TransportCredentials credentials.TransportCredentials
|
||||||
// DialOptions are appended to the gRPC dial options after the defaults.
|
// DialOptions are appended to the gRPC dial options after the defaults.
|
||||||
DialOptions []grpc.DialOption
|
DialOptions []grpc.DialOption
|
||||||
// RequireCertificateValidation forces TLS certificate verification even when
|
|
||||||
// no CACertFile is pinned. Default false: the gateway's self-signed cert is
|
|
||||||
// accepted without verification (internal-tool posture).
|
|
||||||
RequireCertificateValidation bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// BrowseChildrenOptions configures lazy Galaxy hierarchy walks performed by
|
|
||||||
// (*GalaxyClient).Browse and (*LazyBrowseNode).Expand. All fields are optional;
|
|
||||||
// the zero value matches the dashboard default (no filters, all attributes per
|
|
||||||
// the server default).
|
|
||||||
type BrowseChildrenOptions struct {
|
|
||||||
// CategoryIds restricts results to the listed Galaxy category ids when set.
|
|
||||||
CategoryIds []int32
|
|
||||||
// TemplateChainContains restricts results to objects whose template chain
|
|
||||||
// contains any of the listed template tag names.
|
|
||||||
TemplateChainContains []string
|
|
||||||
// TagNameGlob restricts results to objects whose tag name matches the glob
|
|
||||||
// pattern when non-empty.
|
|
||||||
TagNameGlob string
|
|
||||||
// IncludeAttributes overrides the server default for attribute inclusion when
|
|
||||||
// non-nil. The pointer form mirrors the proto's optional field.
|
|
||||||
IncludeAttributes *bool
|
|
||||||
// AlarmBearingOnly limits results to alarm-bearing objects when true.
|
|
||||||
AlarmBearingOnly bool
|
|
||||||
// HistorizedOnly limits results to historized objects when true.
|
|
||||||
HistorizedOnly bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RedactedAPIKey returns a display-safe representation of the configured API
|
// RedactedAPIKey returns a display-safe representation of the configured API
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||||
@@ -392,9 +393,6 @@ func (s *Session) UnsubscribeBulk(ctx context.Context, serverHandle int32, itemH
|
|||||||
// Per-entry failures appear as BulkWriteResult entries with WasSuccessful=false; the call
|
// 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
|
// never returns an error for per-entry MXAccess failures (it returns an error only for
|
||||||
// protocol-level failures or transport errors).
|
// protocol-level failures or transport errors).
|
||||||
//
|
|
||||||
// A non-nil but empty entries slice is treated as a no-op and returns an empty result
|
|
||||||
// without a wire round-trip; pass nil to surface a clear "entries are required" error.
|
|
||||||
func (s *Session) WriteBulk(ctx context.Context, serverHandle int32, entries []*WriteBulkEntry) ([]*BulkWriteResult, error) {
|
func (s *Session) WriteBulk(ctx context.Context, serverHandle int32, entries []*WriteBulkEntry) ([]*BulkWriteResult, error) {
|
||||||
if entries == nil {
|
if entries == nil {
|
||||||
return nil, errors.New("mxgateway: write bulk entries are required")
|
return nil, errors.New("mxgateway: write bulk entries are required")
|
||||||
@@ -402,9 +400,6 @@ func (s *Session) WriteBulk(ctx context.Context, serverHandle int32, entries []*
|
|||||||
if err := ensureBulkSize("write bulk entries", len(entries)); err != nil {
|
if err := ensureBulkSize("write bulk entries", len(entries)); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(entries) == 0 {
|
|
||||||
return []*BulkWriteResult{}, nil
|
|
||||||
}
|
|
||||||
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
|
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
|
||||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_BULK,
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_BULK,
|
||||||
Payload: &pb.MxCommand_WriteBulk{
|
Payload: &pb.MxCommand_WriteBulk{
|
||||||
@@ -421,9 +416,6 @@ func (s *Session) WriteBulk(ctx context.Context, serverHandle int32, entries []*
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Write2Bulk invokes MXAccess Write2 (timestamped) for each entry inside one gateway command.
|
// Write2Bulk invokes MXAccess Write2 (timestamped) for each entry inside one gateway command.
|
||||||
//
|
|
||||||
// A non-nil but empty entries slice is treated as a no-op and returns an empty result
|
|
||||||
// without a wire round-trip; pass nil to surface a clear "entries are required" error.
|
|
||||||
func (s *Session) Write2Bulk(ctx context.Context, serverHandle int32, entries []*Write2BulkEntry) ([]*BulkWriteResult, error) {
|
func (s *Session) Write2Bulk(ctx context.Context, serverHandle int32, entries []*Write2BulkEntry) ([]*BulkWriteResult, error) {
|
||||||
if entries == nil {
|
if entries == nil {
|
||||||
return nil, errors.New("mxgateway: write2 bulk entries are required")
|
return nil, errors.New("mxgateway: write2 bulk entries are required")
|
||||||
@@ -431,9 +423,6 @@ func (s *Session) Write2Bulk(ctx context.Context, serverHandle int32, entries []
|
|||||||
if err := ensureBulkSize("write2 bulk entries", len(entries)); err != nil {
|
if err := ensureBulkSize("write2 bulk entries", len(entries)); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(entries) == 0 {
|
|
||||||
return []*BulkWriteResult{}, nil
|
|
||||||
}
|
|
||||||
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
|
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
|
||||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE2_BULK,
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE2_BULK,
|
||||||
Payload: &pb.MxCommand_Write2Bulk{
|
Payload: &pb.MxCommand_Write2Bulk{
|
||||||
@@ -451,9 +440,6 @@ func (s *Session) Write2Bulk(ctx context.Context, serverHandle int32, entries []
|
|||||||
|
|
||||||
// WriteSecuredBulk invokes MXAccess WriteSecured for each entry. Credential-sensitive
|
// WriteSecuredBulk invokes MXAccess WriteSecured for each entry. Credential-sensitive
|
||||||
// values must not be logged by callers; mirrors the single-item WriteSecured contract.
|
// values must not be logged by callers; mirrors the single-item WriteSecured contract.
|
||||||
//
|
|
||||||
// A non-nil but empty entries slice is treated as a no-op and returns an empty result
|
|
||||||
// without a wire round-trip; pass nil to surface a clear "entries are required" error.
|
|
||||||
func (s *Session) WriteSecuredBulk(ctx context.Context, serverHandle int32, entries []*WriteSecuredBulkEntry) ([]*BulkWriteResult, error) {
|
func (s *Session) WriteSecuredBulk(ctx context.Context, serverHandle int32, entries []*WriteSecuredBulkEntry) ([]*BulkWriteResult, error) {
|
||||||
if entries == nil {
|
if entries == nil {
|
||||||
return nil, errors.New("mxgateway: write-secured bulk entries are required")
|
return nil, errors.New("mxgateway: write-secured bulk entries are required")
|
||||||
@@ -461,9 +447,6 @@ func (s *Session) WriteSecuredBulk(ctx context.Context, serverHandle int32, entr
|
|||||||
if err := ensureBulkSize("write-secured bulk entries", len(entries)); err != nil {
|
if err := ensureBulkSize("write-secured bulk entries", len(entries)); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(entries) == 0 {
|
|
||||||
return []*BulkWriteResult{}, nil
|
|
||||||
}
|
|
||||||
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
|
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
|
||||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED_BULK,
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED_BULK,
|
||||||
Payload: &pb.MxCommand_WriteSecuredBulk{
|
Payload: &pb.MxCommand_WriteSecuredBulk{
|
||||||
@@ -480,9 +463,6 @@ func (s *Session) WriteSecuredBulk(ctx context.Context, serverHandle int32, entr
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WriteSecured2Bulk invokes MXAccess WriteSecured2 (timestamped) for each entry.
|
// WriteSecured2Bulk invokes MXAccess WriteSecured2 (timestamped) for each entry.
|
||||||
//
|
|
||||||
// A non-nil but empty entries slice is treated as a no-op and returns an empty result
|
|
||||||
// without a wire round-trip; pass nil to surface a clear "entries are required" error.
|
|
||||||
func (s *Session) WriteSecured2Bulk(ctx context.Context, serverHandle int32, entries []*WriteSecured2BulkEntry) ([]*BulkWriteResult, error) {
|
func (s *Session) WriteSecured2Bulk(ctx context.Context, serverHandle int32, entries []*WriteSecured2BulkEntry) ([]*BulkWriteResult, error) {
|
||||||
if entries == nil {
|
if entries == nil {
|
||||||
return nil, errors.New("mxgateway: write-secured2 bulk entries are required")
|
return nil, errors.New("mxgateway: write-secured2 bulk entries are required")
|
||||||
@@ -490,9 +470,6 @@ func (s *Session) WriteSecured2Bulk(ctx context.Context, serverHandle int32, ent
|
|||||||
if err := ensureBulkSize("write-secured2 bulk entries", len(entries)); err != nil {
|
if err := ensureBulkSize("write-secured2 bulk entries", len(entries)); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(entries) == 0 {
|
|
||||||
return []*BulkWriteResult{}, nil
|
|
||||||
}
|
|
||||||
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
|
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
|
||||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED2_BULK,
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED2_BULK,
|
||||||
Payload: &pb.MxCommand_WriteSecured2Bulk{
|
Payload: &pb.MxCommand_WriteSecured2Bulk{
|
||||||
@@ -516,10 +493,6 @@ func (s *Session) WriteSecured2Bulk(ctx context.Context, serverHandle int32, ent
|
|||||||
// otherwise. timeout bounds the wait per tag in the snapshot case; pass zero to use the
|
// 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
|
// 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.
|
// with WasSuccessful=false; the call never returns an error for per-tag MXAccess failures.
|
||||||
//
|
|
||||||
// A non-nil but empty tagAddresses slice is treated as a no-op and returns an empty
|
|
||||||
// result without a wire round-trip; pass nil to surface a clear "tag addresses are
|
|
||||||
// required" error.
|
|
||||||
func (s *Session) ReadBulk(ctx context.Context, serverHandle int32, tagAddresses []string, timeout time.Duration) ([]*BulkReadResult, error) {
|
func (s *Session) ReadBulk(ctx context.Context, serverHandle int32, tagAddresses []string, timeout time.Duration) ([]*BulkReadResult, error) {
|
||||||
if tagAddresses == nil {
|
if tagAddresses == nil {
|
||||||
return nil, errors.New("mxgateway: tag addresses are required")
|
return nil, errors.New("mxgateway: tag addresses are required")
|
||||||
@@ -527,9 +500,6 @@ func (s *Session) ReadBulk(ctx context.Context, serverHandle int32, tagAddresses
|
|||||||
if err := ensureBulkSize("tag addresses", len(tagAddresses)); err != nil {
|
if err := ensureBulkSize("tag addresses", len(tagAddresses)); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(tagAddresses) == 0 {
|
|
||||||
return []*BulkReadResult{}, nil
|
|
||||||
}
|
|
||||||
var timeoutMs uint32
|
var timeoutMs uint32
|
||||||
if timeout > 0 {
|
if timeout > 0 {
|
||||||
ms := timeout.Milliseconds()
|
ms := timeout.Milliseconds()
|
||||||
@@ -629,7 +599,7 @@ func (s *Session) subscribeEventsAfter(ctx context.Context, afterWorkerSequence
|
|||||||
}
|
}
|
||||||
continue
|
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
|
return
|
||||||
}
|
}
|
||||||
sendEventResult(
|
sendEventResult(
|
||||||
@@ -658,7 +628,7 @@ func ensureBulkSize(name string, length int) error {
|
|||||||
|
|
||||||
func sendEventResult(
|
func sendEventResult(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
results chan<- EventResult,
|
results chan EventResult,
|
||||||
result EventResult,
|
result EventResult,
|
||||||
cancelWhenBufferFull bool,
|
cancelWhenBufferFull bool,
|
||||||
cancel context.CancelFunc,
|
cancel context.CancelFunc,
|
||||||
@@ -670,7 +640,12 @@ func sendEventResult(
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return false
|
return false
|
||||||
default:
|
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()
|
cancel()
|
||||||
|
deliverTerminalResult(results, EventResult{Err: ErrEventBufferOverflow})
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -683,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) {
|
func (s *Session) invokeCommand(ctx context.Context, command *MxCommand) (*MxCommandReply, error) {
|
||||||
return s.client.Invoke(ctx, &pb.MxCommandRequest{
|
return s.client.Invoke(ctx, &pb.MxCommandRequest{
|
||||||
SessionId: s.ID(),
|
SessionId: s.ID(),
|
||||||
@@ -691,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 {
|
func newCorrelationID() string {
|
||||||
var buffer [16]byte
|
var buffer [16]byte
|
||||||
if _, err := rand.Read(buffer[:]); err != nil {
|
if _, err := randRead(buffer[:]); err != nil {
|
||||||
return ""
|
return fmt.Sprintf("fallback-%x-%x",
|
||||||
|
time.Now().UnixNano(), correlationIDCounter.Add(1))
|
||||||
}
|
}
|
||||||
return hex.EncodeToString(buffer[:])
|
return hex.EncodeToString(buffer[:])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,32 +70,32 @@ type (
|
|||||||
WriteCommand = pb.WriteCommand
|
WriteCommand = pb.WriteCommand
|
||||||
// Write2Command is the payload of an MXAccess Write2 command.
|
// Write2Command is the payload of an MXAccess Write2 command.
|
||||||
Write2Command = pb.Write2Command
|
Write2Command = pb.Write2Command
|
||||||
// WriteBulkCommand is the payload of a bulk Write command.
|
// WriteBulkCommand carries one bulk-Write request.
|
||||||
WriteBulkCommand = pb.WriteBulkCommand
|
WriteBulkCommand = pb.WriteBulkCommand
|
||||||
// WriteBulkEntry is one entry inside a WriteBulkCommand.
|
// WriteBulkEntry is one (item_handle, value, user_id) tuple in a WriteBulk request.
|
||||||
WriteBulkEntry = pb.WriteBulkEntry
|
WriteBulkEntry = pb.WriteBulkEntry
|
||||||
// Write2BulkCommand is the payload of a bulk Write2 (timestamped) command.
|
// Write2BulkCommand carries one bulk-Write2 (timestamped) request.
|
||||||
Write2BulkCommand = pb.Write2BulkCommand
|
Write2BulkCommand = pb.Write2BulkCommand
|
||||||
// Write2BulkEntry is one entry inside a Write2BulkCommand.
|
// Write2BulkEntry is one (item_handle, value, timestamp_value, user_id) tuple in a Write2Bulk request.
|
||||||
Write2BulkEntry = pb.Write2BulkEntry
|
Write2BulkEntry = pb.Write2BulkEntry
|
||||||
// WriteSecuredBulkCommand is the payload of a bulk WriteSecured command.
|
// WriteSecuredBulkCommand carries one bulk-WriteSecured request. Values are credential-sensitive.
|
||||||
WriteSecuredBulkCommand = pb.WriteSecuredBulkCommand
|
WriteSecuredBulkCommand = pb.WriteSecuredBulkCommand
|
||||||
// WriteSecuredBulkEntry is one entry inside a WriteSecuredBulkCommand.
|
// WriteSecuredBulkEntry is one entry in a WriteSecuredBulk request.
|
||||||
WriteSecuredBulkEntry = pb.WriteSecuredBulkEntry
|
WriteSecuredBulkEntry = pb.WriteSecuredBulkEntry
|
||||||
// WriteSecured2BulkCommand is the payload of a bulk WriteSecured2 (timestamped) command.
|
// WriteSecured2BulkCommand carries one bulk-WriteSecured2 (timestamped) request.
|
||||||
WriteSecured2BulkCommand = pb.WriteSecured2BulkCommand
|
WriteSecured2BulkCommand = pb.WriteSecured2BulkCommand
|
||||||
// WriteSecured2BulkEntry is one entry inside a WriteSecured2BulkCommand.
|
// WriteSecured2BulkEntry is one entry in a WriteSecured2Bulk request.
|
||||||
WriteSecured2BulkEntry = pb.WriteSecured2BulkEntry
|
WriteSecured2BulkEntry = pb.WriteSecured2BulkEntry
|
||||||
// ReadBulkCommand is the payload of a bulk Read snapshot command.
|
// ReadBulkCommand carries one bulk-Read request.
|
||||||
ReadBulkCommand = pb.ReadBulkCommand
|
ReadBulkCommand = pb.ReadBulkCommand
|
||||||
// BulkWriteReply aggregates BulkWriteResult entries for a bulk write command.
|
// BulkWriteResult is one per-entry result in a bulk-write reply.
|
||||||
BulkWriteReply = pb.BulkWriteReply
|
|
||||||
// BulkWriteResult is one entry in a bulk write reply list.
|
|
||||||
BulkWriteResult = pb.BulkWriteResult
|
BulkWriteResult = pb.BulkWriteResult
|
||||||
// BulkReadReply aggregates BulkReadResult entries for a bulk read command.
|
// BulkWriteReply aggregates BulkWriteResult entries for a bulk-write command.
|
||||||
BulkReadReply = pb.BulkReadReply
|
BulkWriteReply = pb.BulkWriteReply
|
||||||
// BulkReadResult is one entry in a bulk read reply list.
|
// BulkReadResult is one per-tag result in a bulk-read reply (carries the snapshot value plus a was_cached flag).
|
||||||
BulkReadResult = pb.BulkReadResult
|
BulkReadResult = pb.BulkReadResult
|
||||||
|
// BulkReadReply aggregates BulkReadResult entries for a ReadBulk command.
|
||||||
|
BulkReadReply = pb.BulkReadReply
|
||||||
// RegisterReply carries the ServerHandle returned by Register.
|
// RegisterReply carries the ServerHandle returned by Register.
|
||||||
RegisterReply = pb.RegisterReply
|
RegisterReply = pb.RegisterReply
|
||||||
// AddItemReply carries the ItemHandle returned by AddItem.
|
// AddItemReply carries the ItemHandle returned by AddItem.
|
||||||
@@ -110,14 +110,12 @@ type (
|
|||||||
AcknowledgeAlarmRequest = pb.AcknowledgeAlarmRequest
|
AcknowledgeAlarmRequest = pb.AcknowledgeAlarmRequest
|
||||||
// AcknowledgeAlarmReply is the gateway AcknowledgeAlarm reply message.
|
// AcknowledgeAlarmReply is the gateway AcknowledgeAlarm reply message.
|
||||||
AcknowledgeAlarmReply = pb.AcknowledgeAlarmReply
|
AcknowledgeAlarmReply = pb.AcknowledgeAlarmReply
|
||||||
// QueryActiveAlarmsRequest is the gateway QueryActiveAlarms request message.
|
|
||||||
QueryActiveAlarmsRequest = pb.QueryActiveAlarmsRequest
|
|
||||||
// StreamAlarmsRequest is the gateway StreamAlarms request message.
|
// StreamAlarmsRequest is the gateway StreamAlarms request message.
|
||||||
StreamAlarmsRequest = pb.StreamAlarmsRequest
|
StreamAlarmsRequest = pb.StreamAlarmsRequest
|
||||||
// AlarmFeedMessage is one message on the StreamAlarms feed — an
|
// AlarmFeedMessage is one message on the StreamAlarms feed — an
|
||||||
// active-alarm snapshot row, a snapshot-complete sentinel, or a transition.
|
// active-alarm snapshot row, a snapshot-complete sentinel, or a transition.
|
||||||
AlarmFeedMessage = pb.AlarmFeedMessage
|
AlarmFeedMessage = pb.AlarmFeedMessage
|
||||||
// ActiveAlarmSnapshot is one row in a ConditionRefresh stream.
|
// ActiveAlarmSnapshot is one currently-active alarm in the feed snapshot.
|
||||||
ActiveAlarmSnapshot = pb.ActiveAlarmSnapshot
|
ActiveAlarmSnapshot = pb.ActiveAlarmSnapshot
|
||||||
// OnAlarmTransitionEvent is the body carried by alarm-transition MxEvents.
|
// OnAlarmTransitionEvent is the body carried by alarm-transition MxEvents.
|
||||||
OnAlarmTransitionEvent = pb.OnAlarmTransitionEvent
|
OnAlarmTransitionEvent = pb.OnAlarmTransitionEvent
|
||||||
@@ -131,10 +129,6 @@ type AlarmTransitionKind = pb.AlarmTransitionKind
|
|||||||
// ConditionRefresh snapshot.
|
// ConditionRefresh snapshot.
|
||||||
type AlarmConditionState = pb.AlarmConditionState
|
type AlarmConditionState = pb.AlarmConditionState
|
||||||
|
|
||||||
// QueryActiveAlarmsClient is the generated server-streaming client for the
|
|
||||||
// QueryActiveAlarms RPC.
|
|
||||||
type QueryActiveAlarmsClient = pb.MxAccessGateway_QueryActiveAlarmsClient
|
|
||||||
|
|
||||||
// StreamAlarmsClient is the generated server-streaming client for the
|
// StreamAlarmsClient is the generated server-streaming client for the
|
||||||
// StreamAlarms RPC.
|
// StreamAlarms RPC.
|
||||||
type StreamAlarmsClient = pb.MxAccessGateway_StreamAlarmsClient
|
type StreamAlarmsClient = pb.MxAccessGateway_StreamAlarmsClient
|
||||||
@@ -190,6 +184,16 @@ const (
|
|||||||
CommandKindWrite = pb.MxCommandKind_MX_COMMAND_KIND_WRITE
|
CommandKindWrite = pb.MxCommandKind_MX_COMMAND_KIND_WRITE
|
||||||
// CommandKindWrite2 selects the MXAccess Write2 command.
|
// CommandKindWrite2 selects the MXAccess Write2 command.
|
||||||
CommandKindWrite2 = pb.MxCommandKind_MX_COMMAND_KIND_WRITE2
|
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 denotes an unrecognized MXAccess data type.
|
||||||
DataTypeUnknown = pb.MxDataType_MX_DATA_TYPE_UNKNOWN
|
DataTypeUnknown = pb.MxDataType_MX_DATA_TYPE_UNKNOWN
|
||||||
|
|||||||
@@ -18,13 +18,13 @@ clients/java/
|
|||||||
settings.gradle
|
settings.gradle
|
||||||
build.gradle
|
build.gradle
|
||||||
src/main/generated/
|
src/main/generated/
|
||||||
zb-mom-ww-mxgateway-client/
|
mxgateway-client/
|
||||||
build.gradle
|
build.gradle
|
||||||
src/main/java/com/zb/mom/ww/mxgateway/client/
|
src/main/java/com/dohertylan/mxgateway/client/
|
||||||
src/test/java/com/zb/mom/ww/mxgateway/client/
|
src/test/java/com/dohertylan/mxgateway/client/
|
||||||
zb-mom-ww-mxgateway-cli/
|
mxgateway-cli/
|
||||||
build.gradle
|
build.gradle
|
||||||
src/main/java/com/zb/mom/ww/mxgateway/cli/
|
src/main/java/com/dohertylan/mxgateway/cli/
|
||||||
```
|
```
|
||||||
|
|
||||||
Alternative Maven layout is acceptable if the repo standardizes on Maven.
|
Alternative Maven layout is acceptable if the repo standardizes on Maven.
|
||||||
@@ -112,23 +112,6 @@ Support:
|
|||||||
- custom CA certificate file,
|
- custom CA certificate file,
|
||||||
- server name override for test environments.
|
- server name override for test environments.
|
||||||
|
|
||||||
### Trust posture
|
|
||||||
|
|
||||||
The gateway can serve a self-signed certificate it generates itself (it has no
|
|
||||||
PKI). To make that usable, TLS is **lenient by default**: when the channel is not
|
|
||||||
plaintext and no `caCertificatePath` is set, the client builds
|
|
||||||
`GrpcSslContexts.forClient().trustManager(InsecureTrustManagerFactory.INSTANCE)`
|
|
||||||
(grpc-netty-shaded), so the gateway's self-signed certificate is accepted without
|
|
||||||
verification.
|
|
||||||
|
|
||||||
To verify the gateway instead:
|
|
||||||
|
|
||||||
- set `caCertificatePath` to pin a CA (full verification against that root), or
|
|
||||||
- set `requireCertificateValidation` to `true` to verify against the JVM trust
|
|
||||||
store without pinning.
|
|
||||||
|
|
||||||
Pinning a CA always wins over the lenient default.
|
|
||||||
|
|
||||||
## Streaming
|
## Streaming
|
||||||
|
|
||||||
Support both:
|
Support both:
|
||||||
@@ -209,8 +192,8 @@ stream for bounded time, and close.
|
|||||||
|
|
||||||
Publish library and CLI separately:
|
Publish library and CLI separately:
|
||||||
|
|
||||||
- `zb-mom-ww-mxgateway-client` jar,
|
- `mxgateway-client` jar,
|
||||||
- `zb-mom-ww-mxgateway-cli` runnable distribution.
|
- `mxgateway-cli` runnable distribution.
|
||||||
|
|
||||||
Generated protobuf code should be produced during the build from shared proto
|
Generated protobuf code should be produced during the build from shared proto
|
||||||
files and should not be hand-edited.
|
files and should not be hand-edited.
|
||||||
@@ -223,10 +206,10 @@ Run the Java scaffold checks from `clients/java`:
|
|||||||
gradle test
|
gradle test
|
||||||
```
|
```
|
||||||
|
|
||||||
The `zb-mom-ww-mxgateway-client` project generates the gateway and worker
|
The `mxgateway-client` project generates the gateway and worker protobuf/gRPC
|
||||||
protobuf/gRPC bindings into `src/main/generated`, compiles the generated
|
bindings into `src/main/generated`, compiles the generated contracts, and runs
|
||||||
contracts, and runs JUnit 5 tests. The `zb-mom-ww-mxgateway-cli` project
|
JUnit 5 tests. The `mxgateway-cli` project builds a Picocli-based `mxgw-java`
|
||||||
builds a Picocli-based `mxgw-java` entry point for later command implementation.
|
entry point for later command implementation.
|
||||||
|
|
||||||
## Related Documentation
|
## Related Documentation
|
||||||
|
|
||||||
|
|||||||
+77
-130
@@ -10,23 +10,22 @@ clients/java/
|
|||||||
settings.gradle
|
settings.gradle
|
||||||
build.gradle
|
build.gradle
|
||||||
src/main/generated/
|
src/main/generated/
|
||||||
zb-mom-ww-mxgateway-client/
|
mxgateway-client/
|
||||||
zb-mom-ww-mxgateway-cli/
|
mxgateway-cli/
|
||||||
```
|
```
|
||||||
|
|
||||||
`zb-mom-ww-mxgateway-client` generates Java protobuf and gRPC sources from
|
`mxgateway-client` generates Java protobuf and gRPC sources from
|
||||||
`../../src/ZB.MOM.WW.MxGateway.Contracts/Protos`. The Gradle protobuf plugin writes those
|
`../../src/MxGateway.Contracts/Protos`. The Gradle protobuf plugin writes those
|
||||||
generated sources under `src/main/generated`, which matches the client proto
|
generated sources under `src/main/generated`, which matches the client proto
|
||||||
manifest in `../proto/proto-inputs.json`. Do not edit generated files by hand.
|
manifest in `../proto/proto-inputs.json`. Do not edit generated files by hand.
|
||||||
|
|
||||||
`zb-mom-ww-mxgateway-client` exposes `MxGatewayClientOptions`, `MxGatewayClient`,
|
`mxgateway-client` exposes `MxGatewayClientOptions`, `MxGatewayClient`,
|
||||||
`MxGatewaySession`, value/status helpers, typed gateway exceptions, raw
|
`MxGatewaySession`, value/status helpers, typed gateway exceptions, raw
|
||||||
generated stubs, and generated protobuf messages for parity tests.
|
generated stubs, and generated protobuf messages for parity tests.
|
||||||
|
|
||||||
`zb-mom-ww-mxgateway-cli` depends on `zb-mom-ww-mxgateway-client` and provides
|
`mxgateway-cli` depends on `mxgateway-client` and provides the `mxgw-java`
|
||||||
the `mxgw-java` application entry point. The CLI supports version, session,
|
application entry point. The CLI supports version, session, command, event
|
||||||
command, event streaming, write, and smoke-test commands with deterministic
|
streaming, write, and smoke-test commands with deterministic JSON output.
|
||||||
JSON output.
|
|
||||||
|
|
||||||
## Regenerating Protobuf Bindings
|
## Regenerating Protobuf Bindings
|
||||||
|
|
||||||
@@ -34,7 +33,7 @@ Run generation from `clients/java` after the shared `.proto` files or Java
|
|||||||
output path changes:
|
output path changes:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
gradle :zb-mom-ww-mxgateway-client:generateProto
|
gradle :mxgateway-client:generateProto
|
||||||
```
|
```
|
||||||
|
|
||||||
## Client Usage
|
## Client Usage
|
||||||
@@ -57,32 +56,66 @@ try (MxGatewayClient client = MxGatewayClient.connect(options);
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The gateway can auto-generate its own self-signed certificate (it has no PKI), so
|
|
||||||
the client is **lenient by default**: a TLS connection (`plaintext(false)`) with
|
|
||||||
no `caCertificatePath` accepts whatever certificate the gateway presents (via
|
|
||||||
grpc-netty-shaded's `InsecureTrustManagerFactory`). To verify instead, set
|
|
||||||
`caCertificatePath` to pin a CA, or set `requireCertificateValidation(true)` to
|
|
||||||
verify against the JVM trust store without pinning. Use `serverNameOverride` /
|
|
||||||
`--server-name-override` when the dialed host differs from the certificate SAN.
|
|
||||||
See
|
|
||||||
[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate).
|
|
||||||
|
|
||||||
Use `rawBlockingStub`, `rawFutureStub`, `rawAsyncStub`, `openSessionRaw`,
|
Use `rawBlockingStub`, `rawFutureStub`, `rawAsyncStub`, `openSessionRaw`,
|
||||||
`closeSessionRaw`, `invoke`, and raw session helper methods when tests need the
|
`closeSessionRaw`, `invoke`, and raw session helper methods when tests need the
|
||||||
underlying protobuf messages. `MxGatewayCommandException` and
|
underlying protobuf messages. `MxGatewayCommandException` and
|
||||||
`MxAccessException` preserve the raw `MxCommandReply` when the gateway returns a
|
`MxAccessException` preserve the raw `MxCommandReply` when the gateway returns a
|
||||||
data-bearing MXAccess failure.
|
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
|
`MxEventStream` implements `Iterator<MxEvent>` and `AutoCloseable`. Closing it
|
||||||
cancels the underlying gRPC stream. Canceling or timing out a Java client call
|
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
|
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.
|
||||||
|
|
||||||
For alarms, `MxGatewayClient` exposes `queryActiveAlarms` (one-shot snapshot),
|
Cancellation of `CompletableFuture` results from `openSessionAsync`,
|
||||||
`streamAlarms` (returns an `MxGatewayAlarmFeedSubscription` whose iterator
|
`invokeAsync`, `acknowledgeAlarmAsync`, `getLastDeployTimeAsync`,
|
||||||
yields alarm-feed messages from the gateway's central monitor), and
|
`testConnectionAsync`, and `discoverHierarchyAsync` forwards to the underlying
|
||||||
`acknowledgeAlarm` (ack by full alarm reference with an optional comment and
|
gRPC call: calling `cancel(true)` on the returned future aborts the in-flight
|
||||||
ack target). Close the subscription to cancel the underlying gRPC stream.
|
RPC instead of merely detaching the future from its result.
|
||||||
|
|
||||||
## Galaxy Repository Browse
|
## Galaxy Repository Browse
|
||||||
|
|
||||||
@@ -121,64 +154,11 @@ The CLI exposes matching subcommands: `galaxy-test`, `galaxy-deploy-time`,
|
|||||||
`--timeout`, and `--json` options as the gateway commands.
|
`--timeout`, and `--json` options as the gateway commands.
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-test --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
|
gradle :mxgateway-cli:run --args="galaxy-test --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
|
||||||
gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-deploy-time --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
|
gradle :mxgateway-cli:run --args="galaxy-deploy-time --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
|
||||||
gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-discover --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
|
gradle :mxgateway-cli:run --args="galaxy-discover --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Browsing lazily
|
|
||||||
|
|
||||||
For UI trees or OPC UA bridges, use `browseChildren` to walk one level at a
|
|
||||||
time instead of loading the full hierarchy with `discoverHierarchy`. Pass a
|
|
||||||
default request for root objects; subsequent calls set `parentGobjectId`,
|
|
||||||
`parentTagName`, or `parentContainedPath`. Filter fields match
|
|
||||||
`DiscoverHierarchy`. Each response pairs `getChildrenList()` with
|
|
||||||
`getChildHasChildrenList()` so you know which nodes to expand. See
|
|
||||||
[Galaxy Repository](../../docs/GalaxyRepository.md#browsechildren) for full
|
|
||||||
request and filter semantics. This snippet documents the API as it appears once
|
|
||||||
the Java client is regenerated on the Windows host.
|
|
||||||
|
|
||||||
```java
|
|
||||||
BrowseChildrenReply reply = galaxy.browseChildren(
|
|
||||||
BrowseChildrenRequest.newBuilder().build());
|
|
||||||
|
|
||||||
List<GalaxyObject> children = reply.getChildrenList();
|
|
||||||
List<Boolean> hasChildren = reply.getChildHasChildrenList();
|
|
||||||
for (int i = 0; i < children.size(); i++) {
|
|
||||||
System.out.printf("%s expand=%b%n", children.get(i).getTagName(), hasChildren.get(i));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### High-level walker
|
|
||||||
|
|
||||||
For UI trees, the client provides a `LazyBrowseNode` walker that handles
|
|
||||||
sibling pagination and the `child_has_children` hint for you:
|
|
||||||
|
|
||||||
```java
|
|
||||||
MxGatewayClientOptions options = MxGatewayClientOptions.builder()
|
|
||||||
.endpoint("localhost:5000")
|
|
||||||
.apiKey(System.getenv("MXGATEWAY_API_KEY"))
|
|
||||||
.plaintext(true)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
try (GalaxyRepositoryClient galaxy = GalaxyRepositoryClient.connect(options)) {
|
|
||||||
List<LazyBrowseNode> roots = galaxy.browse();
|
|
||||||
for (LazyBrowseNode root : roots) {
|
|
||||||
if (root.hasChildrenHint()) {
|
|
||||||
root.expand();
|
|
||||||
}
|
|
||||||
for (LazyBrowseNode child : root.getChildren()) {
|
|
||||||
String kind = child.hasChildrenHint() ? "has children" : "leaf";
|
|
||||||
System.out.println(child.getObject().getTagName() + " (" + kind + ")");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`expand` is idempotent — calling it twice fires only one RPC,
|
|
||||||
and is safe under concurrent callers. To refresh after a Galaxy redeploy, call
|
|
||||||
`browse` again from the root.
|
|
||||||
|
|
||||||
### Watching deploy events
|
### Watching deploy events
|
||||||
|
|
||||||
`GalaxyRepository.WatchDeployEvents` is a server-streaming RPC: the gateway
|
`GalaxyRepository.WatchDeployEvents` is a server-streaming RPC: the gateway
|
||||||
@@ -226,8 +206,8 @@ The matching CLI subcommand streams events until cancelled (Ctrl+C) and prints
|
|||||||
one line per event in text mode or one JSON object per event with `--json`:
|
one line per event in text mode or one JSON object per event with `--json`:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-watch --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
|
gradle :mxgateway-cli:run --args="galaxy-watch --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
|
||||||
gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-watch --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --last-seen-deploy-time 2026-04-28T18:30:00Z --limit 5"
|
gradle :mxgateway-cli:run --args="galaxy-watch --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --last-seen-deploy-time 2026-04-28T18:30:00Z --limit 5"
|
||||||
```
|
```
|
||||||
|
|
||||||
## CLI Usage
|
## CLI Usage
|
||||||
@@ -235,16 +215,14 @@ gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-watch --endpoint localhost:50
|
|||||||
Run the CLI through Gradle:
|
Run the CLI through Gradle:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
gradle :zb-mom-ww-mxgateway-cli:run --args="version --json"
|
gradle :mxgateway-cli:run --args="version --json"
|
||||||
gradle :zb-mom-ww-mxgateway-cli:run --args="open-session --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --client-session-name java-cli --json"
|
gradle :mxgateway-cli:run --args="open-session --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --client-session-name java-cli --json"
|
||||||
gradle :zb-mom-ww-mxgateway-cli:run --args="register --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --client-name java-cli --json"
|
gradle :mxgateway-cli:run --args="register --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --client-name java-cli --json"
|
||||||
gradle :zb-mom-ww-mxgateway-cli:run --args="add-item --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item TestObject.TestInt --json"
|
gradle :mxgateway-cli:run --args="add-item --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item TestObject.TestInt --json"
|
||||||
gradle :zb-mom-ww-mxgateway-cli:run --args="advise --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item-handle 1 --json"
|
gradle :mxgateway-cli:run --args="advise --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item-handle 1 --json"
|
||||||
gradle :zb-mom-ww-mxgateway-cli:run --args="write --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --json"
|
gradle :mxgateway-cli:run --args="write --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --json"
|
||||||
gradle :zb-mom-ww-mxgateway-cli:run --args="stream-events --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --limit 1 --json"
|
gradle :mxgateway-cli:run --args="stream-events --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --limit 1 --json"
|
||||||
gradle :zb-mom-ww-mxgateway-cli:run --args="stream-alarms --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --filter-prefix Galaxy --limit 1 --json"
|
gradle :mxgateway-cli:run --args="smoke --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --item TestObject.TestInt --json"
|
||||||
gradle :zb-mom-ww-mxgateway-cli:run --args="acknowledge-alarm --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --reference \"\\Galaxy\Area001.Pump001.PumpFault\" --json"
|
|
||||||
gradle :zb-mom-ww-mxgateway-cli:run --args="smoke --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --item TestObject.TestInt --json"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The CLI accepts `--api-key`, `--api-key-env`, `--plaintext`, `--ca-file`,
|
The CLI accepts `--api-key`, `--api-key-env`, `--plaintext`, `--ca-file`,
|
||||||
@@ -254,7 +232,7 @@ output redacts API keys.
|
|||||||
Use TLS options for a secured gateway:
|
Use TLS options for a secured gateway:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
gradle :zb-mom-ww-mxgateway-cli:run --args="smoke --endpoint mxgateway.example.local:5001 --ca-file C:\certs\mxgateway-ca.pem --server-name-override mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item TestObject.TestInt --json"
|
gradle :mxgateway-cli:run --args="smoke --endpoint mxgateway.example.local:5001 --ca-file C:\certs\mxgateway-ca.pem --server-name-override mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item TestObject.TestInt --json"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Build And Test
|
## Build And Test
|
||||||
@@ -274,11 +252,11 @@ in-process gRPC behavior, stream cancellation, and CLI parser/output behavior.
|
|||||||
Create local library and CLI artifacts from `clients/java`:
|
Create local library and CLI artifacts from `clients/java`:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
gradle :zb-mom-ww-mxgateway-client:jar :zb-mom-ww-mxgateway-cli:installDist
|
gradle :mxgateway-client:jar :mxgateway-cli:installDist
|
||||||
```
|
```
|
||||||
|
|
||||||
The library jar is under `zb-mom-ww-mxgateway-client/build/libs`. The installed CLI
|
The library jar is under `mxgateway-client/build/libs`. The installed CLI
|
||||||
distribution is under `zb-mom-ww-mxgateway-cli/build/install/zb-mom-ww-mxgateway-cli`.
|
distribution is under `mxgateway-cli/build/install/mxgateway-cli`.
|
||||||
|
|
||||||
## Integration Checks
|
## Integration Checks
|
||||||
|
|
||||||
@@ -289,40 +267,9 @@ $env:MXGATEWAY_INTEGRATION = '1'
|
|||||||
$env:MXGATEWAY_ENDPOINT = 'localhost:5000'
|
$env:MXGATEWAY_ENDPOINT = 'localhost:5000'
|
||||||
$env:MXGATEWAY_API_KEY = '<gateway-api-key>'
|
$env:MXGATEWAY_API_KEY = '<gateway-api-key>'
|
||||||
$env:MXGATEWAY_TEST_ITEM = 'TestObject.TestInt'
|
$env:MXGATEWAY_TEST_ITEM = 'TestObject.TestInt'
|
||||||
gradle :zb-mom-ww-mxgateway-cli:run --args="smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json"
|
gradle :mxgateway-cli:run --args="smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Installing from the Gitea Maven repository
|
|
||||||
|
|
||||||
The client publishes to the internal Gitea Maven repository at
|
|
||||||
`https://gitea.dohertylan.com/api/packages/dohertj2/maven`.
|
|
||||||
|
|
||||||
In your consumer project's `build.gradle`:
|
|
||||||
|
|
||||||
````groovy
|
|
||||||
repositories {
|
|
||||||
maven {
|
|
||||||
url 'https://gitea.dohertylan.com/api/packages/dohertj2/maven'
|
|
||||||
credentials {
|
|
||||||
username = System.getenv('GITEA_USERNAME')
|
|
||||||
password = System.getenv('GITEA_TOKEN')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation 'com.zb.mom.ww.mxgateway:zb-mom-ww-mxgateway-client:0.1.0'
|
|
||||||
}
|
|
||||||
````
|
|
||||||
|
|
||||||
To publish a new version from this repo:
|
|
||||||
|
|
||||||
````bash
|
|
||||||
export GITEA_USERNAME=dohertj2
|
|
||||||
export GITEA_TOKEN=<your-gitea-token>
|
|
||||||
gradle :zb-mom-ww-mxgateway-client:publish
|
|
||||||
````
|
|
||||||
|
|
||||||
## Related Documentation
|
## Related Documentation
|
||||||
|
|
||||||
- [Client Packaging](../../docs/ClientPackaging.md)
|
- [Client Packaging](../../docs/ClientPackaging.md)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ ext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
subprojects {
|
subprojects {
|
||||||
group = 'com.zb.mom.ww.mxgateway'
|
group = 'com.dohertylan.mxgateway'
|
||||||
version = '0.1.0'
|
version = '0.1.0'
|
||||||
|
|
||||||
pluginManager.withPlugin('java') {
|
pluginManager.withPlugin('java') {
|
||||||
@@ -37,44 +37,4 @@ subprojects {
|
|||||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pluginManager.withPlugin('maven-publish') {
|
|
||||||
publishing {
|
|
||||||
publications {
|
|
||||||
maven(MavenPublication) {
|
|
||||||
from components.java
|
|
||||||
pom {
|
|
||||||
url = 'https://gitea.dohertylan.com/dohertj2/mxaccessgw'
|
|
||||||
description = 'MxAccessGateway Java client'
|
|
||||||
scm {
|
|
||||||
url = 'https://gitea.dohertylan.com/dohertj2/mxaccessgw'
|
|
||||||
connection = 'scm:git:https://gitea.dohertylan.com/dohertj2/mxaccessgw.git'
|
|
||||||
}
|
|
||||||
developers {
|
|
||||||
developer {
|
|
||||||
id = 'dohertj2'
|
|
||||||
name = 'Joseph Doherty'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
licenses {
|
|
||||||
license {
|
|
||||||
name = 'Proprietary'
|
|
||||||
distribution = 'repo'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
repositories {
|
|
||||||
maven {
|
|
||||||
name = 'GiteaPackages'
|
|
||||||
url = 'https://gitea.dohertylan.com/api/packages/dohertj2/maven'
|
|
||||||
credentials {
|
|
||||||
username = System.getenv('GITEA_USERNAME') ?: ''
|
|
||||||
password = System.getenv('GITEA_TOKEN') ?: ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -3,11 +3,11 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation project(':zb-mom-ww-mxgateway-client')
|
implementation project(':mxgateway-client')
|
||||||
implementation "com.google.protobuf:protobuf-java-util:${protobufVersion}"
|
implementation "com.google.protobuf:protobuf-java-util:${protobufVersion}"
|
||||||
implementation "info.picocli:picocli:${picocliVersion}"
|
implementation "info.picocli:picocli:${picocliVersion}"
|
||||||
}
|
}
|
||||||
|
|
||||||
application {
|
application {
|
||||||
mainClass = 'com.zb.mom.ww.mxgateway.cli.MxGatewayCli'
|
mainClass = 'com.dohertylan.mxgateway.cli.MxGatewayCli'
|
||||||
}
|
}
|
||||||
+298
-263
@@ -1,21 +1,20 @@
|
|||||||
package com.zb.mom.ww.mxgateway.cli;
|
package com.dohertylan.mxgateway.cli;
|
||||||
|
|
||||||
import com.zb.mom.ww.mxgateway.client.DeployEventStream;
|
import com.dohertylan.mxgateway.client.DeployEventStream;
|
||||||
import com.zb.mom.ww.mxgateway.client.GalaxyRepositoryClient;
|
import com.dohertylan.mxgateway.client.GalaxyRepositoryClient;
|
||||||
import com.zb.mom.ww.mxgateway.client.MxEventStream;
|
import com.dohertylan.mxgateway.client.MxEventStream;
|
||||||
import com.zb.mom.ww.mxgateway.client.MxGatewayAlarmFeedSubscription;
|
import com.dohertylan.mxgateway.client.MxGatewayAlarmFeedSubscription;
|
||||||
import com.zb.mom.ww.mxgateway.client.MxGatewayClient;
|
import com.dohertylan.mxgateway.client.MxGatewayClient;
|
||||||
import com.zb.mom.ww.mxgateway.client.MxGatewayClientOptions;
|
import com.dohertylan.mxgateway.client.MxGatewayClientOptions;
|
||||||
import com.zb.mom.ww.mxgateway.client.MxGatewayClientVersion;
|
import com.dohertylan.mxgateway.client.MxGatewayClientVersion;
|
||||||
import com.zb.mom.ww.mxgateway.client.MxGatewaySecrets;
|
import com.dohertylan.mxgateway.client.MxGatewaySecrets;
|
||||||
import com.zb.mom.ww.mxgateway.client.MxGatewaySession;
|
import com.dohertylan.mxgateway.client.MxGatewaySession;
|
||||||
import com.zb.mom.ww.mxgateway.client.MxValues;
|
import com.dohertylan.mxgateway.client.MxValues;
|
||||||
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
|
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
|
||||||
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttribute;
|
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttribute;
|
||||||
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject;
|
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject;
|
||||||
import com.google.protobuf.Message;
|
import com.google.protobuf.Message;
|
||||||
import com.google.protobuf.util.JsonFormat;
|
import com.google.protobuf.util.JsonFormat;
|
||||||
import io.grpc.stub.StreamObserver;
|
|
||||||
import java.io.BufferedReader;
|
import java.io.BufferedReader;
|
||||||
import java.io.InputStreamReader;
|
import java.io.InputStreamReader;
|
||||||
import java.io.PrintWriter;
|
import java.io.PrintWriter;
|
||||||
@@ -33,7 +32,7 @@ import java.util.Optional;
|
|||||||
import java.util.concurrent.ArrayBlockingQueue;
|
import java.util.concurrent.ArrayBlockingQueue;
|
||||||
import java.util.concurrent.BlockingQueue;
|
import java.util.concurrent.BlockingQueue;
|
||||||
import java.util.concurrent.Callable;
|
import java.util.concurrent.Callable;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import io.grpc.stub.StreamObserver;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply;
|
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest;
|
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot;
|
import mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot;
|
||||||
@@ -120,7 +119,7 @@ public final class MxGatewayCli implements Callable<Integer> {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
static CommandLine commandLine(MxGatewayCliClientFactory clientFactory) {
|
private static CommandLine commandLine(MxGatewayCliClientFactory clientFactory) {
|
||||||
CommandLine commandLine = new CommandLine(new MxGatewayCli(clientFactory));
|
CommandLine commandLine = new CommandLine(new MxGatewayCli(clientFactory));
|
||||||
commandLine.addSubcommand("version", new VersionCommand());
|
commandLine.addSubcommand("version", new VersionCommand());
|
||||||
commandLine.addSubcommand("open-session", new OpenSessionCommand(clientFactory));
|
commandLine.addSubcommand("open-session", new OpenSessionCommand(clientFactory));
|
||||||
@@ -155,120 +154,6 @@ public final class MxGatewayCli implements Callable<Integer> {
|
|||||||
/** Sentinel queued by {@code stream-alarms} to mark a clean end of the alarm feed. */
|
/** Sentinel queued by {@code stream-alarms} to mark a clean end of the alarm feed. */
|
||||||
private static final Object ALARM_FEED_END = new Object();
|
private static final Object ALARM_FEED_END = new Object();
|
||||||
|
|
||||||
/**
|
|
||||||
* Tokenises a single batch-mode stdin line into the argv that the inner
|
|
||||||
* {@link CommandLine} should execute. Honours single-quoted, double-quoted,
|
|
||||||
* and backslash-escaped runs so values that contain spaces (e.g.
|
|
||||||
* {@code --comment "needs verification"}) survive intact — the old
|
|
||||||
* implementation used {@code split("\\s+")} which shredded any quoted
|
|
||||||
* argument mid-string (Client.Java-034).
|
|
||||||
*
|
|
||||||
* <p>Rules (a small POSIX-like shell tokenizer; no variable expansion,
|
|
||||||
* command substitution, globbing, or backtick handling):
|
|
||||||
*
|
|
||||||
* <ul>
|
|
||||||
* <li>Outside quotes, runs of whitespace separate tokens.</li>
|
|
||||||
* <li>{@code "..."} groups a sequence into one token; the surrounding
|
|
||||||
* quotes are removed. Inside double quotes a backslash escapes
|
|
||||||
* {@code \\}, {@code "}, and a literal newline; other characters
|
|
||||||
* are taken literally (so {@code \n} is the two characters
|
|
||||||
* backslash-n).</li>
|
|
||||||
* <li>{@code '...'} groups a sequence into one token; the surrounding
|
|
||||||
* quotes are removed. Inside single quotes nothing is escaped —
|
|
||||||
* the run is literal until the matching single quote.</li>
|
|
||||||
* <li>Outside quotes, backslash escapes the next character (including
|
|
||||||
* whitespace, so {@code needs\ verification} is one token).</li>
|
|
||||||
* <li>An unterminated quote or a trailing backslash throws
|
|
||||||
* {@link IllegalArgumentException} so the batch loop surfaces it
|
|
||||||
* as a JSON error instead of silently emitting wrong args.</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* <p>Empty input (or input that contains only whitespace) returns an
|
|
||||||
* empty array so callers can skip the line.
|
|
||||||
*/
|
|
||||||
static String[] tokenizeBatchLine(String line) {
|
|
||||||
List<String> tokens = new ArrayList<>();
|
|
||||||
StringBuilder current = new StringBuilder();
|
|
||||||
boolean inToken = false;
|
|
||||||
// 0 = outside, 1 = inside single quotes, 2 = inside double quotes
|
|
||||||
int quoteMode = 0;
|
|
||||||
int length = line.length();
|
|
||||||
for (int i = 0; i < length; i++) {
|
|
||||||
char c = line.charAt(i);
|
|
||||||
if (quoteMode == 1) {
|
|
||||||
if (c == '\'') {
|
|
||||||
quoteMode = 0;
|
|
||||||
} else {
|
|
||||||
current.append(c);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (quoteMode == 2) {
|
|
||||||
if (c == '\\') {
|
|
||||||
if (i + 1 >= length) {
|
|
||||||
throw new IllegalArgumentException(
|
|
||||||
"batch tokenizer: trailing backslash inside double-quoted string");
|
|
||||||
}
|
|
||||||
char next = line.charAt(i + 1);
|
|
||||||
if (next == '\\' || next == '"' || next == '\n') {
|
|
||||||
current.append(next);
|
|
||||||
i++;
|
|
||||||
} else {
|
|
||||||
// POSIX rule: inside double quotes a backslash is
|
|
||||||
// literal unless it precedes \, ", $, `, or newline.
|
|
||||||
current.append(c);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (c == '"') {
|
|
||||||
quoteMode = 0;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
current.append(c);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Outside any quotes.
|
|
||||||
if (c == '\'') {
|
|
||||||
quoteMode = 1;
|
|
||||||
inToken = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (c == '"') {
|
|
||||||
quoteMode = 2;
|
|
||||||
inToken = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (c == '\\') {
|
|
||||||
if (i + 1 >= length) {
|
|
||||||
throw new IllegalArgumentException(
|
|
||||||
"batch tokenizer: trailing backslash outside quotes");
|
|
||||||
}
|
|
||||||
current.append(line.charAt(i + 1));
|
|
||||||
i++;
|
|
||||||
inToken = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (Character.isWhitespace(c)) {
|
|
||||||
if (inToken) {
|
|
||||||
tokens.add(current.toString());
|
|
||||||
current.setLength(0);
|
|
||||||
inToken = false;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
current.append(c);
|
|
||||||
inToken = true;
|
|
||||||
}
|
|
||||||
if (quoteMode != 0) {
|
|
||||||
throw new IllegalArgumentException(
|
|
||||||
"batch tokenizer: unterminated " + (quoteMode == 1 ? "single" : "double") + " quote");
|
|
||||||
}
|
|
||||||
if (inToken) {
|
|
||||||
tokens.add(current.toString());
|
|
||||||
}
|
|
||||||
return tokens.toArray(new String[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads one CLI invocation per stdin line, executes each via a fresh
|
* Reads one CLI invocation per stdin line, executes each via a fresh
|
||||||
* {@link CommandLine}, and writes {@value #BATCH_EOR} to stdout after
|
* {@link CommandLine}, and writes {@value #BATCH_EOR} to stdout after
|
||||||
@@ -298,8 +183,8 @@ public final class MxGatewayCli implements Callable<Integer> {
|
|||||||
if (line.isEmpty()) {
|
if (line.isEmpty()) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
String[] args = tokenizeBatchLine(line);
|
String[] args = line.trim().split("\\s+");
|
||||||
if (args.length == 0) {
|
if (args.length == 0 || (args.length == 1 && args[0].isEmpty())) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
StringWriter cmdOut = new StringWriter();
|
StringWriter cmdOut = new StringWriter();
|
||||||
@@ -457,9 +342,12 @@ public final class MxGatewayCli implements Callable<Integer> {
|
|||||||
if (json) {
|
if (json) {
|
||||||
out.println(protoJson(event));
|
out.println(protoJson(event));
|
||||||
} else {
|
} else {
|
||||||
|
// sequence is a proto uint64 — print as unsigned so values
|
||||||
|
// past 2^63 do not render as negative signed longs. JSON
|
||||||
|
// path goes through JsonFormat which already does this.
|
||||||
out.printf(
|
out.printf(
|
||||||
"seq=%d observed=%s deployTime=%s objects=%d attributes=%d%n",
|
"seq=%s observed=%s deployTime=%s objects=%d attributes=%d%n",
|
||||||
event.getSequence(),
|
Long.toUnsignedString(event.getSequence()),
|
||||||
formatTimestamp(event.getObservedAt()),
|
formatTimestamp(event.getObservedAt()),
|
||||||
event.getTimeOfLastDeployPresent()
|
event.getTimeOfLastDeployPresent()
|
||||||
? formatTimestamp(event.getTimeOfLastDeploy())
|
? formatTimestamp(event.getTimeOfLastDeploy())
|
||||||
@@ -756,9 +644,7 @@ public final class MxGatewayCli implements Callable<Integer> {
|
|||||||
@Option(names = "--items", required = true, description = "Comma-separated tag addresses.")
|
@Option(names = "--items", required = true, description = "Comma-separated tag addresses.")
|
||||||
String items;
|
String items;
|
||||||
|
|
||||||
@Option(
|
@Option(names = "--timeout-ms", defaultValue = "0",
|
||||||
names = "--timeout-ms",
|
|
||||||
defaultValue = "0",
|
|
||||||
description = "Per-tag snapshot timeout in milliseconds (0 = worker default).")
|
description = "Per-tag snapshot timeout in milliseconds (0 = worker default).")
|
||||||
int timeoutMs;
|
int timeoutMs;
|
||||||
|
|
||||||
@@ -769,8 +655,8 @@ public final class MxGatewayCli implements Callable<Integer> {
|
|||||||
@Override
|
@Override
|
||||||
public Integer call() {
|
public Integer call() {
|
||||||
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
|
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
|
||||||
List<BulkReadResult> results = client.session(sessionId)
|
List<BulkReadResult> results =
|
||||||
.readBulk(serverHandle, parseStringList(items), Duration.ofMillis(timeoutMs));
|
client.session(sessionId).readBulk(serverHandle, parseStringList(items), timeoutMs);
|
||||||
writeReadBulkOutput("read-bulk", common, json, results);
|
writeReadBulkOutput("read-bulk", common, json, results);
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
@@ -808,8 +694,7 @@ public final class MxGatewayCli implements Callable<Integer> {
|
|||||||
List<String> valueTexts = parseStringList(values);
|
List<String> valueTexts = parseStringList(values);
|
||||||
if (handles.size() != valueTexts.size()) {
|
if (handles.size() != valueTexts.size()) {
|
||||||
throw new IllegalArgumentException(
|
throw new IllegalArgumentException(
|
||||||
"item-handles count (" + handles.size() + ") does not match values count ("
|
"item-handles count (" + handles.size() + ") does not match values count (" + valueTexts.size() + ")");
|
||||||
+ valueTexts.size() + ")");
|
|
||||||
}
|
}
|
||||||
List<WriteBulkEntry> entries = new ArrayList<>(handles.size());
|
List<WriteBulkEntry> entries = new ArrayList<>(handles.size());
|
||||||
for (int i = 0; i < handles.size(); i++) {
|
for (int i = 0; i < handles.size(); i++) {
|
||||||
@@ -860,8 +745,7 @@ public final class MxGatewayCli implements Callable<Integer> {
|
|||||||
List<String> valueTexts = parseStringList(values);
|
List<String> valueTexts = parseStringList(values);
|
||||||
if (handles.size() != valueTexts.size()) {
|
if (handles.size() != valueTexts.size()) {
|
||||||
throw new IllegalArgumentException(
|
throw new IllegalArgumentException(
|
||||||
"item-handles count (" + handles.size() + ") does not match values count ("
|
"item-handles count (" + handles.size() + ") does not match values count (" + valueTexts.size() + ")");
|
||||||
+ valueTexts.size() + ")");
|
|
||||||
}
|
}
|
||||||
MxValue timestampValue = MxValues.timestampValue(Instant.parse(timestamp));
|
MxValue timestampValue = MxValues.timestampValue(Instant.parse(timestamp));
|
||||||
List<Write2BulkEntry> entries = new ArrayList<>(handles.size());
|
List<Write2BulkEntry> entries = new ArrayList<>(handles.size());
|
||||||
@@ -914,8 +798,7 @@ public final class MxGatewayCli implements Callable<Integer> {
|
|||||||
List<String> valueTexts = parseStringList(values);
|
List<String> valueTexts = parseStringList(values);
|
||||||
if (handles.size() != valueTexts.size()) {
|
if (handles.size() != valueTexts.size()) {
|
||||||
throw new IllegalArgumentException(
|
throw new IllegalArgumentException(
|
||||||
"item-handles count (" + handles.size() + ") does not match values count ("
|
"item-handles count (" + handles.size() + ") does not match values count (" + valueTexts.size() + ")");
|
||||||
+ valueTexts.size() + ")");
|
|
||||||
}
|
}
|
||||||
List<WriteSecuredBulkEntry> entries = new ArrayList<>(handles.size());
|
List<WriteSecuredBulkEntry> entries = new ArrayList<>(handles.size());
|
||||||
for (int i = 0; i < handles.size(); i++) {
|
for (int i = 0; i < handles.size(); i++) {
|
||||||
@@ -970,8 +853,7 @@ public final class MxGatewayCli implements Callable<Integer> {
|
|||||||
List<String> valueTexts = parseStringList(values);
|
List<String> valueTexts = parseStringList(values);
|
||||||
if (handles.size() != valueTexts.size()) {
|
if (handles.size() != valueTexts.size()) {
|
||||||
throw new IllegalArgumentException(
|
throw new IllegalArgumentException(
|
||||||
"item-handles count (" + handles.size() + ") does not match values count ("
|
"item-handles count (" + handles.size() + ") does not match values count (" + valueTexts.size() + ")");
|
||||||
+ valueTexts.size() + ")");
|
|
||||||
}
|
}
|
||||||
MxValue timestampValue = MxValues.timestampValue(Instant.parse(timestamp));
|
MxValue timestampValue = MxValues.timestampValue(Instant.parse(timestamp));
|
||||||
List<WriteSecured2BulkEntry> entries = new ArrayList<>(handles.size());
|
List<WriteSecured2BulkEntry> entries = new ArrayList<>(handles.size());
|
||||||
@@ -991,113 +873,224 @@ public final class MxGatewayCli implements Callable<Integer> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Command(
|
/**
|
||||||
name = "bench-read-bulk",
|
* Cross-language ReadBulk stress benchmark — mirrors the .NET / Go / Rust /
|
||||||
description = "Repeatedly invokes ReadBulk for benchmarking; prints aggregate timing.")
|
* Python implementations so the PS driver collates one JSON schema across
|
||||||
|
* all five clients.
|
||||||
|
*/
|
||||||
|
@Command(name = "bench-read-bulk", description = "Cross-language ReadBulk stress benchmark.")
|
||||||
static final class BenchReadBulkCommand extends GatewayCommand {
|
static final class BenchReadBulkCommand extends GatewayCommand {
|
||||||
@Option(names = "--session-id", required = true, description = "Gateway session id.")
|
@Option(names = "--client-name", defaultValue = "mxgw-java-bench")
|
||||||
String sessionId;
|
String clientName;
|
||||||
|
|
||||||
@Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
|
@Option(names = "--duration-seconds", defaultValue = "30")
|
||||||
int serverHandle;
|
int durationSeconds;
|
||||||
|
|
||||||
@Option(names = "--items", required = true, description = "Comma-separated tag addresses.")
|
@Option(names = "--warmup-seconds", defaultValue = "3")
|
||||||
String items;
|
int warmupSeconds;
|
||||||
|
|
||||||
@Option(
|
@Option(names = "--bulk-size", defaultValue = "6")
|
||||||
names = "--timeout-ms",
|
int bulkSize;
|
||||||
defaultValue = "0",
|
|
||||||
description = "Per-tag snapshot timeout in milliseconds (0 = worker default).")
|
@Option(names = "--tag-start", defaultValue = "1")
|
||||||
|
int tagStart;
|
||||||
|
|
||||||
|
@Option(names = "--tag-prefix", defaultValue = "TestMachine_")
|
||||||
|
String tagPrefix;
|
||||||
|
|
||||||
|
@Option(names = "--tag-attribute", defaultValue = "TestChangingInt")
|
||||||
|
String tagAttribute;
|
||||||
|
|
||||||
|
@Option(names = "--timeout-ms", defaultValue = "1500")
|
||||||
int timeoutMs;
|
int timeoutMs;
|
||||||
|
|
||||||
@Option(names = "--iterations", defaultValue = "10", description = "Number of ReadBulk calls to perform.")
|
|
||||||
int iterations;
|
|
||||||
|
|
||||||
@Option(
|
|
||||||
names = "--warmup",
|
|
||||||
defaultValue = "1",
|
|
||||||
description = "Number of warmup iterations excluded from timing.")
|
|
||||||
int warmup;
|
|
||||||
|
|
||||||
BenchReadBulkCommand(MxGatewayCliClientFactory clientFactory) {
|
BenchReadBulkCommand(MxGatewayCliClientFactory clientFactory) {
|
||||||
super(clientFactory);
|
super(clientFactory);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Integer call() {
|
public Integer call() {
|
||||||
if (iterations <= 0) {
|
if (bulkSize < 1) {
|
||||||
throw new IllegalArgumentException("--iterations must be positive");
|
throw new IllegalArgumentException("bulk-size must be positive");
|
||||||
}
|
}
|
||||||
if (warmup < 0) {
|
List<String> tags = new ArrayList<>(bulkSize);
|
||||||
throw new IllegalArgumentException("--warmup must be non-negative");
|
for (int i = 0; i < bulkSize; i++) {
|
||||||
|
tags.add(String.format("%s%03d.%s", tagPrefix, tagStart + i, tagAttribute));
|
||||||
}
|
}
|
||||||
List<String> tagAddresses = parseStringList(items);
|
|
||||||
Duration timeout = Duration.ofMillis(timeoutMs);
|
|
||||||
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
|
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
|
||||||
|
var openReply = client.openSession(
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest.newBuilder()
|
||||||
|
.setClientSessionName(clientName)
|
||||||
|
.build());
|
||||||
|
String sessionId = openReply.getSessionId();
|
||||||
MxGatewayCliSession session = client.session(sessionId);
|
MxGatewayCliSession session = client.session(sessionId);
|
||||||
for (int i = 0; i < warmup; i++) {
|
List<Integer> itemHandles = new ArrayList<>();
|
||||||
session.readBulk(serverHandle, tagAddresses, timeout);
|
long steadyElapsedNanos;
|
||||||
}
|
long[] latenciesNanos;
|
||||||
long totalNanos = 0L;
|
int latencyCount = 0;
|
||||||
long minNanos = Long.MAX_VALUE;
|
long successful = 0;
|
||||||
long maxNanos = 0L;
|
long failed = 0;
|
||||||
int lastResultCount = 0;
|
long totalResults = 0;
|
||||||
int lastSuccessCount = 0;
|
long cachedResults = 0;
|
||||||
int lastCachedCount = 0;
|
int serverHandle = session.register(clientName);
|
||||||
for (int i = 0; i < iterations; i++) {
|
try {
|
||||||
long start = System.nanoTime();
|
List<SubscribeResult> subscribeResults = session.subscribeBulk(serverHandle, tags);
|
||||||
List<BulkReadResult> results = session.readBulk(serverHandle, tagAddresses, timeout);
|
for (SubscribeResult r : subscribeResults) {
|
||||||
long elapsed = System.nanoTime() - start;
|
if (r.getWasSuccessful()) {
|
||||||
totalNanos += elapsed;
|
itemHandles.add(r.getItemHandle());
|
||||||
minNanos = Math.min(minNanos, elapsed);
|
|
||||||
maxNanos = Math.max(maxNanos, elapsed);
|
|
||||||
lastResultCount = results.size();
|
|
||||||
lastSuccessCount = 0;
|
|
||||||
lastCachedCount = 0;
|
|
||||||
for (BulkReadResult result : results) {
|
|
||||||
if (result.getWasSuccessful()) {
|
|
||||||
lastSuccessCount++;
|
|
||||||
}
|
|
||||||
if (result.getWasCached()) {
|
|
||||||
lastCachedCount++;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Warm-up window — drives identical calls so JIT / connection
|
||||||
|
// pool effects are amortised before the measurement window.
|
||||||
|
long warmupDeadline = System.nanoTime() + warmupSeconds * 1_000_000_000L;
|
||||||
|
while (System.nanoTime() < warmupDeadline) {
|
||||||
|
session.readBulk(serverHandle, tags, timeoutMs);
|
||||||
}
|
}
|
||||||
double avgMs = totalNanos / 1_000_000.0 / iterations;
|
|
||||||
double minMs = minNanos / 1_000_000.0;
|
latenciesNanos = new long[Math.max(1024, durationSeconds * 1000)];
|
||||||
double maxMs = maxNanos / 1_000_000.0;
|
long steadyStart = System.nanoTime();
|
||||||
PrintWriter out = common.spec.commandLine().getOut();
|
long steadyDeadline = steadyStart + durationSeconds * 1_000_000_000L;
|
||||||
if (json) {
|
while (System.nanoTime() < steadyDeadline) {
|
||||||
Map<String, Object> output = new LinkedHashMap<>();
|
long callStart = System.nanoTime();
|
||||||
output.put("command", "bench-read-bulk");
|
try {
|
||||||
output.put("options", common.redactedJsonMap());
|
List<BulkReadResult> results = session.readBulk(serverHandle, tags, timeoutMs);
|
||||||
output.put("iterations", iterations);
|
long elapsed = System.nanoTime() - callStart;
|
||||||
output.put("warmup", warmup);
|
// Only record successful-call latencies — including failed-call
|
||||||
output.put("tagCount", tagAddresses.size());
|
// durations would pollute the p50/p95/p99 percentile summary
|
||||||
output.put("resultCount", lastResultCount);
|
// (Client.Java-024, mirrors Client.Rust-015). The cross-language
|
||||||
output.put("successCount", lastSuccessCount);
|
// bench matrix expects success-only latency histograms.
|
||||||
output.put("cachedCount", lastCachedCount);
|
if (latencyCount >= latenciesNanos.length) {
|
||||||
output.put("avgMs", avgMs);
|
long[] grown = new long[latenciesNanos.length * 2];
|
||||||
output.put("minMs", minMs);
|
System.arraycopy(latenciesNanos, 0, grown, 0, latencyCount);
|
||||||
output.put("maxMs", maxMs);
|
latenciesNanos = grown;
|
||||||
out.println(jsonObject(output));
|
|
||||||
} else {
|
|
||||||
out.printf(
|
|
||||||
"iterations=%d tags=%d avg=%.3fms min=%.3fms max=%.3fms last_results=%d last_success=%d last_cached=%d%n",
|
|
||||||
iterations,
|
|
||||||
tagAddresses.size(),
|
|
||||||
avgMs,
|
|
||||||
minMs,
|
|
||||||
maxMs,
|
|
||||||
lastResultCount,
|
|
||||||
lastSuccessCount,
|
|
||||||
lastCachedCount);
|
|
||||||
}
|
}
|
||||||
|
latenciesNanos[latencyCount++] = elapsed;
|
||||||
|
successful++;
|
||||||
|
for (BulkReadResult r : results) {
|
||||||
|
totalResults++;
|
||||||
|
if (r.getWasCached()) {
|
||||||
|
cachedResults++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ex) {
|
||||||
|
// Failed-call duration is intentionally NOT recorded into
|
||||||
|
// the success-latency histogram — only count the failure so
|
||||||
|
// the failedCalls JSON field reflects it.
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
steadyElapsedNanos = System.nanoTime() - steadyStart;
|
||||||
|
} finally {
|
||||||
|
if (!itemHandles.isEmpty()) {
|
||||||
|
try { session.unsubscribeBulk(serverHandle, itemHandles); } catch (Exception ignored) { }
|
||||||
|
}
|
||||||
|
try { client.closeSession(mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest.newBuilder()
|
||||||
|
.setSessionId(sessionId).build()); } catch (Exception ignored) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
long totalCalls = successful + failed;
|
||||||
|
double steadyElapsedSeconds = steadyElapsedNanos / 1_000_000_000.0;
|
||||||
|
double callsPerSecond = steadyElapsedSeconds > 0 ? totalCalls / steadyElapsedSeconds : 0.0;
|
||||||
|
writeBenchOutput(common, json, tags, clientName, bulkSize, durationSeconds, warmupSeconds,
|
||||||
|
steadyElapsedNanos, totalCalls, successful, failed, totalResults, cachedResults,
|
||||||
|
callsPerSecond, latenciesNanos, latencyCount);
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void writeBenchOutput(
|
||||||
|
CommonOptions common,
|
||||||
|
boolean json,
|
||||||
|
List<String> tags,
|
||||||
|
String clientName,
|
||||||
|
int bulkSize,
|
||||||
|
int durationSeconds,
|
||||||
|
int warmupSeconds,
|
||||||
|
long steadyElapsedNanos,
|
||||||
|
long totalCalls,
|
||||||
|
long successful,
|
||||||
|
long failed,
|
||||||
|
long totalResults,
|
||||||
|
long cachedResults,
|
||||||
|
double callsPerSecond,
|
||||||
|
long[] latenciesNanos,
|
||||||
|
int latencyCount) {
|
||||||
|
PrintWriter out = common.spec.commandLine().getOut();
|
||||||
|
Map<String, Object> latencyMs = percentileSummaryMs(latenciesNanos, latencyCount);
|
||||||
|
if (json) {
|
||||||
|
Map<String, Object> output = new LinkedHashMap<>();
|
||||||
|
output.put("language", "java");
|
||||||
|
output.put("command", "bench-read-bulk");
|
||||||
|
output.put("endpoint", common.endpoint);
|
||||||
|
output.put("clientName", clientName);
|
||||||
|
output.put("bulkSize", bulkSize);
|
||||||
|
output.put("durationSeconds", durationSeconds);
|
||||||
|
output.put("warmupSeconds", warmupSeconds);
|
||||||
|
output.put("durationMs", steadyElapsedNanos / 1_000_000L);
|
||||||
|
output.put("tags", tags);
|
||||||
|
output.put("totalCalls", totalCalls);
|
||||||
|
output.put("successfulCalls", successful);
|
||||||
|
output.put("failedCalls", failed);
|
||||||
|
output.put("totalReadResults", totalResults);
|
||||||
|
output.put("cachedReadResults", cachedResults);
|
||||||
|
output.put("callsPerSecond", roundTo(callsPerSecond, 2));
|
||||||
|
output.put("latencyMs", latencyMs);
|
||||||
|
out.println(jsonObject(output));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
out.println(callsPerSecond);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Object> percentileSummaryMs(long[] latenciesNanos, int count) {
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
if (count == 0) {
|
||||||
|
result.put("p50", 0.0);
|
||||||
|
result.put("p95", 0.0);
|
||||||
|
result.put("p99", 0.0);
|
||||||
|
result.put("max", 0.0);
|
||||||
|
result.put("mean", 0.0);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
long[] sorted = new long[count];
|
||||||
|
System.arraycopy(latenciesNanos, 0, sorted, 0, count);
|
||||||
|
java.util.Arrays.sort(sorted);
|
||||||
|
double sumMs = 0.0;
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
sumMs += sorted[i] / 1_000_000.0;
|
||||||
|
}
|
||||||
|
result.put("p50", roundTo(percentileMs(sorted, 0.50), 3));
|
||||||
|
result.put("p95", roundTo(percentileMs(sorted, 0.95), 3));
|
||||||
|
result.put("p99", roundTo(percentileMs(sorted, 0.99), 3));
|
||||||
|
result.put("max", roundTo(sorted[count - 1] / 1_000_000.0, 3));
|
||||||
|
result.put("mean", roundTo(sumMs / count, 3));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double percentileMs(long[] sorted, double quantile) {
|
||||||
|
int n = sorted.length;
|
||||||
|
if (n == 0) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
if (n == 1) {
|
||||||
|
return sorted[0] / 1_000_000.0;
|
||||||
|
}
|
||||||
|
double rank = quantile * (n - 1);
|
||||||
|
int lower = (int) Math.floor(rank);
|
||||||
|
int upper = Math.min(lower + 1, n - 1);
|
||||||
|
double fraction = rank - lower;
|
||||||
|
double lowerMs = sorted[lower] / 1_000_000.0;
|
||||||
|
double upperMs = sorted[upper] / 1_000_000.0;
|
||||||
|
return lowerMs + (upperMs - lowerMs) * fraction;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double roundTo(double value, int digits) {
|
||||||
|
double shift = Math.pow(10, digits);
|
||||||
|
return Math.round(value * shift) / shift;
|
||||||
|
}
|
||||||
|
|
||||||
@Command(name = "write", description = "Invokes MXAccess Write.")
|
@Command(name = "write", description = "Invokes MXAccess Write.")
|
||||||
static final class WriteCommand extends GatewayCommand {
|
static final class WriteCommand extends GatewayCommand {
|
||||||
@Option(names = "--session-id", required = true, description = "Gateway session id.")
|
@Option(names = "--session-id", required = true, description = "Gateway session id.")
|
||||||
@@ -1158,7 +1151,13 @@ public final class MxGatewayCli implements Callable<Integer> {
|
|||||||
if (json) {
|
if (json) {
|
||||||
client.out().println(protoJson(event));
|
client.out().println(protoJson(event));
|
||||||
} else {
|
} else {
|
||||||
client.out().printf("%d %s%n", event.getWorkerSequence(), event.getFamily());
|
// worker_sequence is a proto uint64 — print as unsigned so
|
||||||
|
// values past 2^63 do not render as negative signed longs.
|
||||||
|
// JSON path goes through JsonFormat which already does this.
|
||||||
|
client.out().printf(
|
||||||
|
"%s %s%n",
|
||||||
|
Long.toUnsignedString(event.getWorkerSequence()),
|
||||||
|
event.getFamily());
|
||||||
}
|
}
|
||||||
count++;
|
count++;
|
||||||
if (limit > 0 && count >= limit) {
|
if (limit > 0 && count >= limit) {
|
||||||
@@ -1194,29 +1193,11 @@ public final class MxGatewayCli implements Callable<Integer> {
|
|||||||
StreamAlarmsRequest request = StreamAlarmsRequest.newBuilder()
|
StreamAlarmsRequest request = StreamAlarmsRequest.newBuilder()
|
||||||
.setAlarmFilterPrefix(filterPrefix)
|
.setAlarmFilterPrefix(filterPrefix)
|
||||||
.build();
|
.build();
|
||||||
// Client.Java-033 — fail-fast on overflow. A bare
|
|
||||||
// queue.offer(value) silently drops messages past capacity,
|
|
||||||
// which violates the JavaStyleGuide "do not drop events"
|
|
||||||
// contract and lets the CLI exit 0 on a truncated feed.
|
|
||||||
// Mirrors MxEventStream's overflow branch: detect a failed
|
|
||||||
// offer, cancel the subscription, drain the buffer, then
|
|
||||||
// queue an explicit overflow exception followed by the END
|
|
||||||
// sentinel so the drain loop surfaces a non-zero exit.
|
|
||||||
AtomicReference<MxGatewayAlarmFeedSubscription> subscriptionRef = new AtomicReference<>();
|
|
||||||
MxGatewayAlarmFeedSubscription subscription =
|
MxGatewayAlarmFeedSubscription subscription =
|
||||||
client.streamAlarms(request, new StreamObserver<>() {
|
client.streamAlarms(request, new StreamObserver<>() {
|
||||||
@Override
|
@Override
|
||||||
public void onNext(AlarmFeedMessage value) {
|
public void onNext(AlarmFeedMessage value) {
|
||||||
if (!queue.offer(value)) {
|
queue.offer(value);
|
||||||
MxGatewayAlarmFeedSubscription sub = subscriptionRef.get();
|
|
||||||
if (sub != null) {
|
|
||||||
sub.cancel();
|
|
||||||
}
|
|
||||||
queue.clear();
|
|
||||||
queue.offer(new IllegalStateException(
|
|
||||||
"stream-alarms queue overflowed (capacity 1024); consumer too slow"));
|
|
||||||
queue.offer(ALARM_FEED_END);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -1229,7 +1210,6 @@ public final class MxGatewayCli implements Callable<Integer> {
|
|||||||
queue.offer(ALARM_FEED_END);
|
queue.offer(ALARM_FEED_END);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
subscriptionRef.set(subscription);
|
|
||||||
try {
|
try {
|
||||||
int count = 0;
|
int count = 0;
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -1369,38 +1349,93 @@ public final class MxGatewayCli implements Callable<Integer> {
|
|||||||
@Option(names = "--timeout", defaultValue = "30s", description = "Per-call timeout.")
|
@Option(names = "--timeout", defaultValue = "30s", description = "Per-call timeout.")
|
||||||
String timeout;
|
String timeout;
|
||||||
|
|
||||||
private String resolvedApiKey = "";
|
@Option(
|
||||||
private Duration resolvedTimeout = Duration.ofSeconds(30);
|
names = "--shutdown-timeout",
|
||||||
|
description =
|
||||||
|
"Channel shutdown timeout (e.g. 10s, 500ms). When unset, the library default applies.")
|
||||||
|
String shutdownTimeout;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns this options object unchanged.
|
||||||
|
*
|
||||||
|
* <p>Retained as a no-op for call sites that read more naturally as
|
||||||
|
* {@code common.resolved()}. Resolution of the API key and timeout is
|
||||||
|
* computed lazily on demand by {@link #resolvedApiKey()} and
|
||||||
|
* {@link #resolvedTimeout()}, so {@link #toClientOptions()} and
|
||||||
|
* {@link #redactedJsonMap()} produce correct output regardless of
|
||||||
|
* whether this method was ever called.
|
||||||
|
*
|
||||||
|
* @return this options object
|
||||||
|
*/
|
||||||
CommonOptions resolved() {
|
CommonOptions resolved() {
|
||||||
resolvedApiKey = apiKey == null || apiKey.isBlank() ? System.getenv(apiKeyEnv) : apiKey;
|
|
||||||
if (resolvedApiKey == null) {
|
|
||||||
resolvedApiKey = "";
|
|
||||||
}
|
|
||||||
resolvedTimeout = parseDuration(timeout);
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the effective API key: the explicit {@code --api-key} value
|
||||||
|
* when non-blank, otherwise the value of the {@code --api-key-env}
|
||||||
|
* environment variable, otherwise an empty string. Computed on each
|
||||||
|
* call so there is no stale cached state.
|
||||||
|
*
|
||||||
|
* @return the resolved API key, never {@code null}
|
||||||
|
*/
|
||||||
|
String resolvedApiKey() {
|
||||||
|
String resolved = apiKey == null || apiKey.isBlank() ? System.getenv(apiKeyEnv) : apiKey;
|
||||||
|
return resolved == null ? "" : resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the effective per-call timeout from the {@code --timeout}
|
||||||
|
* option. Computed on each call so there is no stale cached state.
|
||||||
|
*
|
||||||
|
* @return the resolved call timeout
|
||||||
|
*/
|
||||||
|
Duration resolvedTimeout() {
|
||||||
|
return parseDuration(timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the effective channel-shutdown timeout from the
|
||||||
|
* {@code --shutdown-timeout} option, or {@code null} when the user did
|
||||||
|
* not pass one (in which case the {@link MxGatewayClientOptions}
|
||||||
|
* default applies). Computed on each call so there is no stale cached
|
||||||
|
* state.
|
||||||
|
*
|
||||||
|
* @return the resolved shutdown timeout, or {@code null} when unset
|
||||||
|
*/
|
||||||
|
Duration resolvedShutdownTimeout() {
|
||||||
|
if (shutdownTimeout == null || shutdownTimeout.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parseDuration(shutdownTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
MxGatewayClientOptions toClientOptions() {
|
MxGatewayClientOptions toClientOptions() {
|
||||||
return MxGatewayClientOptions.builder()
|
MxGatewayClientOptions.Builder builder = MxGatewayClientOptions.builder()
|
||||||
.endpoint(endpoint)
|
.endpoint(endpoint)
|
||||||
.apiKey(resolvedApiKey)
|
.apiKey(resolvedApiKey())
|
||||||
.plaintext(plaintext)
|
.plaintext(plaintext)
|
||||||
.caCertificatePath(caFile)
|
.caCertificatePath(caFile)
|
||||||
.serverNameOverride(serverNameOverride)
|
.serverNameOverride(serverNameOverride)
|
||||||
.callTimeout(resolvedTimeout)
|
.callTimeout(resolvedTimeout());
|
||||||
.build();
|
Duration resolvedShutdownTimeout = resolvedShutdownTimeout();
|
||||||
|
if (resolvedShutdownTimeout != null) {
|
||||||
|
builder.shutdownTimeout(resolvedShutdownTimeout);
|
||||||
|
}
|
||||||
|
return builder.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, Object> redactedJsonMap() {
|
Map<String, Object> redactedJsonMap() {
|
||||||
Map<String, Object> values = new LinkedHashMap<>();
|
Map<String, Object> values = new LinkedHashMap<>();
|
||||||
values.put("endpoint", endpoint);
|
values.put("endpoint", endpoint);
|
||||||
values.put("apiKey", MxGatewaySecrets.redactApiKey(resolvedApiKey));
|
values.put("apiKey", MxGatewaySecrets.redactApiKey(resolvedApiKey()));
|
||||||
values.put("apiKeyEnv", apiKeyEnv);
|
values.put("apiKeyEnv", apiKeyEnv);
|
||||||
values.put("plaintext", plaintext);
|
values.put("plaintext", plaintext);
|
||||||
values.put("caFile", caFile == null ? "" : caFile.toString());
|
values.put("caFile", caFile == null ? "" : caFile.toString());
|
||||||
values.put("serverNameOverride", serverNameOverride);
|
values.put("serverNameOverride", serverNameOverride);
|
||||||
values.put("timeout", timeout);
|
values.put("timeout", timeout);
|
||||||
|
Duration resolvedShutdownTimeout = resolvedShutdownTimeout();
|
||||||
|
values.put("shutdownTimeout", resolvedShutdownTimeout == null ? "" : resolvedShutdownTimeout.toString());
|
||||||
return values;
|
return values;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1446,7 +1481,7 @@ public final class MxGatewayCli implements Callable<Integer> {
|
|||||||
|
|
||||||
List<SubscribeResult> unsubscribeBulk(int serverHandle, List<Integer> itemHandles);
|
List<SubscribeResult> unsubscribeBulk(int serverHandle, List<Integer> itemHandles);
|
||||||
|
|
||||||
List<BulkReadResult> readBulk(int serverHandle, List<String> items, Duration timeout);
|
List<BulkReadResult> readBulk(int serverHandle, List<String> items, int timeoutMs);
|
||||||
|
|
||||||
List<BulkWriteResult> writeBulk(int serverHandle, List<WriteBulkEntry> entries);
|
List<BulkWriteResult> writeBulk(int serverHandle, List<WriteBulkEntry> entries);
|
||||||
|
|
||||||
@@ -1559,8 +1594,8 @@ public final class MxGatewayCli implements Callable<Integer> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<BulkReadResult> readBulk(int serverHandle, List<String> items, Duration timeout) {
|
public List<BulkReadResult> readBulk(int serverHandle, List<String> items, int timeoutMs) {
|
||||||
return session.readBulk(serverHandle, items, timeout);
|
return session.readBulk(serverHandle, items, timeoutMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
+302
-219
@@ -1,17 +1,16 @@
|
|||||||
package com.zb.mom.ww.mxgateway.cli;
|
package com.dohertylan.mxgateway.cli;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
import com.zb.mom.ww.mxgateway.client.MxGatewayAlarmFeedSubscription;
|
|
||||||
import io.grpc.stub.StreamObserver;
|
|
||||||
import java.io.ByteArrayInputStream;
|
import java.io.ByteArrayInputStream;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.io.PrintWriter;
|
import java.io.PrintWriter;
|
||||||
import java.io.StringWriter;
|
import java.io.StringWriter;
|
||||||
|
import com.dohertylan.mxgateway.client.MxGatewayAlarmFeedSubscription;
|
||||||
|
import io.grpc.stub.StreamObserver;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.time.Duration;
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply;
|
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply;
|
||||||
@@ -33,10 +32,10 @@ import mxaccess_gateway.v1.MxaccessGateway.OnAlarmTransitionEvent;
|
|||||||
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply;
|
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest;
|
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
|
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
|
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.RegisterReply;
|
import mxaccess_gateway.v1.MxaccessGateway.RegisterReply;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.SessionState;
|
import mxaccess_gateway.v1.MxaccessGateway.SessionState;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest;
|
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.SubscribeResult;
|
import mxaccess_gateway.v1.MxaccessGateway.SubscribeResult;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.Write2BulkEntry;
|
import mxaccess_gateway.v1.MxaccessGateway.Write2BulkEntry;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.WriteBulkEntry;
|
import mxaccess_gateway.v1.MxaccessGateway.WriteBulkEntry;
|
||||||
@@ -82,8 +81,10 @@ final class MxGatewayCliTests {
|
|||||||
assertEquals(0, run.exitCode());
|
assertEquals(0, run.exitCode());
|
||||||
assertTrue(run.output().contains("\"command\":\"open-session\""));
|
assertTrue(run.output().contains("\"command\":\"open-session\""));
|
||||||
assertTrue(run.output().contains("\"sessionId\":\"session-cli\""));
|
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("visible_secret"));
|
||||||
|
assertFalse(run.output().contains("cret"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -142,6 +143,40 @@ final class MxGatewayCliTests {
|
|||||||
assertTrue(run.output().contains("\"tagAddress\":\"TestMachine_002.TestChangingInt\""));
|
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
|
@Test
|
||||||
void unsubscribeBulkCommandPrintsResults() {
|
void unsubscribeBulkCommandPrintsResults() {
|
||||||
CliRun run = execute(
|
CliRun run = execute(
|
||||||
@@ -161,6 +196,209 @@ final class MxGatewayCliTests {
|
|||||||
assertTrue(run.output().contains("\"wasSuccessful\":true"));
|
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 ----
|
// ---- stream-alarms / acknowledge-alarm subcommands ----
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -225,175 +463,73 @@ final class MxGatewayCliTests {
|
|||||||
assertTrue(run.errors().contains("--reference"), run.errors());
|
assertTrue(run.errors().contains("--reference"), run.errors());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
// ---- Client.Java-027: batch subcommand ----
|
||||||
void readmeDocumentedStreamAlarmsExampleParsesCleanly() {
|
|
||||||
// Client.Java-032 regression — the README's stream-alarms example
|
|
||||||
// (clients/java/README.md:182) must round-trip through picocli's
|
|
||||||
// parser without a parse error. Before the fix, the example used
|
|
||||||
// a non-existent --session-id option and picocli failed at parse
|
|
||||||
// time. This test pins the exact tokens documented in the README.
|
|
||||||
String[] args = {
|
|
||||||
"stream-alarms",
|
|
||||||
"--endpoint",
|
|
||||||
"localhost:5000",
|
|
||||||
"--api-key-env",
|
|
||||||
"MXGATEWAY_API_KEY",
|
|
||||||
"--plaintext",
|
|
||||||
"--filter-prefix",
|
|
||||||
"Galaxy",
|
|
||||||
"--limit",
|
|
||||||
"1",
|
|
||||||
"--json"
|
|
||||||
};
|
|
||||||
assertReadmeExampleParses(args);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void readmeDocumentedAcknowledgeAlarmExampleParsesCleanly() {
|
void batchCommandExecutesTwoCommandsAndEmitsEorAfterEach() {
|
||||||
// Client.Java-032 regression — the README's acknowledge-alarm
|
String stdin = "version --json\nversion --json\n";
|
||||||
// example (clients/java/README.md:183) must parse without error.
|
CliRun run = executeBatch(new FakeClientFactory(), stdin);
|
||||||
// Before the fix it used --session-id (no such option) and
|
|
||||||
// --alarm-reference (the real option is --reference), so picocli
|
|
||||||
// rejected the invocation immediately.
|
|
||||||
String[] args = {
|
|
||||||
"acknowledge-alarm",
|
|
||||||
"--endpoint",
|
|
||||||
"localhost:5000",
|
|
||||||
"--api-key-env",
|
|
||||||
"MXGATEWAY_API_KEY",
|
|
||||||
"--plaintext",
|
|
||||||
"--reference",
|
|
||||||
"\\Galaxy\\Area001.Pump001.PumpFault",
|
|
||||||
"--json"
|
|
||||||
};
|
|
||||||
assertReadmeExampleParses(args);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses the given args through the production picocli {@link CommandLine}
|
|
||||||
* and asserts no parser error, no unknown option, and no missing required
|
|
||||||
* option. Does not execute the command body — only the option / subcommand
|
|
||||||
* parser is exercised, so no network call is made.
|
|
||||||
*/
|
|
||||||
private static void assertReadmeExampleParses(String[] args) {
|
|
||||||
picocli.CommandLine commandLine = MxGatewayCli.commandLine(new FakeClientFactory());
|
|
||||||
try {
|
|
||||||
commandLine.parseArgs(args);
|
|
||||||
} catch (picocli.CommandLine.ParameterException ex) {
|
|
||||||
throw new AssertionError(
|
|
||||||
"documented README invocation failed picocli parse: "
|
|
||||||
+ String.join(" ", args)
|
|
||||||
+ " -> "
|
|
||||||
+ ex.getMessage(),
|
|
||||||
ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void streamAlarmsCommandFailsFastOnQueueOverflow() {
|
|
||||||
// Client.Java-033 regression — the CLI's stream-alarms bounded queue
|
|
||||||
// used queue.offer(value) which silently dropped messages past
|
|
||||||
// capacity (1024). After the fix the CLI must surface the overflow
|
|
||||||
// as a non-zero exit (mirroring MxEventStream's fail-fast contract).
|
|
||||||
//
|
|
||||||
// The OverflowingFakeClient floods the gRPC observer with 2000
|
|
||||||
// messages synchronously, which exceeds the bounded 1024-element
|
|
||||||
// queue. The fix detects the failed offer, cancels the subscription,
|
|
||||||
// queues an overflow exception, and the drain loop surfaces it.
|
|
||||||
OverflowingFakeClientFactory factory = new OverflowingFakeClientFactory();
|
|
||||||
CliRun run = execute(factory, "stream-alarms", "--filter-prefix", "Flood");
|
|
||||||
|
|
||||||
assertFalse(run.exitCode() == 0,
|
|
||||||
"expected non-zero exit when the alarm queue overflows; got exit=" + run.exitCode()
|
|
||||||
+ " out=\n" + run.output() + "\nerr=\n" + run.errors());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void batchCommandExecutesVersionAndEmitsEorMarker() {
|
|
||||||
CliRun run = executeBatch(new FakeClientFactory(), "version --json\n");
|
|
||||||
|
|
||||||
assertEquals(0, run.exitCode());
|
assertEquals(0, run.exitCode());
|
||||||
String out = run.output();
|
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);
|
assertTrue(out.contains("\"clientVersion\""), out);
|
||||||
assertTrue(out.contains(MxGatewayCli.BATCH_EOR), out);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void batchCommandTokenisesDoubleQuotedArgumentWithEmbeddedSpaces() {
|
void batchCommandEmitsEorOnFailedCommand() {
|
||||||
// Client.Java-034 regression — a real shell-style tokenizer must not
|
// "open-session" without --endpoint / --api-key-env will fail against
|
||||||
// shred `"needs verification"` into two arguments. Drives
|
// the FakeClientFactory (missing required option --session-id for
|
||||||
// acknowledge-alarm through batch and asserts the captured --comment
|
// close-session, for example). Use an unknown subcommand to provoke a
|
||||||
// is the un-quoted string with the embedded space preserved.
|
// picocli parse error which produces a non-zero exit code without
|
||||||
FakeClientFactory factory = new FakeClientFactory();
|
// hitting the gateway.
|
||||||
String line = "acknowledge-alarm --reference Tank01.Level.HiHi --comment \"needs verification\" --operator op1\n";
|
String stdin = "no-such-subcommand\nversion --json\n";
|
||||||
CliRun run = executeBatch(factory, line);
|
CliRun run = executeBatch(new FakeClientFactory(), stdin);
|
||||||
|
|
||||||
assertEquals(0, run.exitCode());
|
assertEquals(0, run.exitCode());
|
||||||
assertEquals("needs verification", factory.client.lastAcknowledgeAlarmRequest.getComment());
|
String out = run.output();
|
||||||
assertEquals("op1", factory.client.lastAcknowledgeAlarmRequest.getOperatorUser());
|
// Two EOR sentinels even though the first command failed.
|
||||||
assertEquals(
|
int firstEor = out.indexOf(MxGatewayCli.BATCH_EOR);
|
||||||
"Tank01.Level.HiHi", factory.client.lastAcknowledgeAlarmRequest.getAlarmFullReference());
|
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
|
@Test
|
||||||
void batchCommandTokenisesSingleQuotedArgumentWithEmbeddedSpaces() {
|
void batchCommandExitsZeroOnEmptyLine() {
|
||||||
FakeClientFactory factory = new FakeClientFactory();
|
// An empty line signals EOF-equivalent; loop exits immediately.
|
||||||
String line =
|
CliRun run = executeBatch(new FakeClientFactory(), "\n");
|
||||||
"acknowledge-alarm --reference Tank01.Level.HiHi --comment 'needs verification' --operator op1\n";
|
|
||||||
CliRun run = executeBatch(factory, line);
|
|
||||||
|
|
||||||
assertEquals(0, run.exitCode());
|
assertEquals(0, run.exitCode());
|
||||||
assertEquals("needs verification", factory.client.lastAcknowledgeAlarmRequest.getComment());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void batchCommandTokenisesBackslashEscapedSpaceOutsideQuotes() {
|
void batchCommandExitsZeroOnActualEof() {
|
||||||
FakeClientFactory factory = new FakeClientFactory();
|
CliRun run = executeBatch(new FakeClientFactory(), "");
|
||||||
String line =
|
|
||||||
"acknowledge-alarm --reference Tank01.Level.HiHi --comment needs\\ verification\n";
|
|
||||||
CliRun run = executeBatch(factory, line);
|
|
||||||
|
|
||||||
assertEquals(0, run.exitCode());
|
assertEquals(0, run.exitCode());
|
||||||
assertEquals("needs verification", factory.client.lastAcknowledgeAlarmRequest.getComment());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void batchCommandPreservesEmptyQuotedArgument() {
|
void batchCommandDoesNotTerminateAfterFailedCommand() {
|
||||||
FakeClientFactory factory = new FakeClientFactory();
|
// Three lines: good, bad, good — all three EORs must appear and the
|
||||||
String line = "acknowledge-alarm --reference Tank01.Level.HiHi --comment \"\"\n";
|
// third command must produce its output.
|
||||||
CliRun run = executeBatch(factory, line);
|
String stdin = "version --json\nno-such-subcommand\nversion --json\n";
|
||||||
|
CliRun run = executeBatch(new FakeClientFactory(), stdin);
|
||||||
assertEquals(0, run.exitCode());
|
|
||||||
assertEquals("", factory.client.lastAcknowledgeAlarmRequest.getComment());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void batchCommandSupportsBackslashEscapedQuoteInsideDoubleQuotes() {
|
|
||||||
// `--comment "with \"inner\" quote"` should round-trip the inner
|
|
||||||
// double-quote into the comment string.
|
|
||||||
FakeClientFactory factory = new FakeClientFactory();
|
|
||||||
String line =
|
|
||||||
"acknowledge-alarm --reference Tank01.Level.HiHi --comment \"with \\\"inner\\\" quote\"\n";
|
|
||||||
CliRun run = executeBatch(factory, line);
|
|
||||||
|
|
||||||
assertEquals(0, run.exitCode());
|
|
||||||
assertEquals("with \"inner\" quote", factory.client.lastAcknowledgeAlarmRequest.getComment());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void batchCommandEmitsEorAfterFailedCommandAndContinues() {
|
|
||||||
// An unknown subcommand causes a picocli parse error (non-zero exit).
|
|
||||||
// The loop must still emit BATCH_EOR for the failure and continue
|
|
||||||
// processing the subsequent valid command.
|
|
||||||
CliRun run = executeBatch(new FakeClientFactory(), "no-such-subcommand\nversion --json\n");
|
|
||||||
|
|
||||||
assertEquals(0, run.exitCode());
|
assertEquals(0, run.exitCode());
|
||||||
String out = run.output();
|
String out = run.output();
|
||||||
long eorCount = out.lines()
|
long eorCount = out.lines()
|
||||||
.filter(l -> l.equals(MxGatewayCli.BATCH_EOR))
|
.filter(l -> l.equals(MxGatewayCli.BATCH_EOR))
|
||||||
.count();
|
.count();
|
||||||
assertEquals(2, eorCount, "expected exactly 2 EOR sentinels, got: " + eorCount + "\nOutput:\n" + out);
|
assertEquals(3, eorCount, "expected exactly 3 EOR sentinels, got: " + eorCount + "\nOutput:\n" + out);
|
||||||
assertTrue(out.contains("\"clientVersion\""), out);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -435,77 +571,6 @@ final class MxGatewayCliTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Factory whose fake client floods the {@code streamAlarms} observer with
|
|
||||||
* 2000 messages synchronously, exceeding the CLI's bounded 1024-element
|
|
||||||
* queue. Used by the Client.Java-033 fail-fast overflow regression.
|
|
||||||
*/
|
|
||||||
private static final class OverflowingFakeClientFactory implements MxGatewayCli.MxGatewayCliClientFactory {
|
|
||||||
@Override
|
|
||||||
public MxGatewayCli.MxGatewayCliClient connect(MxGatewayCli.CommonOptions options) {
|
|
||||||
return new OverflowingFakeClient(options.spec.commandLine().getOut());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final class OverflowingFakeClient implements MxGatewayCli.MxGatewayCliClient {
|
|
||||||
private final PrintWriter out;
|
|
||||||
|
|
||||||
OverflowingFakeClient(PrintWriter out) {
|
|
||||||
this.out = out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public PrintWriter out() {
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public OpenSessionReply openSession(OpenSessionRequest request) {
|
|
||||||
return OpenSessionReply.newBuilder().setSessionId("flood-session").setProtocolStatus(ok()).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public CloseSessionReply closeSession(CloseSessionRequest request) {
|
|
||||||
return CloseSessionReply.newBuilder()
|
|
||||||
.setSessionId(request.getSessionId())
|
|
||||||
.setFinalState(SessionState.SESSION_STATE_CLOSED)
|
|
||||||
.setProtocolStatus(ok())
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public MxGatewayCli.MxGatewayCliSession session(String sessionId) {
|
|
||||||
throw new UnsupportedOperationException();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public AcknowledgeAlarmReply acknowledgeAlarm(AcknowledgeAlarmRequest request) {
|
|
||||||
throw new UnsupportedOperationException();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public MxGatewayAlarmFeedSubscription streamAlarms(
|
|
||||||
StreamAlarmsRequest request, StreamObserver<AlarmFeedMessage> observer) {
|
|
||||||
// Synchronously push 2000 messages to overflow the CLI's bounded
|
|
||||||
// 1024-element queue. The CLI must surface the overflow rather
|
|
||||||
// than silently dropping the trailing ~976 messages.
|
|
||||||
for (int i = 0; i < 2000; i++) {
|
|
||||||
observer.onNext(AlarmFeedMessage.newBuilder()
|
|
||||||
.setActiveAlarm(ActiveAlarmSnapshot.newBuilder()
|
|
||||||
.setAlarmFullReference("Flood." + i)
|
|
||||||
.setCurrentState(AlarmConditionState.ALARM_CONDITION_STATE_ACTIVE)
|
|
||||||
.setSeverity(700))
|
|
||||||
.build());
|
|
||||||
}
|
|
||||||
observer.onCompleted();
|
|
||||||
return new MxGatewayAlarmFeedSubscription();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void close() {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final class FakeClient implements MxGatewayCli.MxGatewayCliClient {
|
private static final class FakeClient implements MxGatewayCli.MxGatewayCliClient {
|
||||||
private final PrintWriter out;
|
private final PrintWriter out;
|
||||||
private final FakeSession session = new FakeSession();
|
private final FakeSession session = new FakeSession();
|
||||||
@@ -672,8 +737,21 @@ final class MxGatewayCliTests {
|
|||||||
return results;
|
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
|
@Override
|
||||||
public List<BulkReadResult> readBulk(int serverHandle, List<String> items, Duration timeout) {
|
public List<BulkReadResult> readBulk(int serverHandle, List<String> items, int timeoutMs) {
|
||||||
|
lastReadBulkTimeoutMs = timeoutMs;
|
||||||
|
lastReadBulkItems = items;
|
||||||
List<BulkReadResult> results = new ArrayList<>();
|
List<BulkReadResult> results = new ArrayList<>();
|
||||||
for (int index = 0; index < items.size(); index++) {
|
for (int index = 0; index < items.size(); index++) {
|
||||||
results.add(BulkReadResult.newBuilder()
|
results.add(BulkReadResult.newBuilder()
|
||||||
@@ -681,7 +759,8 @@ final class MxGatewayCliTests {
|
|||||||
.setTagAddress(items.get(index))
|
.setTagAddress(items.get(index))
|
||||||
.setItemHandle(200 + index)
|
.setItemHandle(200 + index)
|
||||||
.setWasSuccessful(true)
|
.setWasSuccessful(true)
|
||||||
.setWasCached(true)
|
.setWasCached(index % 2 == 0)
|
||||||
|
.setQuality(192)
|
||||||
.build());
|
.build());
|
||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
@@ -689,6 +768,7 @@ final class MxGatewayCliTests {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<BulkWriteResult> writeBulk(int serverHandle, List<WriteBulkEntry> entries) {
|
public List<BulkWriteResult> writeBulk(int serverHandle, List<WriteBulkEntry> entries) {
|
||||||
|
lastWriteBulkEntries = entries;
|
||||||
List<BulkWriteResult> results = new ArrayList<>();
|
List<BulkWriteResult> results = new ArrayList<>();
|
||||||
for (WriteBulkEntry entry : entries) {
|
for (WriteBulkEntry entry : entries) {
|
||||||
results.add(BulkWriteResult.newBuilder()
|
results.add(BulkWriteResult.newBuilder()
|
||||||
@@ -702,6 +782,7 @@ final class MxGatewayCliTests {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<BulkWriteResult> write2Bulk(int serverHandle, List<Write2BulkEntry> entries) {
|
public List<BulkWriteResult> write2Bulk(int serverHandle, List<Write2BulkEntry> entries) {
|
||||||
|
lastWrite2BulkEntries = entries;
|
||||||
List<BulkWriteResult> results = new ArrayList<>();
|
List<BulkWriteResult> results = new ArrayList<>();
|
||||||
for (Write2BulkEntry entry : entries) {
|
for (Write2BulkEntry entry : entries) {
|
||||||
results.add(BulkWriteResult.newBuilder()
|
results.add(BulkWriteResult.newBuilder()
|
||||||
@@ -715,6 +796,7 @@ final class MxGatewayCliTests {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<BulkWriteResult> writeSecuredBulk(int serverHandle, List<WriteSecuredBulkEntry> entries) {
|
public List<BulkWriteResult> writeSecuredBulk(int serverHandle, List<WriteSecuredBulkEntry> entries) {
|
||||||
|
lastWriteSecuredBulkEntries = entries;
|
||||||
List<BulkWriteResult> results = new ArrayList<>();
|
List<BulkWriteResult> results = new ArrayList<>();
|
||||||
for (WriteSecuredBulkEntry entry : entries) {
|
for (WriteSecuredBulkEntry entry : entries) {
|
||||||
results.add(BulkWriteResult.newBuilder()
|
results.add(BulkWriteResult.newBuilder()
|
||||||
@@ -728,6 +810,7 @@ final class MxGatewayCliTests {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<BulkWriteResult> writeSecured2Bulk(int serverHandle, List<WriteSecured2BulkEntry> entries) {
|
public List<BulkWriteResult> writeSecured2Bulk(int serverHandle, List<WriteSecured2BulkEntry> entries) {
|
||||||
|
lastWriteSecured2BulkEntries = entries;
|
||||||
List<BulkWriteResult> results = new ArrayList<>();
|
List<BulkWriteResult> results = new ArrayList<>();
|
||||||
for (WriteSecured2BulkEntry entry : entries) {
|
for (WriteSecured2BulkEntry entry : entries) {
|
||||||
results.add(BulkWriteResult.newBuilder()
|
results.add(BulkWriteResult.newBuilder()
|
||||||
@@ -740,7 +823,7 @@ final class MxGatewayCliTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public com.zb.mom.ww.mxgateway.client.MxEventStream streamEventsAfter(long afterWorkerSequence) {
|
public com.dohertylan.mxgateway.client.MxEventStream streamEventsAfter(long afterWorkerSequence) {
|
||||||
throw new UnsupportedOperationException("stream-events is covered by client tests");
|
throw new UnsupportedOperationException("stream-events is covered by client tests");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-7
@@ -1,7 +1,6 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id 'java-library'
|
id 'java-library'
|
||||||
id 'com.google.protobuf'
|
id 'com.google.protobuf'
|
||||||
id 'maven-publish'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
@@ -23,7 +22,7 @@ dependencies {
|
|||||||
sourceSets {
|
sourceSets {
|
||||||
main {
|
main {
|
||||||
proto {
|
proto {
|
||||||
srcDir rootProject.file('../../src/ZB.MOM.WW.MxGateway.Contracts/Protos')
|
srcDir rootProject.file('../../src/MxGateway.Contracts/Protos')
|
||||||
include 'mxaccess_gateway.proto'
|
include 'mxaccess_gateway.proto'
|
||||||
include 'mxaccess_worker.proto'
|
include 'mxaccess_worker.proto'
|
||||||
include 'galaxy_repository.proto'
|
include 'galaxy_repository.proto'
|
||||||
@@ -31,11 +30,6 @@ sourceSets {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
java {
|
|
||||||
withSourcesJar()
|
|
||||||
withJavadocJar()
|
|
||||||
}
|
|
||||||
|
|
||||||
protobuf {
|
protobuf {
|
||||||
protoc {
|
protoc {
|
||||||
artifact = "com.google.protobuf:protoc:${protobufVersion}"
|
artifact = "com.google.protobuf:protoc:${protobufVersion}"
|
||||||
+49
-12
@@ -1,4 +1,4 @@
|
|||||||
package com.zb.mom.ww.mxgateway.client;
|
package com.dohertylan.mxgateway.client;
|
||||||
|
|
||||||
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
|
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
|
||||||
import galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest;
|
import galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest;
|
||||||
@@ -11,20 +11,29 @@ import java.util.NoSuchElementException;
|
|||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.concurrent.ArrayBlockingQueue;
|
import java.util.concurrent.ArrayBlockingQueue;
|
||||||
import java.util.concurrent.BlockingQueue;
|
import java.util.concurrent.BlockingQueue;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Iterator-style adaptor over the {@code WatchDeployEvents} server-streaming
|
* Iterator-style adaptor over the {@code WatchDeployEvents} server-streaming
|
||||||
* RPC. Mirrors {@link MxEventStream}: events arrive on a background gRPC thread
|
* RPC. Mirrors {@link MxEventStream}: events arrive on a background gRPC thread
|
||||||
* and are buffered in a bounded blocking queue; the iterator drains them.
|
* and are buffered in a bounded blocking queue; the iterator drains them.
|
||||||
* Closing the stream cancels the underlying gRPC call.
|
* 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 {
|
public final class DeployEventStream implements Iterator<DeployEvent>, AutoCloseable {
|
||||||
private static final Object END = new Object();
|
private static final Object END = new Object();
|
||||||
|
|
||||||
private final BlockingQueue<Object> queue;
|
private final BlockingQueue<Object> queue;
|
||||||
private final AtomicBoolean closed = new AtomicBoolean();
|
private final Object terminalLock = new Object();
|
||||||
private volatile ClientCallStreamObserver<WatchDeployEventsRequest> requestStream;
|
private volatile ClientCallStreamObserver<WatchDeployEventsRequest> requestStream;
|
||||||
|
private volatile boolean closed;
|
||||||
|
private boolean terminated;
|
||||||
private Object next;
|
private Object next;
|
||||||
|
|
||||||
DeployEventStream(int capacity) {
|
DeployEventStream(int capacity) {
|
||||||
@@ -36,7 +45,7 @@ public final class DeployEventStream implements Iterator<DeployEvent>, AutoClose
|
|||||||
@Override
|
@Override
|
||||||
public void beforeStart(ClientCallStreamObserver<WatchDeployEventsRequest> requestStream) {
|
public void beforeStart(ClientCallStreamObserver<WatchDeployEventsRequest> requestStream) {
|
||||||
DeployEventStream.this.requestStream = requestStream;
|
DeployEventStream.this.requestStream = requestStream;
|
||||||
if (closed.get()) {
|
if (closed) {
|
||||||
requestStream.cancel("client cancelled deploy event stream", null);
|
requestStream.cancel("client cancelled deploy event stream", null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,7 +57,7 @@ public final class DeployEventStream implements Iterator<DeployEvent>, AutoClose
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onError(Throwable error) {
|
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);
|
offer(END);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -94,12 +103,12 @@ public final class DeployEventStream implements Iterator<DeployEvent>, AutoClose
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void close() {
|
public void close() {
|
||||||
closed.set(true);
|
closed = true;
|
||||||
ClientCallStreamObserver<WatchDeployEventsRequest> stream = requestStream;
|
ClientCallStreamObserver<WatchDeployEventsRequest> stream = requestStream;
|
||||||
if (stream != null) {
|
if (stream != null) {
|
||||||
stream.cancel("client cancelled deploy event stream", null);
|
stream.cancel("client cancelled deploy event stream", null);
|
||||||
}
|
}
|
||||||
offer(END);
|
terminate(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Object take() {
|
private Object take() {
|
||||||
@@ -117,10 +126,7 @@ public final class DeployEventStream implements Iterator<DeployEvent>, AutoClose
|
|||||||
private void offer(Object value) {
|
private void offer(Object value) {
|
||||||
Objects.requireNonNull(value, "value");
|
Objects.requireNonNull(value, "value");
|
||||||
if (value == END) {
|
if (value == END) {
|
||||||
if (!queue.offer(value)) {
|
terminate(null);
|
||||||
queue.clear();
|
|
||||||
queue.offer(value);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!queue.offer(value)) {
|
if (!queue.offer(value)) {
|
||||||
@@ -128,9 +134,40 @@ public final class DeployEventStream implements Iterator<DeployEvent>, AutoClose
|
|||||||
if (stream != null) {
|
if (stream != null) {
|
||||||
stream.cancel("client deploy event stream queue overflowed", null);
|
stream.cancel("client deploy event stream queue overflowed", null);
|
||||||
}
|
}
|
||||||
|
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.clear();
|
||||||
queue.offer(new MxGatewayException("galaxy watch deploy events queue overflowed"));
|
|
||||||
queue.offer(END);
|
queue.offer(END);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
package com.dohertylan.mxgateway.client;
|
||||||
|
|
||||||
|
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
|
||||||
|
import galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancellable handle returned by the async {@code watchDeployEvents} variant.
|
||||||
|
* Mirrors {@link MxGatewayEventSubscription} but for the Galaxy Repository
|
||||||
|
* deploy-event stream.
|
||||||
|
*/
|
||||||
|
public final class DeployEventSubscription implements AutoCloseable {
|
||||||
|
private final AtomicReference<ClientCallStreamObserver<WatchDeployEventsRequest>> requestStream =
|
||||||
|
new AtomicReference<>();
|
||||||
|
private final AtomicBoolean cancelled = new AtomicBoolean();
|
||||||
|
|
||||||
|
ClientResponseObserver<WatchDeployEventsRequest, DeployEvent> wrap(StreamObserver<DeployEvent> observer) {
|
||||||
|
return new ClientResponseObserver<>() {
|
||||||
|
@Override
|
||||||
|
public void beforeStart(ClientCallStreamObserver<WatchDeployEventsRequest> stream) {
|
||||||
|
requestStream.set(stream);
|
||||||
|
if (cancelled.get()) {
|
||||||
|
stream.cancel("client cancelled deploy event stream", null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onNext(DeployEvent 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<WatchDeployEventsRequest> stream = requestStream.get();
|
||||||
|
if (stream != null) {
|
||||||
|
stream.cancel("client cancelled deploy event stream", null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
-195
@@ -1,11 +1,6 @@
|
|||||||
package com.zb.mom.ww.mxgateway.client;
|
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.GalaxyRepositoryGrpc;
|
||||||
import galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply;
|
|
||||||
import galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest;
|
|
||||||
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
|
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
|
||||||
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply;
|
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply;
|
||||||
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest;
|
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest;
|
||||||
@@ -19,8 +14,6 @@ import com.google.protobuf.Timestamp;
|
|||||||
import io.grpc.Channel;
|
import io.grpc.Channel;
|
||||||
import io.grpc.ClientInterceptors;
|
import io.grpc.ClientInterceptors;
|
||||||
import io.grpc.ManagedChannel;
|
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 io.grpc.stub.StreamObserver;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.Iterator;
|
import java.util.Iterator;
|
||||||
@@ -28,8 +21,7 @@ import java.util.List;
|
|||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
import javax.net.ssl.SSLException;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Thin wrapper around the generated {@link GalaxyRepositoryGrpc} stubs that
|
* Thin wrapper around the generated {@link GalaxyRepositoryGrpc} stubs that
|
||||||
@@ -39,7 +31,6 @@ import javax.net.ssl.SSLException;
|
|||||||
*/
|
*/
|
||||||
public final class GalaxyRepositoryClient implements AutoCloseable {
|
public final class GalaxyRepositoryClient implements AutoCloseable {
|
||||||
private static final int DISCOVER_HIERARCHY_PAGE_SIZE = 5000;
|
private static final int DISCOVER_HIERARCHY_PAGE_SIZE = 5000;
|
||||||
private static final int BROWSE_CHILDREN_PAGE_SIZE = 500;
|
|
||||||
|
|
||||||
private final ManagedChannel ownedChannel;
|
private final ManagedChannel ownedChannel;
|
||||||
private final MxGatewayClientOptions options;
|
private final MxGatewayClientOptions options;
|
||||||
@@ -81,7 +72,8 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
|
|||||||
* @return a connected client
|
* @return a connected client
|
||||||
*/
|
*/
|
||||||
public static GalaxyRepositoryClient connect(MxGatewayClientOptions options) {
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -90,7 +82,7 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
|
|||||||
* @return the blocking stub
|
* @return the blocking stub
|
||||||
*/
|
*/
|
||||||
public GalaxyRepositoryGrpc.GalaxyRepositoryBlockingStub rawBlockingStub() {
|
public GalaxyRepositoryGrpc.GalaxyRepositoryBlockingStub rawBlockingStub() {
|
||||||
return withDeadline(blockingStub);
|
return MxGatewayChannels.withDeadline(blockingStub, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,7 +91,7 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
|
|||||||
* @return the future stub
|
* @return the future stub
|
||||||
*/
|
*/
|
||||||
public GalaxyRepositoryGrpc.GalaxyRepositoryFutureStub rawFutureStub() {
|
public GalaxyRepositoryGrpc.GalaxyRepositoryFutureStub rawFutureStub() {
|
||||||
return withDeadline(futureStub);
|
return MxGatewayChannels.withDeadline(futureStub, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -136,8 +128,14 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
|
|||||||
* exceptionally with {@link MxGatewayException} on failure
|
* exceptionally with {@link MxGatewayException} on failure
|
||||||
*/
|
*/
|
||||||
public CompletableFuture<Boolean> testConnectionAsync() {
|
public CompletableFuture<Boolean> testConnectionAsync() {
|
||||||
return toCompletable(rawFutureStub().testConnection(TestConnectionRequest.getDefaultInstance()))
|
// Apply the projection inside toCompletable rather than via .thenApply
|
||||||
.thenApply(TestConnectionReply::getOk);
|
// 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -168,8 +166,10 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
|
|||||||
* completed exceptionally with {@link MxGatewayException} on failure
|
* completed exceptionally with {@link MxGatewayException} on failure
|
||||||
*/
|
*/
|
||||||
public CompletableFuture<Optional<Instant>> getLastDeployTimeAsync() {
|
public CompletableFuture<Optional<Instant>> getLastDeployTimeAsync() {
|
||||||
return toCompletable(rawFutureStub().getLastDeployTime(GetLastDeployTimeRequest.getDefaultInstance()))
|
return MxGatewayChannels.toCompletable(
|
||||||
.thenApply(GalaxyRepositoryClient::mapDeployTime);
|
rawFutureStub().getLastDeployTime(GetLastDeployTimeRequest.getDefaultInstance()),
|
||||||
|
"galaxy get last deploy time",
|
||||||
|
GalaxyRepositoryClient::mapDeployTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -213,99 +213,33 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
|
|||||||
* exceptionally with {@link MxGatewayException} on failure
|
* exceptionally with {@link MxGatewayException} on failure
|
||||||
*/
|
*/
|
||||||
public CompletableFuture<List<GalaxyObject>> discoverHierarchyAsync() {
|
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;
|
||||||
/**
|
|
||||||
* Lazy-browse entry point: fetches the root layer of the Galaxy hierarchy.
|
|
||||||
* Each returned {@link LazyBrowseNode} can be expanded on demand via
|
|
||||||
* {@link LazyBrowseNode#expand()} to load its direct children.
|
|
||||||
*
|
|
||||||
* @return the root nodes (no parent selector) with default options
|
|
||||||
* @throws MxGatewayException on transport or protocol failure
|
|
||||||
*/
|
|
||||||
public List<LazyBrowseNode> browse() {
|
|
||||||
return browse(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lazy-browse entry point with caller-supplied filters / shape.
|
|
||||||
*
|
|
||||||
* @param options filter and shape options; {@code null} means {@link BrowseChildrenOptions#empty()}
|
|
||||||
* @return the root nodes matching the options
|
|
||||||
* @throws MxGatewayException on transport or protocol failure
|
|
||||||
*/
|
|
||||||
public List<LazyBrowseNode> browse(BrowseChildrenOptions options) {
|
|
||||||
BrowseChildrenOptions effective = options == null ? BrowseChildrenOptions.empty() : options;
|
|
||||||
return browseChildrenInner(null, effective);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Issues a single {@code BrowseChildren} RPC and returns the raw reply.
|
|
||||||
* Callers wanting full control over pagination can drive the loop themselves.
|
|
||||||
*
|
|
||||||
* @param request the request to send
|
|
||||||
* @return the reply
|
|
||||||
* @throws MxGatewayException on transport or protocol failure
|
|
||||||
*/
|
|
||||||
public BrowseChildrenReply browseChildrenRaw(BrowseChildrenRequest request) {
|
|
||||||
try {
|
|
||||||
return rawBlockingStub().browseChildren(request);
|
|
||||||
} catch (RuntimeException error) {
|
|
||||||
if (error instanceof MxGatewayException) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
throw MxGatewayErrors.fromGrpc("galaxy browse children", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drives the BrowseChildren paging loop for a single parent (or roots when
|
|
||||||
* {@code parentGobjectId} is {@code null}). Detects repeated page tokens to
|
|
||||||
* avoid infinite loops on a buggy server.
|
|
||||||
*/
|
|
||||||
List<LazyBrowseNode> browseChildrenInner(Integer parentGobjectId, BrowseChildrenOptions options) {
|
|
||||||
java.util.ArrayList<LazyBrowseNode> nodes = new java.util.ArrayList<>();
|
|
||||||
java.util.HashSet<String> seenPageTokens = new java.util.HashSet<>();
|
|
||||||
String pageToken = "";
|
|
||||||
while (true) {
|
|
||||||
BrowseChildrenRequest.Builder builder = BrowseChildrenRequest.newBuilder()
|
|
||||||
.setPageSize(BROWSE_CHILDREN_PAGE_SIZE)
|
|
||||||
.setPageToken(pageToken)
|
|
||||||
.setAlarmBearingOnly(options.isAlarmBearingOnly())
|
|
||||||
.setHistorizedOnly(options.isHistorizedOnly());
|
|
||||||
if (parentGobjectId != null) {
|
|
||||||
builder.setParentGobjectId(parentGobjectId.intValue());
|
|
||||||
}
|
|
||||||
if (!options.getCategoryIds().isEmpty()) {
|
|
||||||
builder.addAllCategoryIds(options.getCategoryIds());
|
|
||||||
}
|
|
||||||
if (!options.getTemplateChainContains().isEmpty()) {
|
|
||||||
builder.addAllTemplateChainContains(options.getTemplateChainContains());
|
|
||||||
}
|
|
||||||
if (!options.getTagNameGlob().isEmpty()) {
|
|
||||||
builder.setTagNameGlob(options.getTagNameGlob());
|
|
||||||
}
|
|
||||||
if (options.getIncludeAttributes() != null) {
|
|
||||||
builder.setIncludeAttributes(options.getIncludeAttributes());
|
|
||||||
}
|
|
||||||
|
|
||||||
BrowseChildrenReply reply = browseChildrenRaw(builder.build());
|
|
||||||
|
|
||||||
for (int i = 0; i < reply.getChildrenCount(); i++) {
|
|
||||||
boolean hint = i < reply.getChildHasChildrenCount() && reply.getChildHasChildren(i);
|
|
||||||
nodes.add(new LazyBrowseNode(this, reply.getChildren(i), hint, options));
|
|
||||||
}
|
|
||||||
|
|
||||||
pageToken = reply.getNextPageToken();
|
|
||||||
if (pageToken == null || pageToken.isEmpty()) {
|
|
||||||
return nodes;
|
|
||||||
}
|
|
||||||
if (!seenPageTokens.add(pageToken)) {
|
|
||||||
throw new MxGatewayException(
|
|
||||||
"galaxy browse children returned repeated page token: " + pageToken);
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -319,7 +253,8 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
|
|||||||
*/
|
*/
|
||||||
public DeployEventStream watchDeployEvents(Instant lastSeenDeployTime) {
|
public DeployEventStream watchDeployEvents(Instant lastSeenDeployTime) {
|
||||||
DeployEventStream stream = new DeployEventStream(16);
|
DeployEventStream stream = new DeployEventStream(16);
|
||||||
withStreamDeadline(rawAsyncStub()).watchDeployEvents(buildWatchRequest(lastSeenDeployTime), stream.observer());
|
MxGatewayChannels.withStreamDeadline(rawAsyncStub(), options)
|
||||||
|
.watchDeployEvents(buildWatchRequest(lastSeenDeployTime), stream.observer());
|
||||||
return stream;
|
return stream;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,7 +283,7 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
|
|||||||
Instant lastSeenDeployTime, StreamObserver<DeployEvent> observer) {
|
Instant lastSeenDeployTime, StreamObserver<DeployEvent> observer) {
|
||||||
Objects.requireNonNull(observer, "observer");
|
Objects.requireNonNull(observer, "observer");
|
||||||
DeployEventSubscription subscription = new DeployEventSubscription();
|
DeployEventSubscription subscription = new DeployEventSubscription();
|
||||||
withStreamDeadline(rawAsyncStub())
|
MxGatewayChannels.withStreamDeadline(rawAsyncStub(), options)
|
||||||
.watchDeployEvents(buildWatchRequest(lastSeenDeployTime), subscription.wrap(observer));
|
.watchDeployEvents(buildWatchRequest(lastSeenDeployTime), subscription.wrap(observer));
|
||||||
return subscription;
|
return subscription;
|
||||||
}
|
}
|
||||||
@@ -364,34 +299,35 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
|
|||||||
return builder.build();
|
return builder.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
private <T extends io.grpc.stub.AbstractStub<T>> T withStreamDeadline(T stub) {
|
/**
|
||||||
if (options.streamTimeout() == null || options.streamTimeout().isNegative()) {
|
* Shuts the owned channel down and awaits termination so try-with-resources
|
||||||
return stub;
|
* callers do not leave in-flight calls or Netty event-loop threads running
|
||||||
}
|
* after the block exits.
|
||||||
return stub.withDeadlineAfter(options.streamTimeout().toNanos(), TimeUnit.NANOSECONDS);
|
*
|
||||||
}
|
* <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
|
@Override
|
||||||
public void close() {
|
public void close() {
|
||||||
if (ownedChannel != null) {
|
MxGatewayChannels.shutdown(ownedChannel, options);
|
||||||
ownedChannel.shutdown();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shuts the owned channel down and waits up to the configured connect
|
* Shuts the owned channel down and waits up to
|
||||||
* timeout for termination, forcibly shutting it down on timeout. No-op
|
* {@link MxGatewayClientOptions#shutdownTimeout()} for termination,
|
||||||
* for clients that do not own their channel.
|
* 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
|
* @throws InterruptedException if the calling thread is interrupted while waiting
|
||||||
*/
|
*/
|
||||||
public void closeAndAwaitTermination() throws InterruptedException {
|
public void closeAndAwaitTermination() throws InterruptedException {
|
||||||
if (ownedChannel != null) {
|
MxGatewayChannels.shutdownAndAwaitTermination(ownedChannel, options);
|
||||||
ownedChannel.shutdown();
|
|
||||||
if (!ownedChannel.awaitTermination(options.connectTimeout().toMillis(), TimeUnit.MILLISECONDS)) {
|
|
||||||
ownedChannel.shutdownNow();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Optional<Instant> mapDeployTime(GetLastDeployTimeReply reply) {
|
private static Optional<Instant> mapDeployTime(GetLastDeployTimeReply reply) {
|
||||||
@@ -402,47 +338,22 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
|
|||||||
return Optional.of(Instant.ofEpochSecond(ts.getSeconds(), ts.getNanos()));
|
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(
|
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()
|
DiscoverHierarchyRequest request = DiscoverHierarchyRequest.newBuilder()
|
||||||
.setPageSize(DISCOVER_HIERARCHY_PAGE_SIZE)
|
.setPageSize(DISCOVER_HIERARCHY_PAGE_SIZE)
|
||||||
.setPageToken(pageToken)
|
.setPageToken(pageToken)
|
||||||
.build();
|
.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());
|
objects.addAll(reply.getObjectsList());
|
||||||
if (reply.getNextPageToken().isBlank()) {
|
if (reply.getNextPageToken().isBlank()) {
|
||||||
return CompletableFuture.completedFuture(objects);
|
return CompletableFuture.completedFuture(objects);
|
||||||
@@ -450,38 +361,11 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
|
|||||||
if (!seenPageTokens.add(reply.getNextPageToken())) {
|
if (!seenPageTokens.add(reply.getNextPageToken())) {
|
||||||
CompletableFuture<List<GalaxyObject>> failed = new CompletableFuture<>();
|
CompletableFuture<List<GalaxyObject>> failed = new CompletableFuture<>();
|
||||||
failed.completeExceptionally(new MxGatewayException(
|
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 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.zb.mom.ww.mxgateway.client;
|
package com.dohertylan.mxgateway.client;
|
||||||
|
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
|
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
|
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
|
||||||
+67
-7
@@ -1,4 +1,4 @@
|
|||||||
package com.zb.mom.ww.mxgateway.client;
|
package com.dohertylan.mxgateway.client;
|
||||||
|
|
||||||
import io.grpc.Status;
|
import io.grpc.Status;
|
||||||
import io.grpc.StatusRuntimeException;
|
import io.grpc.StatusRuntimeException;
|
||||||
@@ -21,13 +21,38 @@ import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
|
|||||||
* stream cancels the underlying gRPC call. If the queue overflows the call is
|
* stream cancels the underlying gRPC call. If the queue overflows the call is
|
||||||
* cancelled and a follow-up call to {@link #next()} throws
|
* cancelled and a follow-up call to {@link #next()} throws
|
||||||
* {@link MxGatewayException}.
|
* {@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 {
|
public final class MxEventStream implements Iterator<MxEvent>, AutoCloseable {
|
||||||
private static final Object END = new Object();
|
private static final Object END = new Object();
|
||||||
|
|
||||||
private final BlockingQueue<Object> queue;
|
private final BlockingQueue<Object> queue;
|
||||||
|
private final Object terminalLock = new Object();
|
||||||
private volatile ClientCallStreamObserver<StreamEventsRequest> requestStream;
|
private volatile ClientCallStreamObserver<StreamEventsRequest> requestStream;
|
||||||
private volatile boolean closed;
|
private volatile boolean closed;
|
||||||
|
private boolean terminated;
|
||||||
private Object next;
|
private Object next;
|
||||||
|
|
||||||
MxEventStream(int capacity) {
|
MxEventStream(int capacity) {
|
||||||
@@ -38,7 +63,16 @@ public final class MxEventStream implements Iterator<MxEvent>, AutoCloseable {
|
|||||||
return new ClientResponseObserver<>() {
|
return new ClientResponseObserver<>() {
|
||||||
@Override
|
@Override
|
||||||
public void beforeStart(ClientCallStreamObserver<StreamEventsRequest> requestStream) {
|
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;
|
MxEventStream.this.requestStream = requestStream;
|
||||||
|
if (closed) {
|
||||||
|
requestStream.cancel("client cancelled event stream", null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -98,7 +132,7 @@ public final class MxEventStream implements Iterator<MxEvent>, AutoCloseable {
|
|||||||
if (stream != null) {
|
if (stream != null) {
|
||||||
stream.cancel("client cancelled event stream", null);
|
stream.cancel("client cancelled event stream", null);
|
||||||
}
|
}
|
||||||
offer(END);
|
terminate(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Object take() {
|
private Object take() {
|
||||||
@@ -115,10 +149,7 @@ public final class MxEventStream implements Iterator<MxEvent>, AutoCloseable {
|
|||||||
private void offer(Object value) {
|
private void offer(Object value) {
|
||||||
Objects.requireNonNull(value, "value");
|
Objects.requireNonNull(value, "value");
|
||||||
if (value == END) {
|
if (value == END) {
|
||||||
if (!queue.offer(value)) {
|
terminate(null);
|
||||||
queue.clear();
|
|
||||||
queue.offer(value);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!queue.offer(value)) {
|
if (!queue.offer(value)) {
|
||||||
@@ -126,9 +157,38 @@ public final class MxEventStream implements Iterator<MxEvent>, AutoCloseable {
|
|||||||
if (stream != null) {
|
if (stream != null) {
|
||||||
stream.cancel("client event stream queue overflowed", null);
|
stream.cancel("client event stream queue overflowed", null);
|
||||||
}
|
}
|
||||||
|
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.clear();
|
||||||
queue.offer(new MxGatewayException("gateway stream events queue overflowed"));
|
|
||||||
queue.offer(END);
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.zb.mom.ww.mxgateway.client;
|
package com.dohertylan.mxgateway.client;
|
||||||
|
|
||||||
import io.grpc.CallOptions;
|
import io.grpc.CallOptions;
|
||||||
import io.grpc.Channel;
|
import io.grpc.Channel;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user