diff --git a/CLAUDE.md b/CLAUDE.md index 6742561..5413e41 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`). - **`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. -- **`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. `InternalsVisibleTo` exposes internals to the test assembly and the reverse-engineering tool. diff --git a/README.md b/README.md index fc9c59e..3e95762 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,126 @@ auth) and `RemoteTcpCertificate` (server-cert TLS) are now live-verified for `ProbeAsync`; `RemoteTcpIntegrated` is additionally live-verified for the full 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 ```powershell @@ -80,6 +200,28 @@ $env:HISTORIAN_USER, $env:HISTORIAN_PASSWORD $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 ``` @@ -165,10 +307,17 @@ property dictionary → Retr.EndEventQuery → Hist.Close2 ## 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 (`EnsureTagAsync` / `DeleteTagAsync`) — verified end-to-end against both a local Historian (`LocalPipe`) and a remote Historian (`RemoteTcpIntegrated` over Net.TCP with Windows transport auth). `RemoteTcpCertificate` ProbeAsync is live-verified; deeper coverage over the cert transport plus the 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). diff --git a/src/AVEVA.Historian.Client/HistorianServerVersionGate.cs b/src/AVEVA.Historian.Client/HistorianServerVersionGate.cs index ba9f984..96930be 100644 --- a/src/AVEVA.Historian.Client/HistorianServerVersionGate.cs +++ b/src/AVEVA.Historian.Client/HistorianServerVersionGate.cs @@ -32,10 +32,11 @@ internal enum HistorianServiceInterface /// The Status (Stat) service's GetInterfaceVersion returns 0 (not a real /// 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 -/// proven 2020 native buffers; until those integers are captured, point such a server at -/// this gate with set to -/// . +/// A 2023 R2 gRPC server reports History interface version 12 even though it carries the +/// same proven 2020 native buffers. That value is captured and accepted (see +/// ), so a v12 server passes with the default +/// =; +/// the opt-out is only a safety valve for some future, not-yet-captured interface integer. /// internal static class HistorianServerVersionGate {