Merge grpc-test-doc-parity: gRPC live-test parity + transport-matrix docs + v12 gate-note fix
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
This commit is contained in:
@@ -72,7 +72,7 @@ Three layered subsystems, intentionally decoupled so protocol parsing can be uni
|
|||||||
- **`Wcf/`** — managed WCF/MDAS layer. The Historian uses Net.TCP on port `32568` with a custom `application/x-mdas` content type wrapping a binary SOAP 1.2 / WS-Addressing 1.0 envelope. `MdasMessageEncoder` + `MdasMessageEncodingBindingElement` implement that wrapper. `HistorianWcfBindingFactory` produces three flavors: plain MDAS, MDAS+Windows transport (used for `/Hist-Integrated`), and MDAS+certificate (used for `/HistCert`). Service paths live in `HistorianWcfServiceNames`. WCF data contracts (`Wcf/Contracts/`) are reproduced from server-side static analysis and are versioned per native interface (e.g., `IRetrievalServiceContract2..4`).
|
- **`Wcf/`** — managed WCF/MDAS layer. The Historian uses Net.TCP on port `32568` with a custom `application/x-mdas` content type wrapping a binary SOAP 1.2 / WS-Addressing 1.0 envelope. `MdasMessageEncoder` + `MdasMessageEncodingBindingElement` implement that wrapper. `HistorianWcfBindingFactory` produces three flavors: plain MDAS, MDAS+Windows transport (used for `/Hist-Integrated`), and MDAS+certificate (used for `/HistCert`). Service paths live in `HistorianWcfServiceNames`. WCF data contracts (`Wcf/Contracts/`) are reproduced from server-side static analysis and are versioned per native interface (e.g., `IRetrievalServiceContract2..4`).
|
||||||
- **`Protocol/`** — binary frame layer (`HistorianFrameReader`/`Writer`, `HistorianBinaryPrimitives`, `HistorianMessageType`). `Historian2020ProtocolDialect` is the version-anchored bridge between `HistorianClient` and the frame layer; methods without sufficient evidence throw `ProtocolEvidenceMissingException` rather than guessing wire bytes.
|
- **`Protocol/`** — binary frame layer (`HistorianFrameReader`/`Writer`, `HistorianBinaryPrimitives`, `HistorianMessageType`). `Historian2020ProtocolDialect` is the version-anchored bridge between `HistorianClient` and the frame layer; methods without sufficient evidence throw `ProtocolEvidenceMissingException` rather than guessing wire bytes.
|
||||||
- **`Transport/`** — pluggable `IHistorianTransport` (default: TCP). Tests inject a fake transport.
|
- **`Transport/`** — pluggable `IHistorianTransport` (default: TCP). Tests inject a fake transport.
|
||||||
- **`Grpc/`** — 2023 R2 gRPC transport (`HistorianTransport.RemoteGrpc`). The recovered protobuf contract lives in `Grpc/Protos/*.proto` and is compiled to client stubs at build time by `Grpc.Tools`. `HistorianGrpcChannelFactory` builds a gRPC-Web/HTTP-1.1 channel (default port `32565`, optional TLS, gzip) matching the stock 2023 R2 client. `HistorianGrpcReadOrchestrator` mirrors `HistorianWcfReadOrchestrator` but over gRPC: it reuses the exact native serializers/parsers — the same Open2 buffer, SSPI/NTLM tokens, and `DataQueryRequest`/result buffers travel inside protobuf `bytes` fields. The 2020→gRPC op map: `Hist.ValCl`→`StorageService.ValidateClientCredential` (the SSPI/Negotiate token loop), `Hist.Open2`→`HistoryService.OpenConnection`, `Retr.StartQuery2`→`RetrievalService.StartQuery`, `Retr.GetNextQueryResultBuffer2`→`RetrievalService.GetNextQueryResultBuffer`. The transport-agnostic handshake (Open2 request builder + SSPI token loop + response decode) is shared via `Wcf/HistorianNativeHandshake`. **Live-verified 2026-06-21 against a real 2023 R2 server** (interface versions History=12, Retrieval=4, Storage=4): the full read chain returns rows. NOTE: `HistoryService.ExchangeKey` is a SEPARATE key-exchange/cert-path op, NOT the Negotiate loop — an earlier revision wrongly routed the token loop there and it was rejected at round 0 regardless of credentials; the loop belongs on `StorageService.ValidateClientCredential` (which kept the 2020 inBuff/outBuff token framing). The byte payloads are the proven 2020 protocol and transfer unchanged; only the History interface integer differs (12 vs 11) and is buffer-compatible, so `VerifyServerInterfaceVersion=false` is currently required against a v12 server (the gate still pins History=11). Gated live test: set `HISTORIAN_GRPC_HOST` (+ `HISTORIAN_TEST_TAG`, optional `HISTORIAN_GRPC_PORT`/`HISTORIAN_GRPC_TLS`/`HISTORIAN_GRPC_DNSID`); reach the live 2023 R2 box via [[reference_2023r2_live_server_access]].
|
- **`Grpc/`** — 2023 R2 gRPC transport (`HistorianTransport.RemoteGrpc`). The recovered protobuf contract lives in `Grpc/Protos/*.proto` and is compiled to client stubs at build time by `Grpc.Tools`. `HistorianGrpcChannelFactory` builds a gRPC-Web/HTTP-1.1 channel (default port `32565`, optional TLS, gzip) matching the stock 2023 R2 client. `HistorianGrpcReadOrchestrator` mirrors `HistorianWcfReadOrchestrator` but over gRPC: it reuses the exact native serializers/parsers — the same Open2 buffer, SSPI/NTLM tokens, and `DataQueryRequest`/result buffers travel inside protobuf `bytes` fields. The 2020→gRPC op map: `Hist.ValCl`→`StorageService.ValidateClientCredential` (the SSPI/Negotiate token loop), `Hist.Open2`→`HistoryService.OpenConnection`, `Retr.StartQuery2`→`RetrievalService.StartQuery`, `Retr.GetNextQueryResultBuffer2`→`RetrievalService.GetNextQueryResultBuffer`. The transport-agnostic handshake (Open2 request builder + SSPI token loop + response decode) is shared via `Wcf/HistorianNativeHandshake`. **Live-verified 2026-06-21 against a real 2023 R2 server** (interface versions History=12, Retrieval=4, Storage=4): the full read chain returns rows. NOTE: `HistoryService.ExchangeKey` is a SEPARATE key-exchange/cert-path op, NOT the Negotiate loop — an earlier revision wrongly routed the token loop there and it was rejected at round 0 regardless of credentials; the loop belongs on `StorageService.ValidateClientCredential` (which kept the 2020 inBuff/outBuff token framing). The byte payloads are the proven 2020 protocol and transfer unchanged; only the History interface integer differs (12 vs 11) and is buffer-compatible. The version gate now accepts BOTH 11 and 12 for History (`HistorianServerVersionGate.AcceptedVersions`), so a v12 server passes with the default `VerifyServerInterfaceVersion=true` — no opt-out needed (the earlier requirement to set it false is obsolete). Gated live test: set `HISTORIAN_GRPC_HOST` (+ `HISTORIAN_TEST_TAG`, optional `HISTORIAN_GRPC_PORT`/`HISTORIAN_GRPC_TLS`/`HISTORIAN_GRPC_DNSID`); reach the live 2023 R2 box via [[reference_2023r2_live_server_access]].
|
||||||
- **`Models/`** — public DTOs and enums (`HistorianSample`, `RetrievalMode`, etc.). `HistorianDataValue` represents the discriminated value type.
|
- **`Models/`** — public DTOs and enums (`HistorianSample`, `RetrievalMode`, etc.). `HistorianDataValue` represents the discriminated value type.
|
||||||
|
|
||||||
`InternalsVisibleTo` exposes internals to the test assembly and the reverse-engineering tool.
|
`InternalsVisibleTo` exposes internals to the test assembly and the reverse-engineering tool.
|
||||||
|
|||||||
@@ -56,6 +56,126 @@ auth) and `RemoteTcpCertificate` (server-cert TLS) are now live-verified for
|
|||||||
`ProbeAsync`; `RemoteTcpIntegrated` is additionally live-verified for the full
|
`ProbeAsync`; `RemoteTcpIntegrated` is additionally live-verified for the full
|
||||||
read / browse / metadata / event / status surface.
|
read / browse / metadata / event / status surface.
|
||||||
|
|
||||||
|
## Transport matrix (WCF vs gRPC)
|
||||||
|
|
||||||
|
The SDK speaks two wire protocols. **WCF** is the 2020 binary MDAS protocol over
|
||||||
|
Net.TCP `32568` (transports `LocalPipe`, `RemoteTcpIntegrated`,
|
||||||
|
`RemoteTcpCertificate`); **gRPC** is the 2023 R2 HCAL transport over HTTP/2
|
||||||
|
`32565` (transport `RemoteGrpc`). The core read chain reuses the *same* native
|
||||||
|
Open2 buffers and SSPI tokens on both — on gRPC they simply ride inside protobuf
|
||||||
|
`bytes` fields — so reads are at parity. The surfaces diverge at the edges.
|
||||||
|
|
||||||
|
Legend: ✅ tooled + live-verified · ⚠️ tooled, partial/synthesized ·
|
||||||
|
🔌 **the gRPC server exposes the RPC (recovered in `Grpc/Protos/*.proto`) but the
|
||||||
|
SDK doesn't drive it yet** — untooled/uncaptured, *not* a protocol gap ·
|
||||||
|
❌ unavailable on that transport.
|
||||||
|
|
||||||
|
| Operation | WCF | gRPC | Notes |
|
||||||
|
|---|:---:|:---:|---|
|
||||||
|
| `ProbeAsync` | ✅ | ✅ | |
|
||||||
|
| `ReadRawAsync` | ✅ | ✅ | |
|
||||||
|
| `ReadAggregateAsync` | ✅ | ✅ | all 16 retrieval modes |
|
||||||
|
| `ReadAtTimeAsync` | ✅ | ✅ | |
|
||||||
|
| `BrowseTagNamesAsync` | ✅ | ✅ | |
|
||||||
|
| `GetTagMetadataAsync` | ✅ | ✅ | |
|
||||||
|
| `GetSystemParameterAsync` | ✅ | ✅ | |
|
||||||
|
| `AddHistoricalValuesAsync` | ❌ | ✅ | historical/backfill writes ride `HistoryService.AddStreamValues`; non-gRPC throws `ProtocolEvidenceMissingException` |
|
||||||
|
| `GetServerTimeZoneAsync` | ❌ | ✅ | 2020 `GetSystemTimeZoneName` is a client-side stub (empty); WCF throws |
|
||||||
|
| `GetStoreForwardStatusAsync` | ⚠️ | ✅ | gRPC contacts the server (measured idle-state, reports `ErrorOccurred`); WCF returns synthesized all-false. Active-SF magnitude is D2-gated on both |
|
||||||
|
| `ReadEventsAsync` | ✅ | 🔌 | gRPC `RetrievalService.StartEventQuery` / `GetNextEventQueryResultBuffer` / `EndEventQuery` recovered (`bytes btRequest` + handle); not tooled over gRPC |
|
||||||
|
| `SendEventAsync` | ✅ | 🔌 | rides `AddStreamValues` family; no distinct event-send RPC, framing uncaptured over gRPC |
|
||||||
|
| `EnsureTagAsync` / `DeleteTagAsync` / `RenameTagsAsync` | ✅ | 🔌 | gRPC `HistoryService.EnsureTags` / `DeleteTags` / `StartJob`(+`GetJobStatus`) recovered (`bytes btTagInfos`/`btTagnames`/`btInput` + handle) |
|
||||||
|
| `GetTagExtendedPropertiesAsync` / `AddTagExtendedPropertiesAsync` | ✅ | 🔌 | gRPC `RetrievalService.GetTagExtendedPropertiesFromName` + `HistoryService.AddTagExtendedProperties`; gRPC also exposes `DeleteTagExtendedProperties` (WCF delete was server-blocked) |
|
||||||
|
| `ExecuteSqlCommandAsync` | ✅ | 🔌 | gRPC `RetrievalService.ExecuteSqlCommand` (`StrCommand` + `uiOption`, mirrors WCF `ExeC`/`GetR`) |
|
||||||
|
| `GetRuntimeParameterAsync` | ✅ | 🔌 | gRPC `StatusService.GetRuntimeParameter` (`bytes btRequest` + handle) |
|
||||||
|
| `GetConnectionStatusAsync` | ✅ | ❌ | synthesized from an authenticated probe — no dedicated RPC on either transport (gRPC `PingServer`/`GetHistorianConsoleStatus` could synthesize it) |
|
||||||
|
| `ReadBlocksAsync` | ❌ | ❌ | `StartBlockRetrievalQuery` never captured on either transport — throws `ProtocolEvidenceMissingException` |
|
||||||
|
|
||||||
|
In short: **WCF is the broad, mature surface** (every config write, events, SQL,
|
||||||
|
and all reads), while **gRPC is the narrower *tooled* surface** — but the 2023 R2
|
||||||
|
gRPC *contract* is actually a **superset** of WCF. Every 🔌 row above has a
|
||||||
|
recovered RPC carrying the **same opaque `bytes` buffers the existing WCF
|
||||||
|
serializers already emit**, keyed by the same `strHandle`/`uiHandle` session
|
||||||
|
handle the gRPC read path already obtains. So these are **capture-and-wire** items
|
||||||
|
(route the existing serializer into a gRPC orchestrator + golden-capture the
|
||||||
|
framing), **not** protocol-discovery items. We have only *buffer-verified* two
|
||||||
|
gRPC families live — the read chain and `AddStreamValues` — so per the
|
||||||
|
"capture first, never guess wire bytes" rule the 🔌 rows stay untooled until each
|
||||||
|
is captured. The natural production pattern today remains WCF for config/reads and
|
||||||
|
`RemoteGrpc` reserved for `AddHistoricalValuesAsync`.
|
||||||
|
|
||||||
|
> A 2023 R2 server reports History interface version 12 (vs. 11 on 2020). The
|
||||||
|
> connect-time version gate accepts both — they are byte-compatible — so gRPC
|
||||||
|
> works against a v12 server with the default `VerifyServerInterfaceVersion=true`;
|
||||||
|
> no opt-out is required.
|
||||||
|
|
||||||
|
## Resilience subsystems (M4)
|
||||||
|
|
||||||
|
Two **pure client-side** subsystems layered on top of `HistorianClient`. They use
|
||||||
|
only the public SDK surface — no extra reverse-engineering, no server-side
|
||||||
|
protocol — and are fully unit-tested without a live server.
|
||||||
|
|
||||||
|
### Store-and-forward (`AVEVA.Historian.Client.StoreForward`)
|
||||||
|
|
||||||
|
A durable local outbox that buffers writes when the Historian is unreachable and
|
||||||
|
replays them on reconnect. `HistorianStoreForwardWriter` wraps a `HistorianClient`
|
||||||
|
(or any `IHistorianWriteSink`) and persists each enqueued write to an
|
||||||
|
`IHistorianOutboxStore` — `FileHistorianOutboxStore` (crash-durable, atomic
|
||||||
|
JSON-per-entry, FIFO by filename sequence, corrupt-file quarantine) or
|
||||||
|
`InMemoryHistorianOutboxStore`. A background drain loop retries with FIFO
|
||||||
|
head-of-line ordering, optional `MaxDeliveryAttempts` dead-lettering, and a
|
||||||
|
`DropOldest`/`Reject` overflow policy.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using AVEVA.Historian.Client.StoreForward;
|
||||||
|
|
||||||
|
await using HistorianStoreForwardWriter writer = new(
|
||||||
|
client,
|
||||||
|
new FileHistorianOutboxStore(@"C:\ProgramData\histsdk\outbox"));
|
||||||
|
await writer.StartAsync(); // background drain on reconnect
|
||||||
|
|
||||||
|
await writer.EnqueueHistoricalValuesAsync("MyTag",
|
||||||
|
[new HistorianHistoricalValue(DateTime.UtcNow, 42.0)]); // returns immediately, durable
|
||||||
|
|
||||||
|
HistorianStoreForwardStatusSnapshot status = await writer.GetStatusAsync();
|
||||||
|
// status.PendingCount / .Storing / .ErrorOccurred — mirrors the server SF semantics
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a pragmatic outbox, **not** the bit-faithful native SF cache
|
||||||
|
(`Forward*Snapshot` decode), which stays deferred.
|
||||||
|
|
||||||
|
### Multi-historian redundancy (`AVEVA.Historian.Client.Redundancy`)
|
||||||
|
|
||||||
|
`HistorianRedundantClient` fronts N members as one logical client. Reads fail over
|
||||||
|
to the next healthy member in priority order (streaming reads fail over only
|
||||||
|
*before the first row* — mid-stream failures propagate to avoid duplicated/skipped
|
||||||
|
rows); writes fan out (`AllMembers`/`PreferredOnly`) under an `All`/`Any`
|
||||||
|
acknowledgement policy and return a per-member `HistorianRedundantWriteResult`. A
|
||||||
|
failure-threshold demotion + background watchdog restores recovered members.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using AVEVA.Historian.Client.Redundancy;
|
||||||
|
|
||||||
|
await using HistorianRedundantClient cluster = new(
|
||||||
|
[
|
||||||
|
new HistorianClientMember("primary", primaryClient),
|
||||||
|
new HistorianClientMember("secondary", secondaryClient),
|
||||||
|
]);
|
||||||
|
await cluster.StartAsync(); // health watchdog
|
||||||
|
|
||||||
|
await foreach (HistorianSample s in cluster.ReadRawAsync("MyTag", startUtc, endUtc, 100))
|
||||||
|
{
|
||||||
|
// served by the first healthy member; transparently fails over on connect
|
||||||
|
}
|
||||||
|
|
||||||
|
HistorianRedundantWriteResult w = await cluster.AddHistoricalValuesAsync("MyTag",
|
||||||
|
[new HistorianHistoricalValue(DateTime.UtcNow, 42.0)]); // fanned out across members
|
||||||
|
```
|
||||||
|
|
||||||
|
Backing a member's writes with a `HistorianStoreForwardWriter` gives the pragmatic
|
||||||
|
equivalent of native ReSyncTags: a down member buffers locally and replays on
|
||||||
|
recovery.
|
||||||
|
|
||||||
## Build & test
|
## Build & test
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -80,6 +200,28 @@ $env:HISTORIAN_USER, $env:HISTORIAN_PASSWORD
|
|||||||
$env:HISTORIAN_TAG_FILTER = 'Sys*' # or any LIKE-pattern
|
$env:HISTORIAN_TAG_FILTER = 'Sys*' # or any LIKE-pattern
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The 2023 R2 `RemoteGrpc` transport has its own gated live suite
|
||||||
|
(`HistorianGrpcIntegrationTests`) covering the full tooled gRPC surface — probe,
|
||||||
|
raw / aggregate (incl. multiple retrieval modes) / at-time reads, browse,
|
||||||
|
metadata, system-parameter, server time-zone, and measured store-forward status.
|
||||||
|
It skips cleanly unless `HISTORIAN_GRPC_HOST` is set:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:HISTORIAN_GRPC_HOST = 'my-2023r2-host' # gates the gRPC suite
|
||||||
|
$env:HISTORIAN_TEST_TAG = 'SysTimeSec'
|
||||||
|
$env:HISTORIAN_USER, $env:HISTORIAN_PASSWORD # required for the authenticated ops
|
||||||
|
# Optional:
|
||||||
|
$env:HISTORIAN_GRPC_PORT = '32565' # default 32565
|
||||||
|
$env:HISTORIAN_GRPC_TLS = 'true' # gRPC over TLS
|
||||||
|
$env:HISTORIAN_GRPC_DNSID = 'my-2023r2-host' # cert DNS name when connecting by IP
|
||||||
|
$env:HISTORIAN_GRPC_TIMEOUT = '120' # per-call deadline (s); raise for slow links
|
||||||
|
$env:HISTORIAN_WRITE_SANDBOX_TAG = 'MyFloatTag' # gates the AddHistoricalValues write test
|
||||||
|
```
|
||||||
|
|
||||||
|
The aggregate tests self-calibrate their query window from a real raw sample, so
|
||||||
|
they pass against an idle 2023 R2 server (no recent collection) as well as a
|
||||||
|
live-collecting one.
|
||||||
|
|
||||||
## Repository layout
|
## Repository layout
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -165,10 +307,17 @@ property dictionary → Retr.EndEventQuery → Hist.Close2
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
165 unit + live integration tests pass (`dotnet test --logger "console;verbosity=minimal"`).
|
313 unit + live integration tests pass (`dotnet test --logger "console;verbosity=minimal"`).
|
||||||
Full SDK surface — reads, browse, metadata, status, plus the two write ops
|
Full SDK surface — reads, browse, metadata, status, plus the two write ops
|
||||||
(`EnsureTagAsync` / `DeleteTagAsync`) — verified end-to-end against both a
|
(`EnsureTagAsync` / `DeleteTagAsync`) — verified end-to-end against both a
|
||||||
local Historian (`LocalPipe`) and a remote Historian (`RemoteTcpIntegrated`
|
local Historian (`LocalPipe`) and a remote Historian (`RemoteTcpIntegrated`
|
||||||
over Net.TCP with Windows transport auth). `RemoteTcpCertificate` ProbeAsync
|
over Net.TCP with Windows transport auth). `RemoteTcpCertificate` ProbeAsync
|
||||||
is live-verified; deeper coverage over the cert transport plus the
|
is live-verified; deeper coverage over the cert transport plus the
|
||||||
explicit-credentials path await additional verification.
|
explicit-credentials path await additional verification.
|
||||||
|
|
||||||
|
The 2023 R2 `RemoteGrpc` transport's full tooled surface is live-verified against
|
||||||
|
a real 2023 R2 server: probe, raw / aggregate (TimeWeightedAverage +
|
||||||
|
Minimum/MaximumWithTime + BestFit) / at-time reads, browse, metadata,
|
||||||
|
system-parameter, server time-zone, and measured store-forward status — plus the
|
||||||
|
gRPC-only `AddHistoricalValuesAsync` backfill write. The remaining gRPC config ops
|
||||||
|
are exposed by the server but untooled (see the transport matrix above).
|
||||||
|
|||||||
@@ -32,10 +32,11 @@ internal enum HistorianServiceInterface
|
|||||||
/// The Status (<c>Stat</c>) service's <c>GetInterfaceVersion</c> returns 0 (not a real
|
/// The Status (<c>Stat</c>) service's <c>GetInterfaceVersion</c> returns 0 (not a real
|
||||||
/// version), so the Status interface is validated for reachability only, never value.
|
/// version), so the Status interface is validated for reachability only, never value.
|
||||||
///
|
///
|
||||||
/// A 2023 R2 gRPC server may report different integers even though it carries the same
|
/// A 2023 R2 gRPC server reports History interface version 12 even though it carries the
|
||||||
/// proven 2020 native buffers; until those integers are captured, point such a server at
|
/// same proven 2020 native buffers. That value is captured and accepted (see
|
||||||
/// this gate with <see cref="HistorianClientOptions.VerifyServerInterfaceVersion"/> set to
|
/// <see cref="AcceptedVersions"/>), so a v12 server passes with the default
|
||||||
/// <see langword="false"/>.
|
/// <see cref="HistorianClientOptions.VerifyServerInterfaceVersion"/>=<see langword="true"/>;
|
||||||
|
/// the opt-out is only a safety valve for some future, not-yet-captured interface integer.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static class HistorianServerVersionGate
|
internal static class HistorianServerVersionGate
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -233,6 +233,124 @@ public sealed class HistorianGrpcIntegrationTests
|
|||||||
Assert.Contains(samples, s => s.NumericValue is { } v && Math.Abs(v - expected) < 0.01);
|
Assert.Contains(samples, s => s.NumericValue is { } v && Math.Abs(v - expected) < 0.01);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadAggregateAsync_OverGrpc_ReturnsTimeWeightedAverageRows()
|
||||||
|
{
|
||||||
|
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
|
||||||
|
string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG");
|
||||||
|
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
HistorianClient client = new(BuildOptions(host));
|
||||||
|
|
||||||
|
// Self-calibrate the window from a real raw sample. The 2023 R2 box may be idle (no recent
|
||||||
|
// collection), so a "last N hours" window can be empty AND make the interpolating modes do a
|
||||||
|
// slow bounding-value scan. Seeding from where data actually exists makes this robust on any
|
||||||
|
// server state and keeps the per-bucket scan cheap. See HISTORIAN_GRPC_TIMEOUT for slow links.
|
||||||
|
(DateTime startUtc, DateTime endUtc)? window = await SeedAggregateWindowAsync(client, testTag!);
|
||||||
|
if (window is null)
|
||||||
|
{
|
||||||
|
return; // tag has no data anywhere in the lookback — nothing to aggregate
|
||||||
|
}
|
||||||
|
|
||||||
|
List<HistorianAggregateSample> samples = [];
|
||||||
|
await foreach (HistorianAggregateSample sample in client.ReadAggregateAsync(
|
||||||
|
testTag!, window.Value.startUtc, window.Value.endUtc,
|
||||||
|
RetrievalMode.TimeWeightedAverage,
|
||||||
|
TimeSpan.FromMinutes(10),
|
||||||
|
CancellationToken.None))
|
||||||
|
{
|
||||||
|
samples.Add(sample);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.NotEmpty(samples);
|
||||||
|
Assert.All(samples, s => Assert.Equal(testTag, s.TagName));
|
||||||
|
Assert.All(samples, s => Assert.Equal(RetrievalMode.TimeWeightedAverage, s.RetrievalMode));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exercises the "QueryType byte = native enum ordinal" mapping over the gRPC StartQuery envelope
|
||||||
|
// for a few non-default retrieval modes — the server must accept each without error. Window is
|
||||||
|
// seeded from real data (idle-server-safe); rows may legitimately be empty for some modes.
|
||||||
|
[Theory]
|
||||||
|
[InlineData(RetrievalMode.MinimumWithTime)]
|
||||||
|
[InlineData(RetrievalMode.MaximumWithTime)]
|
||||||
|
[InlineData(RetrievalMode.BestFit)]
|
||||||
|
public async Task ReadAggregateAsync_OverGrpc_AcceptsRetrievalMode(RetrievalMode mode)
|
||||||
|
{
|
||||||
|
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
|
||||||
|
string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG");
|
||||||
|
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
HistorianClient client = new(BuildOptions(host));
|
||||||
|
|
||||||
|
(DateTime startUtc, DateTime endUtc)? window = await SeedAggregateWindowAsync(client, testTag!);
|
||||||
|
if (window is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<HistorianAggregateSample> samples = [];
|
||||||
|
await foreach (HistorianAggregateSample s in client.ReadAggregateAsync(
|
||||||
|
testTag!, window.Value.startUtc, window.Value.endUtc, mode, TimeSpan.FromMinutes(10), CancellationToken.None))
|
||||||
|
{
|
||||||
|
samples.Add(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Absence of an exception proves the QueryType byte was accepted; pin the echoed mode.
|
||||||
|
Assert.All(samples, s => Assert.Equal(mode, s.RetrievalMode));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finds a 1-hour window that actually contains raw data for <paramref name="tag"/> by reading a
|
||||||
|
/// real raw sample over a wide lookback, then anchoring the window at that sample. Returns null
|
||||||
|
/// when the tag has no data in the lookback. This keeps the aggregate tests independent of whether
|
||||||
|
/// the live 2023 R2 box is actively collecting.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<(DateTime startUtc, DateTime endUtc)?> SeedAggregateWindowAsync(HistorianClient client, string tag)
|
||||||
|
{
|
||||||
|
DateTime endUtc = DateTime.UtcNow;
|
||||||
|
DateTime startUtc = endUtc - TimeSpan.FromDays(30);
|
||||||
|
|
||||||
|
await foreach (HistorianSample s in client.ReadRawAsync(tag, startUtc, endUtc, maxValues: 1, CancellationToken.None))
|
||||||
|
{
|
||||||
|
DateTime anchor = s.TimestampUtc;
|
||||||
|
return (anchor - TimeSpan.FromMinutes(1), anchor + TimeSpan.FromHours(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadAtTimeAsync_OverGrpc_ReturnsRequestedTimestamps()
|
||||||
|
{
|
||||||
|
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
|
||||||
|
string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG");
|
||||||
|
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
HistorianClient client = new(BuildOptions(host));
|
||||||
|
|
||||||
|
DateTime nowUtc = DateTime.UtcNow;
|
||||||
|
DateTime[] timestamps =
|
||||||
|
[
|
||||||
|
nowUtc - TimeSpan.FromDays(1),
|
||||||
|
nowUtc - TimeSpan.FromHours(12),
|
||||||
|
nowUtc - TimeSpan.FromHours(1)
|
||||||
|
];
|
||||||
|
|
||||||
|
IReadOnlyList<HistorianSample> samples = await client.ReadAtTimeAsync(testTag, timestamps, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotEmpty(samples);
|
||||||
|
Assert.All(samples, s => Assert.Equal(testTag, s.TagName));
|
||||||
|
}
|
||||||
|
|
||||||
private static HistorianClientOptions BuildOptions(string host)
|
private static HistorianClientOptions BuildOptions(string host)
|
||||||
{
|
{
|
||||||
string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER");
|
string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER");
|
||||||
@@ -242,6 +360,11 @@ public sealed class HistorianGrpcIntegrationTests
|
|||||||
? parsed
|
? parsed
|
||||||
: HistorianClientOptions.DefaultGrpcPort;
|
: HistorianClientOptions.DefaultGrpcPort;
|
||||||
bool tls = string.Equals(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_TLS"), "true", StringComparison.OrdinalIgnoreCase);
|
bool tls = string.Equals(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_TLS"), "true", StringComparison.OrdinalIgnoreCase);
|
||||||
|
// Optional per-call deadline override (seconds) for slow/remote boxes — heavier aggregate
|
||||||
|
// modes over a tunnelled link can exceed the 30s default. Falls back to the SDK default.
|
||||||
|
TimeSpan timeout = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_TIMEOUT"), out int secs) && secs > 0
|
||||||
|
? TimeSpan.FromSeconds(secs)
|
||||||
|
: new HistorianClientOptions { Host = host }.RequestTimeout;
|
||||||
|
|
||||||
return new HistorianClientOptions
|
return new HistorianClientOptions
|
||||||
{
|
{
|
||||||
@@ -253,7 +376,8 @@ public sealed class HistorianGrpcIntegrationTests
|
|||||||
ServerDnsIdentity = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_DNSID"),
|
ServerDnsIdentity = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_DNSID"),
|
||||||
IntegratedSecurity = !explicitCreds,
|
IntegratedSecurity = !explicitCreds,
|
||||||
UserName = user ?? string.Empty,
|
UserName = user ?? string.Empty,
|
||||||
Password = password ?? string.Empty
|
Password = password ?? string.Empty,
|
||||||
|
RequestTimeout = timeout
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user