Compare commits
147 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f2297315b9 | |||
| de8d5e91ce | |||
| 8777c0b816 | |||
| 954b9cc9cc | |||
| 7992e43908 | |||
| 64c9793b91 | |||
| f1c57f7149 | |||
| 85f3bd4b4e | |||
| 5a7a28872b | |||
| 100b44a365 | |||
| aa8ca2f6ad | |||
| 95b924cdbe | |||
| aa56d2d81b | |||
| dea9107b4b | |||
| 79bb1d9e06 | |||
| 43c2587498 | |||
| e04eb539f7 | |||
| 2687b2b6d2 | |||
| 5f949a86e2 | |||
| 81da404c5d | |||
| 777a7700b4 | |||
| dc4141e718 | |||
| 81aff03748 | |||
| 2f689cbe71 | |||
| be60d0b8d9 | |||
| 15da8516ba | |||
| d2b00fcda5 | |||
| ad2c02eb42 | |||
| d42019f481 | |||
| a0b5d35e48 | |||
| 9909921e87 | |||
| f8db01fd7f | |||
| 3849f17746 | |||
| 899f9ccf6b | |||
| 96f10372de | |||
| 7d8be48d89 | |||
| f2c442bbaf | |||
| 32d508eed4 | |||
| df9751f066 | |||
| 5ad1adb429 | |||
| dd57d212f8 | |||
| afc7c4bf96 | |||
| ae536bb4b8 | |||
| cac81c7e5c | |||
| 0037ab8ca9 | |||
| 6a67a8366c | |||
| 6faf8a5f30 | |||
| 8f4a188f78 | |||
| 8199dde452 | |||
| 6cf4dd13fe | |||
| e091965d59 | |||
| f19eb3b821 | |||
| b0388e7a40 | |||
| 1161c40fd3 | |||
| 88287a8c66 | |||
| 9a25fa4ef7 | |||
| 0921e21bdb | |||
| 32ae301050 | |||
| 6d0f5c4b8f | |||
| c45f1a957b | |||
| b2ac35b98e | |||
| 3fd522fa10 | |||
| d67f6f5e96 | |||
| 7284fdc976 | |||
| 0b1e9d0a7f | |||
| ea85ea248d | |||
| 876cbc5d94 | |||
| 8ad160b843 | |||
| c6752804ee | |||
| dbb5c99c53 | |||
| d9051ba890 | |||
| 73f66cbf27 | |||
| 941e11d292 | |||
| 8b966f3d80 | |||
| c88260c973 | |||
| b3417c2f6a | |||
| 2bd86e4e83 | |||
| 32cb5152a6 | |||
| df28bcfa53 | |||
| a714aa1bff | |||
| ecf446965a | |||
| 8984dac1ed | |||
| 3525653c2b | |||
| 000f4120d5 | |||
| 27e969f86d | |||
| 274466c050 | |||
| f1fd3691ba | |||
| 2d69f2860e | |||
| 7e8bb07df3 | |||
| e7a6cf1989 | |||
| 0780cec9a7 | |||
| ef68016c7a | |||
| 035d8a92f2 | |||
| b80ac07942 | |||
| 6d8a7d48f8 | |||
| e45c615a79 | |||
| 9db2864f70 | |||
| 53a9c87114 | |||
| c2d8fb9bc8 | |||
| f840af5873 | |||
| 60b3673f01 | |||
| a9000ec06a | |||
| dd2aec3b8b | |||
| a91f126287 | |||
| 7b5d27a8d3 | |||
| d1e96f48de | |||
| d527784def | |||
| 3cc02e3ed0 | |||
| 273103f882 | |||
| dafafa0c98 | |||
| aa36e58d58 | |||
| 85f0c2f0fa | |||
| 0e78d638d0 | |||
| 9bcfffb365 | |||
| d5c04cd410 | |||
| c1f263ef83 | |||
| ce8576bd6e | |||
| 328748c0ae | |||
| 222eed9c02 | |||
| 57b9506d01 | |||
| 78cb689bdf | |||
| 1a08dd9ec2 | |||
| ac28679a1f | |||
| 8fbb868813 | |||
| 23798db1ef | |||
| 04ea0b9a1f | |||
| 25aff409dc | |||
| d23722ea73 | |||
| 4de222c950 | |||
| 85ff1b48df | |||
| 630295bd18 | |||
| 4c9f0d476c | |||
| 26ef5e5645 | |||
| 0e19adae68 | |||
| b0703ebf80 | |||
| c4b8d0dde4 | |||
| 22e9c5e5f8 | |||
| c1b1b3d23b | |||
| 08b950caee | |||
| bc353df8c4 | |||
| fbd839077b | |||
| 1a539882d0 | |||
| 108220c36b | |||
| 4da5287d01 | |||
| 6d470eab4a | |||
| f1e23a3a02 | |||
| 1a7519c803 |
@@ -29,3 +29,9 @@ Thumbs.db
|
||||
# Test droppings
|
||||
*.coverage
|
||||
coverage.cobertura.xml
|
||||
|
||||
# Live 2023 R2 server credentials — never commit
|
||||
wonder-sql-vd03.txt
|
||||
|
||||
# Reverse-engineering IL-rewrite output: derived AVEVA binaries, never commit
|
||||
docs/reverse-engineering/dnlib-write-copy/
|
||||
|
||||
@@ -18,10 +18,12 @@ Reads (the original required surface, all working live as of 2026-05-04):
|
||||
|
||||
Writes (added 2026-05-04 by explicit user request — do not extend further without one):
|
||||
|
||||
- `EnsureTagAsync` for analog types: Float, Double, Int2, Int4, UInt4 (live-verified end-to-end). Other types (SingleByteString/DoubleByteString/Int1/Int8/UInt8) fail at native AddTag — likely require a different path and are intentionally not supported. `MinEU`/`MaxEU`/`MinRaw`/`MaxRaw` all round-trip into the DB. By default `ApplyScaling=false` and the server mirrors MinRaw→MinEU and sets `AnalogTag.Scaling=0`; set `ApplyScaling=true` on the definition to persist distinct raw bounds with `AnalogTag.Scaling=1`. The wire encoding is the trailer's second byte (`FE 00` vs `FE 01`).
|
||||
- `EnsureTagAsync` for analog types: Float, Double, Int2, Int4, UInt4, **Int8, UInt8** (all live-verified end-to-end; `Int8`/`UInt8` added 2026-06-25 — same analog `CTagMetadata` layout, type codes `0x19`/`0x39`). **`UInt1` is NOT supported**: the server accepts `EnsureTags(UInt1)` but stores a *degenerate* analog tag (`GetTagInfosFromName` returns a 31-byte stub — descriptor type byte `0x00`, no GUID), so the write fails on the `GetTagInfo` path; re-gated fail-closed. SingleByteString/DoubleByteString and special/event types require a different (non-analog) path and are intentionally not supported. `MinEU`/`MaxEU`/`MinRaw`/`MaxRaw` all round-trip into the DB. By default `ApplyScaling=false` and the server mirrors MinRaw→MinEU and sets `AnalogTag.Scaling=0`; set `ApplyScaling=true` on the definition to persist distinct raw bounds with `AnalogTag.Scaling=1`. The wire encoding is the trailer's second byte (`FE 00` vs `FE 01`).
|
||||
- `DeleteTagAsync`
|
||||
- `AddHistoricalValuesAsync` (added 2026-06-21 by explicit user request — M3 historical/backfill writes). **gRPC-only** (`HistorianTransport.RemoteGrpc`); non-gRPC transports throw `ProtocolEvidenceMissingException`. Reverse-engineered by capturing the native 2023 R2 client: the historical write rides `HistoryService.AddStreamValues` with an "ON" storage-sample buffer (`HistorianHistoricalWriteProtocol`, golden-tested), NOT the TransactionService `AddNonStreamValues` path the static decompile suggested. Orchestrator (`HistorianGrpcHistoricalWriteOrchestrator`): write-enabled session → `GetTagInfosFromName` (resolves the per-tag GUID = the tag-info `TypeId`, and maps the data type via `MapDataType`) → `AddStreamValues`. Tag must pre-exist (`EnsureTagAsync`). Supports the analog types `EnsureTagAsync` does — **Float, Double, Int2, Int4, UInt4, Int8, UInt8** (all captured live + golden-tested + write/read-back validated; `Int8`/`UInt8` added 2026-06-25, value = native-width LE int64/uint64). The 4-byte value descriptor is constant (`C0 10 01 00`); the value is `u32(0) + native-width value` (float32 / double64 / int16 / int32 / uint32 / int64 / uint64) selected by the tag's declared type. Other tag types throw `ProtocolEvidenceMissingException` (incl. `UInt1` — server-degenerate, see `EnsureTagAsync` above). Live-validated end-to-end against the 2023 R2 server. The D2/`AddS2` cache gate (err 129) does NOT block the primed 2023 R2 client. See `docs/plans/revision-write-path.md` §"R3.1 CAPTURED".
|
||||
- `SendEventAsync` (M2 event-send; added by explicit user request). Appends a single `HistorianEvent` to the built-in `CM_EVENT` tag, readable back via `ReadEventsAsync` / `v_AlarmEventHistory2`. Works on **both transports**, routed by `HistorianClientOptions.Transport`: WCF runs Open2 event-mode (`0x501`) → CM_EVENT registration (RTag2 + EnsT2) → `AddS2` (`AddStreamValues2`); gRPC (`RemoteGrpc`, `HistorianGrpcEventWriteOrchestrator`, added 2026-06-23) runs the v8 Event `OpenConnection` (ExchangeKey ECDH) → CM_EVENT registration → `HistoryService.AddStreamValues`. **Both carry the same `"OS"` (0x534F) event VTQ buffer** (`HistorianEventWriteProtocol`, the managed `PackToVtq` equivalent) — there is NO distinct event-send RPC and it is NOT the historical write's `"ON"` buffer (captured live from the native 2023 R2 client; the write-enabled Event open is byte-identical to the read-only one). The gRPC path is **live-validated end-to-end** (send → `BSuccess` → event reads back from the server). Only **original events** (`RevisionVersion = 0`) with **string-valued properties** have a captured encoding; revision/update/delete events and non-string property values throw `ProtocolEvidenceMissingException`. Registration buffers are golden-tested against the live capture (`GrpcEventSendProtocolTests`); gated live test `SendEventAsync_OverGrpc_AcceptsEvent` (opt-in `HISTORIAN_GRPC_EVENT_SEND=1`).
|
||||
|
||||
`AddS2` (write samples) is architecturally blocked — server cache only ingests from configured IOServers/ApplicationServer pipelines. Do not add write-samples support.
|
||||
`AddS2` (streaming process-sample writes for user tags) remains architecturally blocked — the server cache only ingests from configured IOServers/ApplicationServer pipelines. Do not add streaming write-samples support. (`AddHistoricalValuesAsync` is the distinct *non-streamed original/backfill* path and is supported.)
|
||||
|
||||
Methods without protocol evidence currently throw `ProtocolEvidenceMissingException` from `Historian2020ProtocolDialect`. Do not stub fake behavior — leave them throwing until evidence supports an implementation.
|
||||
|
||||
@@ -71,7 +73,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`→`HistoryService.ExchangeKey`, `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`. **Not yet live-verified against a 2023 R2 server** — the auth handshake op (`ExchangeKey`) is the first thing to revisit if a live server rejects it; the byte payloads are the proven 2020 protocol. Gated live test: set `HISTORIAN_GRPC_HOST` (+ `HISTORIAN_TEST_TAG`, optional `HISTORIAN_GRPC_PORT`/`HISTORIAN_GRPC_TLS`/`HISTORIAN_GRPC_DNSID`).
|
||||
- **`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.
|
||||
|
||||
@@ -17,7 +17,7 @@ The supported surface (per [`CLAUDE.md`](CLAUDE.md)):
|
||||
| `BrowseTagNamesAsync` | live-verified |
|
||||
| `GetTagMetadataAsync` | live-verified for 17 distinct native data-type codes |
|
||||
| `GetConnectionStatusAsync` | synthesized from authenticated probe (matches native semantic) |
|
||||
| `GetStoreForwardStatusAsync` | synthesized defaults (no SF sidecar to probe) |
|
||||
| `GetStoreForwardStatusAsync` | gRPC: measured idle-state (live-verified — contacts server, reports `ErrorOccurred` when unreachable; active-SF magnitude is D2-gated). Non-gRPC: synthesized defaults |
|
||||
| `GetSystemParameterAsync` | live-verified via `Stat/GetSystemParameter` |
|
||||
| `EnsureTagAsync` | live-verified for analog Float/Double/Int2/Int4/UInt4; `ApplyScaling=true` persists distinct MinRaw/MaxRaw |
|
||||
| `DeleteTagAsync` | live-verified |
|
||||
@@ -56,6 +56,143 @@ 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 ·
|
||||
🧪 tooled + routed but **sandbox-gated** (mutates server state, not yet run
|
||||
destructively against a live box) · 🔌 **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 · ⛔ tooled but **server-walled** (the
|
||||
request rides the RPC but the server faults on an unmet precondition) ·
|
||||
❌ 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 |
|
||||
| `GetRuntimeParameterAsync` | ✅ | ✅ | tooled + live-verified over gRPC (`StatusService.GetRuntimeParameter`, the 2020 `GETRP` buffers ride unchanged) |
|
||||
| `GetTagExtendedPropertiesAsync` | ✅ | ✅ | tooled + live-verified over gRPC (`RetrievalService.GetTagExtendedPropertiesFromName`, the `GetTepByNm` buffers ride unchanged). The shared parser now handles the live multi-property response shape (one group per property + a uint16 searchability-flags trailer), fixed 2026-06-22 |
|
||||
| `ExecuteSqlCommandAsync` | ✅ | ⛔ | gRPC request rides `RetrievalService.ExecuteSqlCommand`, but the server-side `CSrvDbConnection.ExecuteSqlCommand` faults (`IndexOutOfRange`, native err 38) — an unmet DB-connection precondition. A `HistoryService.RegisterTags` prime does **not** clear it (tried live 2026-06-22, both 0x402/0x401). Bounded behind `ProtocolEvidenceMissingException`. Use WCF |
|
||||
| `ReadEventsAsync` | ✅ | ⚠️ | tooled + routed over gRPC: the full CM_EVENT registration replay (`UpdateClientStatus`→`RegisterTags`→`EnsureTags` + discovery probes) runs and `StartEventQuery` succeeds, but `GetNextEventQueryResultBuffer` **long-polls** on no data (it blocks to the deadline rather than returning the synchronous 5-byte code-85 terminal the WCF op gives). The read is **hard-bounded** (≤30s) and throws `ProtocolEvidenceMissingException` on the no-row path rather than assert a false empty. Row-level retrieval is **not yet live-verified** — the dev box holds no events; pending a capture against an event-bearing 2023 R2 server. Use WCF for event reads |
|
||||
| `SendEventAsync` | ✅ | 🔌 | rides `AddStreamValues` family; no distinct event-send RPC, framing uncaptured over gRPC |
|
||||
| `EnsureTagAsync` / `DeleteTagAsync` / `RenameTagsAsync` | ✅ | ✅ | live-verified 2026-06-22 over gRPC (`HistoryService.EnsureTags` / `DeleteTags` / `StartJob`, write-enabled 0x401 session, WCF serializers reused) via a self-cleaning sandbox-tag lifecycle. Rename is an async StartJob — transiently rejectable right after create, so callers should retry |
|
||||
| `AddTagExtendedPropertiesAsync` | ✅ | ✅ | live-verified 2026-06-22 over gRPC (`HistoryService.AddTagExtendedProperties`, write-enabled session); a written prop now round-trips through `GetTagExtendedPropertiesAsync` (the multi-property parser fix above). `DeleteTagExtendedProperties` stays unshipped: probed over gRPC 2026-06-22 (prime `GetTgByNm`+`GetTepByNm` then `DelTep`, all on the one shared channel) — the server still rejects the delete (native code=1) and the property survives, so gRPC's multiplexed channel does **not** lift the WCF per-connection working-set wall |
|
||||
| `GetConnectionStatusAsync` | ✅ | ✅ | live-verified 2026-06-22 over gRPC — measured from the handshake (`OpenConnection` yields a storage-session GUID ⇒ connected). No dedicated RPC on either transport; store-forward connectivity stays false (D2-gated) |
|
||||
| `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. The recovered config RPCs carry
|
||||
the **same opaque `bytes` buffers the existing WCF serializers already emit**,
|
||||
keyed by the same `strHandle`/`uiHandle` session handle the read path obtains —
|
||||
confirmed by tooling the read-side config ops (`GetRuntimeParameter`,
|
||||
`GetTagExtendedProperties`) live: the WCF buffers ride the gRPC RPC unchanged and
|
||||
the server accepts them. Two caveats surfaced when capturing the rest: `ExecuteSqlCommand`
|
||||
is **server-walled** (the front-door `CSrvDbConnection` faults on a DB-connection
|
||||
precondition the managed session doesn't establish — the same *class* of wall as
|
||||
`OpenStorageConnection`), and `ReadEvents` is now tooled over gRPC (the CM_EVENT
|
||||
registration state machine is ported and `StartEventQuery` succeeds) but its row
|
||||
retrieval is not yet live-verified: the gRPC server long-polls
|
||||
`GetNextEventQueryResultBuffer` on no data instead of returning the WCF code-85
|
||||
terminal, so on the idle dev box the bounded read throws
|
||||
`ProtocolEvidenceMissingException` rather than fabricate an empty result —
|
||||
confirming rows awaits an event-bearing 2023 R2 server. The remaining 🔌 row
|
||||
(`SendEventAsync`) is a **capture-and-wire** item (route the existing serializer
|
||||
into a gRPC orchestrator + live-capture), not protocol-discovery — but per
|
||||
"capture first, never guess wire bytes" it stays untooled until verified live. The
|
||||
natural production pattern today: `RemoteGrpc` now covers reads,
|
||||
`AddHistoricalValuesAsync`, the tag-config writes (create/delete/rename/extended
|
||||
properties, including read-back), and connection status — all live-verified. Use
|
||||
WCF for SQL (server-walled on gRPC) and event reads/sends (gRPC event rows are
|
||||
long-poll-blocked pending an event-bearing server).
|
||||
|
||||
> 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 +217,29 @@ $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
|
||||
$env:HISTORIAN_GRPC_WRITE_SANDBOX_TAG = 'SandboxTag' # gates the DESTRUCTIVE tag create/rename/delete lifecycle 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 +325,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).
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
# gRPC Tooling Completion Plan
|
||||
|
||||
Status as of 2026-06-22. Tracks the remaining work to finish tooling the AVEVA
|
||||
Historian SDK's `RemoteGrpc` (2023 R2) transport so it reaches WCF surface parity.
|
||||
Self-contained for pickup after context compaction.
|
||||
|
||||
## Where things stand
|
||||
|
||||
The gRPC transport already tools: probe, raw/aggregate/at-time reads, browse,
|
||||
metadata, system-parameter, server time-zone, measured store-forward status,
|
||||
`AddHistoricalValues` backfill write, **and** (newest, branch `grpc-config-ops`,
|
||||
3 commits, NOT yet merged — `main` = `035d8a9`):
|
||||
|
||||
- `GetRuntimeParameterAsync` — ✅ live-verified
|
||||
- `GetTagExtendedPropertiesAsync` (read) — ✅ live-verified
|
||||
- `ExecuteSqlCommandAsync` — ⛔ server-walled, bounded behind `ProtocolEvidenceMissingException`
|
||||
- `EnsureTag` / `DeleteTag` / `RenameTags` / `AddTagExtendedProperties` — 🧪 tooled + routed, sandbox-gated, **not yet run destructively live**
|
||||
- `ReadEventsAsync` — ⚠️ tooled + routed 2026-06-22 (item #2 below): chain runs, `StartEventQuery` succeeds, but `GetNextEventQueryResultBuffer` long-polls on no data; hard-bounded (≤30s) and throws `ProtocolEvidenceMissingException` on the no-row path. Row retrieval pending an event-bearing server.
|
||||
|
||||
Test baseline: 317 offline green, 19 gRPC-live green. Relevant memory:
|
||||
`project_grpc_config_ops_tooling`, `project_m0_grpc_parity`,
|
||||
`project_roadmap_exhausted_2020wcf`, `reference_2023r2_live_server_access`,
|
||||
`reference_wonder_sql_vd03_credentials`.
|
||||
|
||||
## Proven pattern (reuse for everything below)
|
||||
|
||||
A WCF config op is tooled over gRPC by reusing its **existing byte serializer/parser
|
||||
verbatim** inside the protobuf `bytes` fields, keyed by the Open2 session handle:
|
||||
|
||||
- `HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);`
|
||||
- `HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, ct[, connectionMode]);`
|
||||
- `session.StringHandle` = uppercase Open2 GUID → **string-handle** ops (Retrieval/Status/History string-handle RPCs).
|
||||
- `session.ClientHandle` = transient `uint` → **uint-handle** ops (StartQuery, DeleteTags, GetNext*).
|
||||
- write ops pass `connectionMode: HistorianWcfAuthChainHelper.NativeIntegratedWriteEnabledConnectionMode` (0x401).
|
||||
- Call `new <Service>.<Service>Client(connection.Channel).<Rpc>(request, connection.Metadata, DateTime.UtcNow.Add(options.RequestTimeout), ct)`.
|
||||
- Check `response.Status?.BSuccess`; decode error via `response.Status?.BtError` (hex = native byte0 0x84 + LE u32 code, often followed by facility/file/message ASCII — this decode cracked the SQL + extended-prop cases).
|
||||
- The gRPC RetrievalService string-handle ops do NOT need the WCF `Retr.GetV` prime.
|
||||
|
||||
Proto field-name reference and WCF serializer signatures: see the mapping captured
|
||||
in `project_grpc_config_ops_tooling` memory and `Grpc/Protos/*.proto`.
|
||||
|
||||
## Remaining items (priority order)
|
||||
|
||||
### 1. Live-verify the write ops — ✅ DONE 2026-06-22
|
||||
**Outcome:** ran the gated lifecycle against a synthetic sandbox tag (`ZZ_SdkGrpcWriteProbe`); the
|
||||
writes flip 🧪→✅. `EnsureTags` (create), `AddTagExtendedProperties`, `StartJob` rename, and
|
||||
`DeleteTags` all succeed live over gRPC (write-enabled 0x401 session, WCF serializers reused) — NO
|
||||
priming discovery-dance needed. Two findings: (a) **rename** is an async StartJob that the server can
|
||||
transiently reject right after the create commits and on target-name collision — the test now
|
||||
pre-cleans both names and retries rename (4×); callers should likewise retry. (b) **reading a written
|
||||
extended property back** via `GetTagExtendedPropertiesAsync` hits a shared-parser evidence gap (value
|
||||
marker `0x01` where the parser expects compact-string `0x09`) — a read-side gap, not a write failure;
|
||||
the test tolerates it. Lifecycle test is self-cleaning and best-effort cleans up (rename is async +
|
||||
the browse/metadata view is eventually consistent, so a hard absence assert would be racy).
|
||||
**Read-side follow-up DONE 2026-06-22:** captured the live `GetTagExtendedPropertiesFromName` bytes
|
||||
and fixed the parser — the response is one group per property (tag name repeats) with a **uint16
|
||||
searchability-flags trailer** per property (e.g. `0x0003` built-in, `0x0001` user-added), NOT the
|
||||
1-byte group trailer the old model assumed (which drifted one byte per group → `0x09`-vs-`0x01`). A
|
||||
written prop now round-trips end-to-end live; golden multi-group test added.
|
||||
|
||||
_Original notes:_
|
||||
- **Goal:** flip the 🧪 writes to ✅ by running the gated lifecycle test against a sandbox tag.
|
||||
- **How:** set `HISTORIAN_GRPC_WRITE_SANDBOX_TAG` to a throwaway name and run
|
||||
`TagWriteLifecycle_OverGrpc_CreatesAddsPropRenamesDeletes` against the live 2023 R2 box.
|
||||
- **Risk/gotcha:** if any write is rejected, the first fix is to add the WCF write
|
||||
**priming discovery-dance** (`HistorianWcfTagWriteOrchestrator.RunWritePriming`:
|
||||
UpdC3 + 6 `GetSystemParameter` + `AllowRenameTags` + Trx/Stat/Retr `GetV`) to
|
||||
`HistorianGrpcTagWriteOrchestrator` over the gRPC StatusService/HistoryService.
|
||||
Rename also needs server `AllowRenameTags` enabled. Needs explicit user OK to
|
||||
mutate the shared server (they previously chose "no live mutate").
|
||||
- **Files:** `tests/.../HistorianGrpcIntegrationTests.cs` (run only),
|
||||
`src/.../Grpc/HistorianGrpcTagWriteOrchestrator.cs` (priming only if rejected).
|
||||
|
||||
### 2. ReadEvents over gRPC (heaviest read op) — ✅ TOOLED 2026-06-22 (rows pending event-bearing server)
|
||||
**Outcome:** `ReadEventsAsync` is routed over gRPC (`HistorianGrpcEventOrchestrator`). The CM_EVENT
|
||||
registration replay (`UpdateClientStatus`→6 `GetSystemParameter`→`RegisterTags`→cross-service version
|
||||
probes→`EnsureTags`, captured buffers shared with WCF via `HistorianEventRegistrationProtocol`) runs
|
||||
and **`StartEventQuery` succeeds live**. The blocker that remains is server behavior, not the port:
|
||||
`GetNextEventQueryResultBuffer` **long-polls** when the query has no rows — it blocks to the call
|
||||
deadline instead of returning the synchronous 5-byte type=4 code=85 terminal the 2020 WCF op returns.
|
||||
Per-call gRPC-Web deadlines proved unreliable over the tunnel (a 4s-deadline chain still ran >90s), so
|
||||
the read is hard-bounded by an **overall linked-CTS budget** (≤30s, scaled to `RequestTimeout`); gRPC
|
||||
honors token cancellation. On the no-row path the orchestrator throws `ProtocolEvidenceMissingException`
|
||||
rather than assert a false-empty list. The idle dev box holds no events, so **row-level retrieval is
|
||||
not yet live-verified** — flip the gated test
|
||||
`ReadEventsAsync_OverGrpc_StartsQueryButRowRetrievalIsLongPollBlocked` to assert parsed rows once an
|
||||
event-bearing 2023 R2 server is available (and consider whether the long-poll needs a "fetch historical
|
||||
then stop" request flag the native client may set). README row is ⚠️.
|
||||
|
||||
_Original notes (still the reference for the registration replay):_
|
||||
- **Goal:** route `ReadEventsAsync` over gRPC.
|
||||
- **RPCs (exist):** `RetrievalService.StartEventQuery` (`uiHandle`, `uiQueryRequestType`,
|
||||
`btRequest`) → `{Status, uiQueryHandle, btResonse}`; `GetNextEventQueryResultBuffer`
|
||||
(`uiHandle`, `uiQueryHandle`) → `{Status, btResult}`; `EndEventQuery`.
|
||||
- **Reuse:** `HistorianEventQueryProtocol.CreateStartEventQueryAttempts(...)` for the
|
||||
request buffer (`QueryRequestTypeEvent`), `HistorianEventRowProtocol.Parse(...)` for rows.
|
||||
- **The hard part — port the CM_EVENT registration state machine.** Without it,
|
||||
`GetNextEventQueryResultBuffer` returns native error type=4 **code=85**. WCF does this
|
||||
in `HistorianWcfEventOrchestrator.AddCmEventTagViaAddT`: UpdC3 → 6 system params →
|
||||
`RegisterTags2` (CM_EVENT tag id `353b8145-5df0-4d46-a253-871aef49b321`, 24-byte
|
||||
RTag2 buffer) → cross-service `GetV` → `EnsureTags2` (CM_EVENT CTagMetadata via
|
||||
`HistorianAddTagsProtocol.SerializeCmEventCTagMetadata`). gRPC equivalents:
|
||||
`HistoryService.RegisterTags`, `HistoryService.EnsureTags`,
|
||||
`HistoryService.UpdateClientStatus`, `StatusService.GetSystemParameter`.
|
||||
- **Approach:** new `Grpc/HistorianGrpcEventOrchestrator`. Open a read-only session,
|
||||
replay the registration over gRPC (RegisterTags + EnsureTags + the discovery calls),
|
||||
then run StartEventQuery → loop GetNextEventQueryResultBuffer → EndEventQuery, parsing
|
||||
rows. Route in `Historian2020ProtocolDialect.ReadEventsAsync` on `UseGrpc`.
|
||||
- **Verify:** live (read-only, safe) against the 2023 R2 box; dev box may return no
|
||||
rows (env) — assert "no error 85 + chain completes," mirror the WCF event test.
|
||||
- **Risk:** medium-high. Registration may need exact call ordering; capture the error
|
||||
buffer (hex+ASCII) at each step if code 85 persists.
|
||||
|
||||
### 3. SendEvent over gRPC
|
||||
- **Goal:** route `SendEventAsync` over gRPC.
|
||||
- **Blocker:** no distinct event-send RPC; WCF rides `AddStreamValues2` (the
|
||||
`HistorianEventWriteProtocol.SerializeAddStreamValuesBuffer` VTQ). The gRPC framing is
|
||||
**uncaptured** — needs a native-client gRPC capture before implementing (per
|
||||
"capture first, never guess"). Depends on #2 (same CM_EVENT registration).
|
||||
- **Risk:** high / blocked on capture. Lowest priority.
|
||||
|
||||
### 4. (Stretch) SQL server-wall investigation — ❌ RegisterTags prime does NOT help (2026-06-22)
|
||||
- `ExecuteSqlCommand` over gRPC faults server-side in `CSrvDbConnection.ExecuteSqlCommand`
|
||||
(IndexOutOfRange / native err 38). Tried the `HistoryService.RegisterTags`-family prime
|
||||
before `ExecuteSqlCommand` on both read-only (0x402) and write-enabled (0x401) sessions:
|
||||
it does **not** clear the wall — `RegisterTags` itself returned false and `ExecuteSqlCommand`
|
||||
faulted with the identical native-38 error (decoded buffer: `...CSrvDbConnection.ExecuteSqlCommand
|
||||
... System.IndexOutOfRangeException`). So unlike OpenStorageConnection, the SQL DB-connection
|
||||
context is NOT established by the RegisterTags family. The op stays bounded behind
|
||||
`ProtocolEvidenceMissingException`; use WCF for SQL. Remaining avenues are deeper (reproduce
|
||||
the server-side DB connection-string/index setup the native client triggers) — low priority.
|
||||
|
||||
### 5. GetConnectionStatus over gRPC — ✅ DONE 2026-06-22
|
||||
- `HistorianGrpcStatusClient.GetConnectionStatusAsync` synthesizes the status from a measured
|
||||
gRPC handshake (OpenConnection yielding a storage-session GUID ⇒ connected), mirroring the WCF
|
||||
synthesize-from-probe approach. Routed in `Historian2020ProtocolDialect` on `UseGrpc` (the WCF
|
||||
path used the MDAS binding, which can't reach the gRPC port). Live-verified; store-forward
|
||||
connectivity stays false (D2-gated). Gated test `GetConnectionStatusAsync_OverGrpc_ReportsConnected`.
|
||||
|
||||
### Out of scope
|
||||
- `ReadBlocks` (`StartBlockRetrievalQuery`) — never captured on either transport; leave
|
||||
throwing `ProtocolEvidenceMissingException`.
|
||||
- `DeleteTagExtendedProperties` — ❌ **PROBED 2026-06-22, multiplexed-channel hypothesis DISPROVEN.**
|
||||
The WCF block (server resolves the property from a per-connection working set the SDK's separate
|
||||
per-service channels can't populate) is NOT lifted by gRPC. The probe
|
||||
(`HistorianGrpcTagWriteOrchestrator.ProbeDeleteTagExtendedPropertiesAsync`) runs the native
|
||||
`GetTgByNm` → `GetTepByNm` → `DelTep` sequence over ONE write-enabled (0x401) session on gRPC's
|
||||
single shared channel. Live against the 2023 R2 server (History iface 12): both primes succeed on the
|
||||
shared channel (`TgPrimeBytes=98`, `TepPrimePages=1`) yet `DelTep` is still rejected with native
|
||||
**code=1** (the 5-byte error buffer's byte0=132 is the universal `0x84` marker, not a code) and the
|
||||
property survives. Conclusion: the working set the server consults is populated by something the SDK
|
||||
can't reproduce even over one connection — most likely the native client's in-process registration
|
||||
object, not the wire session. Stays server-blocked on BOTH transports; not shipped publicly. Pinned
|
||||
by the gated negative test `DeleteTagExtendedProperties_OverGrpc_ProbeMultiplexedChannel` (flips if a
|
||||
future server/registration lifts the wall).
|
||||
|
||||
## Live verification setup (every live run)
|
||||
|
||||
Tunnel to `WONDER-SQL-VD03` must be up (gRPC `localhost:32565`, TLS, cert CN
|
||||
`WONDER-SQL-VD03`; hosts entry present). Creds in gitignored `wonder-sql-vd03.txt`
|
||||
(**QUOTED, colon-delimited** — strip quotes; use the `domainusername`/`domainpassword`
|
||||
NAM domain account, which works for Historian gRPC; `wonderapp` does NOT). Env:
|
||||
|
||||
```
|
||||
HISTORIAN_GRPC_HOST=wonder-sql-vd03 HISTORIAN_GRPC_PORT=32565
|
||||
HISTORIAN_GRPC_TLS=true HISTORIAN_GRPC_DNSID=WONDER-SQL-VD03
|
||||
HISTORIAN_USER=<domain user> HISTORIAN_PASSWORD=<domain pass>
|
||||
HISTORIAN_TEST_TAG=SysTimeSec
|
||||
# writes only, destructive: HISTORIAN_GRPC_WRITE_SANDBOX_TAG=<throwaway>
|
||||
# slow links: HISTORIAN_GRPC_TIMEOUT=120
|
||||
```
|
||||
|
||||
Run a subset: `dotnet test ./Histsdk.slnx --no-build --filter "FullyQualifiedName~<name>"`.
|
||||
Aggregate tests self-calibrate their window from a real raw sample (the box is idle/
|
||||
not-collecting). Sanitization scan before any commit:
|
||||
`wonder-sql-vd03|zimmer|nam\\|dohertj2|ADOBuild` over commit-safe files.
|
||||
|
||||
## Standing constraints
|
||||
- Never commit credentials/hostnames/customer tag names/raw captures — placeholders only.
|
||||
- `src/` stays pure managed .NET 10 (one allowed P/Invoke: SSPI). Never modify `current/`
|
||||
or `aveva-install-*/`.
|
||||
- Commit only when asked; branch first if on `main`; required footers
|
||||
(Co-Authored-By + Claude-Session). Capture wire bytes before implementing — never guess.
|
||||
@@ -74,7 +74,7 @@ blob needs RE).
|
||||
| Create string/discrete tag | `AddTag` | `History.EnsureTags` | ⬜ | GATED/BOUNDED | native AddTag rejects these types server-side; needs different metadata path |
|
||||
| Delete tag(s) | `DeleteTags` | `History.DeleteTags` | ✅ | DONE | |
|
||||
| Rename tag(s) | `RenameTags` | (History op) | ⬜ | BOUNDED | `AllowRenameTags` param already probed |
|
||||
| Add/Delete extended properties | `AddTagExtendedProperties`, `DeleteTagExtendedPropertiesByName` | `History.AddTagExtendedProperties` / `DeleteTagExtendedProperties` | ⬜ | BOUNDED | gRPC op + TEP serialize |
|
||||
| Add/Delete extended properties | `AddTagExtendedProperties`, `DeleteTagExtendedPropertiesByName` | `History.AddTagExtendedProperties` / `DeleteTagExtendedProperties` | 🟗 | BOUNDED | **Add DONE** (`AddTagExtendedPropertiesAsync`, AddTEx; inBuff = inverse of R1.5 read framing + trailing `01 00`). Delete (DelTep) deferred — native sync gate (err 229) blocks capturing its inBuff. See `wcf-add-tag-extended-properties.md` |
|
||||
| Add/Delete localized properties | `AddTagLocalizedProperties`, `DeleteTagLocalizedPropertiesByName` | `History.AddTagLocalizedProperties` / `DeleteTagLocalizedProperties` | ⬜ | BOUNDED | |
|
||||
|
||||
## 6. Data writes — values
|
||||
@@ -99,7 +99,7 @@ blob needs RE).
|
||||
|---|---|---|---|---|---|
|
||||
| System parameter | `GetSystemParameter` | `Status.GetSystemParameter` | ✅ | DONE | |
|
||||
| Runtime parameter | `GetRuntimeParameter` | `Status.GetRuntimeParameter` | ⬜ | TRIVIAL | same shape as GetSystemParameter |
|
||||
| Historian info | `GetHistorianInfo` | `Status.GetHistorianInfo` | 🟗 | BOUNDED | GETHI buffer; partially decoded (incl. EventStorageMode @ offset 514) |
|
||||
| Historian info | `GetHistorianInfo` | `Status.GetHistorianInfo` | 🟗 | BOUNDED | **2020 WCF = version-only** (GETHI is a named-value query; `EventStorageMode` not on the wire). 518-byte struct + `EventStorageMode`@514 is gRPC/2023R2-only. See `wcf-historian-info.md` |
|
||||
| Server timezone | `GetSystemTimeZoneInfo` | `Status.GetSystemTimeZoneName` | ⬜ | TRIVIAL | |
|
||||
| Historization status | `GetHistorizationStatus` | `Status` op | ⬜ | BOUNDED | |
|
||||
| Store-and-forward status | `GetStoreForwardStatus` | (push events / pull GETHI) | 🟗 | HARD | currently synthesized; real read needs duplex push or a decoded pull endpoint — see store-forward plan |
|
||||
|
||||
+201
-51
@@ -9,15 +9,75 @@ HCAL replacement, built on the **2023 R2 gRPC transport**. Derived from
|
||||
> protocol serializer/parser + golden-byte unit test + an env-gated live integration
|
||||
> test against the local Historian.
|
||||
|
||||
## Progress (updated 2026-06-19)
|
||||
## Status: roadmap exhausted (2026-06-22)
|
||||
|
||||
The reachable surface is **complete**. M0/M1/M2/M3 are done and live-verified; M4 R4.1 (store-forward
|
||||
outbox), R4.3 (measured idle-state SF status), and R4.4 (redundancy) are shipped and **merged to
|
||||
`main`**; and the follow-on `grpc-tooling-completion.md` plan is fully executed (writes, ReadEvents,
|
||||
GetConnectionStatus, the extended-property read-parser fix — all done or bounded-out). The transport
|
||||
matrix in the top-level `README.md` is the authoritative per-operation status.
|
||||
|
||||
**Nothing left is a pure code task** — every remaining item is gated:
|
||||
|
||||
- **Infra-gated** (needs a different live server): gRPC event *row* retrieval (`StartEventQuery`
|
||||
succeeds but `GetNextEventQueryResultBuffer` long-polls — needs an **event-bearing** 2023 R2 server);
|
||||
R4.3 active-SF *magnitude* (needs an **SF-active** server / the D2 storage-engine console handle).
|
||||
- **Capture-gated** ("capture before guessing wire bytes"): `SendEvent` over gRPC (no distinct RPC;
|
||||
framing uncaptured).
|
||||
- **Architecturally walled** (no client-side fix): `ExecuteSqlCommand` over gRPC (server-side
|
||||
`CSrvDbConnection` fault; a `RegisterTags` prime does not clear it); R4.2 revision *edits*
|
||||
(storage-engine-pipe-only on both transports).
|
||||
- **Out of scope until a demand signal**: `ReadBlocks` (`StartBlockRetrievalQuery`, never captured);
|
||||
`DeleteTagExtendedProperties` (server-blocked on WCF).
|
||||
|
||||
## Progress (updated 2026-06-22)
|
||||
|
||||
- ✅ **R0.6 version gate** — `HistorianServerVersionGate` + `HistorianClientOptions.VerifyServerInterfaceVersion`;
|
||||
fail-closed on connect, wired into both WCF and gRPC paths. Supported versions are
|
||||
evidence-based (Hist=11, Retr=4, Trx=2; Status reachability-only), captured from the
|
||||
live server. 10 unit tests.
|
||||
evidence-based (Hist=11/12, Retr=4, Trx=2; Status reachability-only), captured from the
|
||||
live server. History 12 (2023 R2 gRPC) accepted alongside 11 (buffer-compatible).
|
||||
- ✅ **CW-1 capture pipeline** — `ProtocolCaptureSanitizer` + `ProtocolFixtureWriter` +
|
||||
`capture-tag-info` CLI command; produces sanitized `fixtures/protocol/<op>/` golden files.
|
||||
11 unit tests. First fixture: `get-tag-info/analog-*.json`.
|
||||
- ✅ **gRPC auth handshake (read chain)** — LIVE-VERIFIED 2026-06-21 against a real 2023 R2
|
||||
server: `ReadRawAsync` over `RemoteGrpc` returns rows. Token loop routes to
|
||||
`StorageService.ValidateClientCredential`. Shared handshake extracted to
|
||||
`Grpc/HistorianGrpcHandshake` for reuse by the status/browse/metadata paths.
|
||||
- ✅ **R0.4 Probe over gRPC** — `Grpc/HistorianGrpcProbe` (History/Retrieval/Status
|
||||
`GetInterfaceVersion`); `ProbeAsync` routes over gRPC when `Transport==RemoteGrpc`.
|
||||
**LIVE-VERIFIED 2026-06-21** (no credentials required — runs before the auth loop).
|
||||
- ✅ **R0.3 System parameter over gRPC** — `Grpc/HistorianGrpcStatusClient.GetSystemParameterAsync`
|
||||
(`StatusService.GetSystemParameter`); routed in the dialect. Built + unit-tested + **LIVE-VERIFIED
|
||||
2026-06-21** against a real 2023 R2 server (returned `HistorianVersion`). Code path is the proven
|
||||
handshake + a single string-in/string-out RPC.
|
||||
- ✅ **R0.2 Tag metadata over gRPC** — `Grpc/HistorianGrpcTagClient.GetTagMetadataAsync`
|
||||
(`RetrievalService.GetTagInfosFromName`, the plural **string-handle** op). `GetTagMetadataAsync`
|
||||
routes over gRPC when `Transport==RemoteGrpc`. Request `btTagNames` = `uint count + per-name(uint
|
||||
charCount + UTF-16LE)` (golden-byte unit-tested); response `btTagInfos` = `uint count + CTagMetadata`
|
||||
records (reuses `ParseGetTagInfoResponse`); string handle = uppercase Open2 storage GUID. The 2020
|
||||
WCF string-handle wall does **not** apply on the gRPC front door (as predicted). **LIVE-VERIFIED
|
||||
2026-06-21** — `GetTagMetadataAsync` returned the requested tag + a valid data type.
|
||||
- ✅ **R0.1 Browse over gRPC** — DONE, **LIVE-VERIFIED 2026-06-21**.
|
||||
`HistorianClient.BrowseTagNamesAsync` routes over gRPC via
|
||||
`Grpc/HistorianGrpcTagClient.BrowseTagNamesAsync`: StartTagQuery(**OData** filter) → paged
|
||||
**QueryTag** (`btRequest` = `u16 0x6752 + u16 1 + u16 queryType + u32 startIndex + u32 count`) →
|
||||
EndTagQuery; response = `u32 count + per-name(u32 charCount + UTF-16LE) + trailer`. The SDK glob
|
||||
filter is translated by `GlobToODataFilter` (`Pre*`→`startswith`, `*suf`→`endswith`, `*mid*`→
|
||||
`contains`, exact→`eq`). The QueryTag packet-id `0x6752` was recovered from a `.rdata`
|
||||
packet-descriptor table (`{0x6751,1}`=StartTagQuery, `{0x6752,1}`=QueryTag) — no Ghidra needed.
|
||||
Golden-byte + glob unit tests + gated live test. Full finding:
|
||||
`docs/reverse-engineering/grpc-tag-query-odata.md`.
|
||||
|
||||
> ✅ **Milestone 0 (gRPC parity) is COMPLETE** — probe, system-param, metadata, and browse all run
|
||||
> over `RemoteGrpc` and are live-verified against a real 2023 R2 server, alongside the read chain.
|
||||
|
||||
> ℹ️ **Auth note (2026-06-21, resolved):** an apparent NTLM round-1 `SEC_E_LOGON_DENIED` blocker
|
||||
> turned out to be a **test-harness credential-parsing bug**, not a server/account/SDK issue — the
|
||||
> gitignored creds file stores **quoted** values (`"nam\user"`, `"pass"`), and the env-setup must
|
||||
> **strip surrounding quotes** before exporting `HISTORIAN_USER`/`HISTORIAN_PASSWORD`. With quotes
|
||||
> stripped, the domain account authenticates and the full read + system-param + probe chain passes
|
||||
> live. The round-failure diagnostic added during the hunt is kept
|
||||
> (`HistorianNativeHandshake.DescribeError` decodes the native error + hex/ASCII preview).
|
||||
|
||||
> ⚠️ **Live-verification constraint:** the local Historian is **2020** (WCF, port 32568) — the
|
||||
> 2023 R2 gRPC endpoint (32565) is absent. M0's gRPC routing (R0.1–R0.4) can be built and
|
||||
@@ -36,10 +96,28 @@ HCAL replacement, built on the **2023 R2 gRPC transport**. Derived from
|
||||
> the boundary is the **handle type** (see the string-handle wall note under §1b and
|
||||
> `docs/reverse-engineering/wcf-string-handle-wall.md`): **`uint`-handle ops work, `string`-handle
|
||||
> ops are blocked.** GETHI/GetTepByNm were probed and confirmed blocked (not, as first guessed,
|
||||
> reachable). The genuinely reachable next items on 2020 WCF are the remaining **`uint`-handle**
|
||||
> ops: **R1.8/R1.9 StartQuery summary/state modes** and **R1.7 event filters** (filter bytes ride
|
||||
> the proven `uint`-handle `StartEventQuery`). Everything string-handle waits on one RE target:
|
||||
> the native session/filter registration.
|
||||
> reachable). The reachable **`uint`-handle** items are now **DONE**: ~~R1.8/R1.9 StartQuery
|
||||
> summary/state modes~~ (resolved = existing `ReadAggregateAsync`) and ~~R1.7 event filters~~
|
||||
> (✅ 2026-06-20 — `ReadEventsAsync(…, HistorianEventFilter)`, live-honored). M2 event send is
|
||||
> also done (✅ WCF `AddS2`). **R1.2 `GetRuntimeParameterAsync` is also done** (✅ 2026-06-20,
|
||||
> `aa/Stat/GETRP`, live-verified) — notably a *string-handle* op that punches through the wall
|
||||
> using the Open2 storage-session GUID as an **uppercase** string handle, which proved the
|
||||
> GETHI/ExeC failures were a handle-*format* issue rather than a missing native registration.
|
||||
> **Follow-up done:** R1.1 `ExecuteSqlCommandAsync` shipped; R1.5 extended-property read shipped
|
||||
> (R1.6 collapsed into it — no distinct localized op). **R1.4 `GetHistorianInfo` bounded out on
|
||||
> 2020 WCF** — GETHI there is a named-value query (only `HistorianVersion`); `EventStorageMode` is
|
||||
> 2023R2-gRPC-only (see `wcf-historian-info.md`). Net: the **reachable 2020-WCF M1 read surface is
|
||||
> complete**; what remains is config *writes* (M1c — gated on an explicit user request) and the
|
||||
> gRPC/2023R2-only items (R1.3 timezone, R1.4 EventStorageMode — need a live 2023 R2 server).
|
||||
>
|
||||
> **Update 2026-06-21 (live 2023 R2 gRPC probe — both closed):** **R1.3 SHIPPED on gRPC** —
|
||||
> `GetServerTimeZoneAsync` returns a real zone ("Eastern Daylight Time") via
|
||||
> `StatusService.GetSystemTimeZoneName`; non-gRPC path fails closed
|
||||
> (`ProtocolEvidenceMissingException`). **R1.4 bounded out on gRPC too** — `GetHistorianInfo` is
|
||||
> named-value-only on the gRPC wire as well, `EventStorageMode` resolves under no name on either
|
||||
> `GetHistorianInfo` or `GetSystemParameter`, and the 518-byte struct is C++-HCAL-internal (filled
|
||||
> via native vtable+648, not the gRPC op). So **no gRPC/2023R2-specific reads remain open** — the
|
||||
> entire M1 read surface (2020 WCF + 2023 R2 gRPC) is now closed.
|
||||
|
||||
## Guiding principles
|
||||
|
||||
@@ -85,36 +163,40 @@ read/browse/status surface is Windows-free and the gRPC stack is the default pat
|
||||
### 1a. Trivial (XS–S each, no new payload format)
|
||||
| ID | Capability | gRPC op | Notes |
|
||||
|---|---|---|---|
|
||||
| ~~R1.1~~ | ~~`ExecuteSqlCommandAsync`~~ | `Retrieval.ExecuteSqlCommand` | ⚠ **Blocked on 2020 WCF.** Live-probed 2026-06-20: `ExeC` returns native error type 4 / code **51 (InvalidParameter)** for every handle variant — same unmapped *native session/filter registration* prerequisite that blocks `StartTagQuery`/`QueryTag` (see `implementation-status.md` lines ~982, ~1404). Needs that registration RE'd, or a 2023 R2 gRPC server. Do not wire via guessed calls. |
|
||||
| R1.2 | `GetRuntimeParameterAsync` | `Status.GetRuntimeParameter` | mirror `GetSystemParameter` |
|
||||
| ~~R1.3~~ | ~~`GetServerTimeZoneAsync`~~ | `Status.GetSystemTimeZoneName` | ⚠ **gRPC/2023R2-only.** Verified 2026-06-20: over **2020 WCF** this op is a stub (rc=0, empty value) in the `GetServerTime` family — not shippable here. Build+verify only against a live 2023 R2 server. See `docs/reverse-engineering/wcf-status-localhost.md`. |
|
||||
| ~~R1.1~~ | ~~`ExecuteSqlCommandAsync`~~ | `Retrieval.ExecuteSqlCommand` (`ExeC`+`GetR`) | ✅ **DONE (2026-06-20), live-verified.** `ExecuteSqlCommandAsync(sql)` → `HistorianSqlResult` (columns + typed rows). String-handle op via the uppercase storage GUID. Chain: `Retr.GetV` prime → `ExeC(handle, sql, option=0, ref queryHandle)` → `GetR` loop (note: `GetR` returns **false even on success** — the stream is in `pResultBuff` regardless; false = final page). `GetR`'s `pResultBuff` is an **NRBF-serialized `DataTable`** (`SerializationFormat.Xml`: members `XmlSchema` + `XmlDiffGram`). BinaryFormatter is gone from .NET 10, so it's decoded read-only with `System.Formats.Nrbf` + `XDocument` (no BinaryFormatter). Shipped: `HistorianSqlResult`/`HistorianSqlColumn`/`HistorianSqlExecuteOption`, `HistorianSqlResultProtocol`, `HistorianWcfSqlClient`, golden `WcfSqlResultProtocolTests`, gated live tests. See `docs/reverse-engineering/wcf-exec-sql.md`. |
|
||||
| ~~R1.2~~ | ~~`GetRuntimeParameterAsync`~~ | `Status.GetRuntimeParameter` (`aa/Stat/GETRP`) | ✅ **DONE (2026-06-20), live-verified.** Captured (`scripts/Capture-RuntimeParam.ps1`): GETRP is a **`string`-handle** op (GETHI's shape), but reachable from the managed client using the Open2 storage-session GUID as an **uppercase** string handle (`ToString("D").ToUpperInvariant()`). Returns `HistorianVersion` = `20,0,000,000` live. pRequestBuff = `54 67 01 00` + uint nameCount + per-name(uint charCount + UTF-16); pResponseBuff = version + uint resultCount + CRetVariant(`0x43` VT_BSTR + uint16 len + uint16 charCount + UTF-16). Single string-valued param only (multi-name framing inferred, not captured). Shipped: `HistorianClient.GetRuntimeParameterAsync(name)`; golden `WcfRuntimeParameterProtocolTests`. **Note:** GETRP punching through the string-handle wall with the uppercase storage GUID is a strong lead that GETHI/ExeC may be a handle-*format* issue — see `wcf-string-handle-wall.md` §Update. |
|
||||
| ~~R1.3~~ | `GetServerTimeZoneAsync` | `Status.GetSystemTimeZoneName` | ✅ **DONE on gRPC (2026-06-21), LIVE-VERIFIED** against the real 2023 R2 server — returns `"Eastern Daylight Time"`. `HistorianClient.GetServerTimeZoneAsync` routes over `RemoteGrpc` (`HistorianGrpcStatusClient.GetSystemTimeZoneNameAsync`, `uiHandle`-in/string-out, no buffer). The 2020 WCF op stays a client-side stub (rc=0, empty), so the non-gRPC path **throws `ProtocolEvidenceMissingException`** (fail-closed) rather than return an empty string. Golden message-shape + non-gRPC guardrail unit tests + gated live test. (2020-only routes — per-block `HistoryBlock.TimeZoneOffset`, SQL via R1.1 — remain DST-specific and are not this op.) |
|
||||
|
||||
> ⛔ **String-handle wall (2026-06-20).** R1.4/R1.5/R1.6 (and R1.1) are **all blocked on 2020
|
||||
> WCF** for the *same* reason: their ops take a **`string` GUID handle** and require an unmapped
|
||||
> native session/filter registration. Probed live — GETHI returns code 1 for the exact native
|
||||
> request shape across 5 handle formats + Stat.GetV priming; ExeC returns code 51. The proven
|
||||
> surface uses **`uint`-handle** ops only. **One RE target — the native string-handle session
|
||||
> registration — unblocks this whole sub-milestone.** Full analysis:
|
||||
> `docs/reverse-engineering/wcf-string-handle-wall.md`. R1.8/R1.9 (StartQuery summary/state modes)
|
||||
> are `uint`-handle and remain reachable on 2020 WCF.
|
||||
> ✅ **String-handle "wall" RESOLVED (2026-06-20) — it was a handle-FORMAT bug.** R1.4/R1.5/R1.6
|
||||
> (and R1.1) take a **`string` GUID handle**; the earlier "code 1/51 blocked" verdict came from
|
||||
> passing the Open2 storage GUID in .NET's default **lowercase**. Sent **uppercase**
|
||||
> (`storageSessionId.ToString("D").ToUpperInvariant()`) the same handle works: **GETRP** (R1.2,
|
||||
> shipped), **GETHI** (R1.4) and **ExeC** (R1.1) are all live-verified reachable, and **R1.5
|
||||
> `GetTepByNm`** is now **shipped + live-verified** (`GetTagExtendedPropertiesAsync`). **R1.6 has no
|
||||
> distinct op** (collapses into R1.5). Note: `QTB` (StartTagQuery) does **not** punch through — it
|
||||
> fails *server-side* (`CMdServer::StartActiveTagnamesQuery` over the `aahMetadataServer` pipe),
|
||||
> independent of handle format, so the index-based property/query paths stay blocked here. Full
|
||||
> analysis: `docs/reverse-engineering/wcf-string-handle-wall.md` (RESOLVED banner) and
|
||||
> `docs/reverse-engineering/wcf-tag-extended-properties.md`.
|
||||
> R1.8/R1.9 (StartQuery summary/state modes) are `uint`-handle and were already reachable.
|
||||
|
||||
### 1b. Bounded (decode one `bytes` payload; S–M each)
|
||||
| ID | Capability | gRPC op | Payload to decode | Depends |
|
||||
|---|---|---|---|---|
|
||||
| ~~R1.4~~ | `GetHistorianInfoAsync` | `Status.GetHistorianInfo` | ⛔ **string-handle wall** — GETHI returns code 1 on 2020 WCF (all handle/priming variants). GETHI buffer incl. `EventStorageMode`@514. | string-handle RE |
|
||||
| ~~R1.5~~ | Extended-property **read** | `Retrieval.GetTagExtendedPropertiesFromName` | ⛔ **string-handle wall** (GetTepByNm takes `string handle`). TEP result buffer. | string-handle RE |
|
||||
| ~~R1.6~~ | Localized-property **read** | `Retrieval.GetTagLocalizedPropertiesFromName` | ⛔ **string-handle wall** (same family). | string-handle RE |
|
||||
| R1.7 | Event **filters** | filter bytes in `Retrieval.StartEventQuery` | filter predicate encoding (name/op/value) — **`uint`-handle**, reachable | R0.5 |
|
||||
| R1.8 | Analog-summary query | `Retrieval.StartQuery` (summary mode) | summary row layout — **`uint`-handle, reachable. Scoped + decode targets located** (`CAnalogSummaryValue.UnpackFromValueBuffer`, fields Min/Max/First/Last/ValueCount/Integral/…). Plan: [`r1.8-r1.9-summary-queries.md`](r1.8-r1.9-summary-queries.md) | — |
|
||||
| R1.9 | State-summary query | `Retrieval.StartQuery` (state mode) | state-summary row layout — **`uint`-handle, reachable. Scoped** (`CStateSummaryStruct`: MinContained/MaxContained/TotalContained/PartialStart/PartialEnd/StateEntryCount). Plan: [`r1.8-r1.9-summary-queries.md`](r1.8-r1.9-summary-queries.md) | — |
|
||||
| ~~R1.4~~ | `GetHistorianInfoAsync` | `Status.GetHistorianInfo` (`GETHI`) | ⛔ **BOUNDED OUT — now confirmed on the 2023 R2 gRPC front door too (2026-06-21, live-probed).** The motivating field `EventStorageMode` is **not on the wire on either transport.** Live gRPC probe against the real 2023 R2 server: `GetHistorianInfo` is a **named-value** query exactly like 2020 WCF — only `HistorianVersion` resolves (→ `"23,1,000,000"` + `02 00 01 00` trailer); `EventStorageMode` + 7 name variants fail (`success=false`) on **both** `GetHistorianInfo` **and** `GetSystemParameter`. The 518-byte `HISTORIAN_INFO` struct (mode@514) is the **C++ HCAL in-memory model** (managed `HistorianAccess.GetHistorianInfo` fills it via a native **vtable+648** call, not the gRPC op — verified in the 2023 R2 decompile), derived outside the wire. The only wire-reachable field (version) is already shipped (`ProbeAsync`/`GetSystemParameterAsync`/`GetRuntimeParameterAsync`), so a struct API would be hollow + misleading. **Closes the prior "build against a live 2023 R2 server" caveat — done, and there is nothing to ship.** See `docs/reverse-engineering/wcf-historian-info.md`. | uppercase string handle |
|
||||
| ~~R1.5~~ | Extended-property **read** | `Retrieval.GetTagExtendedPropertiesFromName` (`GetTepByNm`) | ✅ **DONE (2026-06-20), live-verified.** `GetTagExtendedPropertiesAsync(tag)` → name/value pairs. String-handle op via the uppercase storage GUID; name-based path (`GetTagExtendedPropertiesByName`, not the QTB-gated TagQuery path). Request `tagNames` = `uint count` + per-name(`uint charCount`+UTF-16); response = `uint tagCount` + per-tag(marker + compact-ASCII name + `uint propCount` + per-prop(marker + compact-ASCII name + `0x43` VT_BSTR value) + trailer). Sequence-paged. Shipped: `HistorianTagExtendedPropertyProtocol`, golden `WcfTagExtendedPropertyProtocolTests`, gated live test. See `docs/reverse-engineering/wcf-tag-extended-properties.md`. | uppercase string handle |
|
||||
| ~~R1.6~~ | Localized-property **read** | (no op) | ⛔ **No distinct op on 2020 — collapses into R1.5.** There is no `GetTagLocalizedPropertiesFromName`/`GetTlpByNm` or `GetTagLocalizedPropertiesByName` in `current/aahClientManaged.dll`; the only "localized" surfaces are error-message/UI-text localization. Extended properties (R1.5) are the user-defined tag-property read surface. Closed, not throwing. | — |
|
||||
| ~~R1.7~~ | Event **filters** | filter bytes in `Retrieval.StartEventQuery` | ✅ **DONE (2026-06-20), live-honored.** `ReadEventsAsync(start, end, HistorianEventFilter)`. The filter rides `StartEventQuery`'s `pRequestBuff` (captured via `EventQuery.AddEventFilter` + instrument-wcf-writemessage; Equal vs Contains diffed to isolate the op). Filter block: `ushort 0 + uint filterCount + uint condCount + uint nameLen + name(UTF-16) + uint 1 + ushort op + uint 1 + value(0x09-len-0x00 compact-ASCII) + byte 0`. **REAL, not inert** (a non-matching predicate returns 0 events; matching returns the subset). Single string-valued predicate only; multi-filter (OR) / multi-condition (AND via `AddEventFilterCondition`) framing not yet fully captured. See `HistorianEventFilter`, golden `WcfEventQueryProtocolTests`. | — |
|
||||
| ~~R1.8~~ | Analog-summary query | `Retrieval.StartQuery` (summary mode) | ✅ **RESOLVED (2026-06-21) — no new code; == existing `ReadAggregateAsync`.** Request + response both captured (`scripts/Capture-SummaryRequest.ps1 -WithResponse`): the `GetNextQueryResultBuffer2` response is the **ordinary version-9 row buffer** the raw/aggregate parser already handles (decoded 7 rows = SQL ground truth exactly). There is **no rich `CAnalogSummaryValue` struct on the wire** — each row carries a *single* value selected by `RetrievalMode`/QueryType (Integral→8, TimeWeightedAverage→5, …), not an all-aggregates-in-one row; `ValueSelector`/`AggregationType`/`MaxStates` are **inert** on the WCF retrieval path (they configure the SQL provider, not this query). The all-aggregates-at-once shape is the SQL/OLEDB provider's, or the gRPC front door — not 2020 WCF binary. Plan + capture evidence: [`r1.8-r1.9-summary-queries.md`](r1.8-r1.9-summary-queries.md). | — |
|
||||
| ~~R1.9~~ | State-summary query | `Retrieval.StartQuery` (state mode) | ✅ **RESOLVED (2026-06-21) — same finding as R1.8.** State-summary is the **same `StartQuery2` request** (only `MaxStates`/defaults differ on the wire); the response carries no distinct `CStateSummaryStruct` on the 2020 WCF binary path. Covered by the existing aggregate read; no new `src/` code warranted. Plan: [`r1.8-r1.9-summary-queries.md`](r1.8-r1.9-summary-queries.md). | — |
|
||||
|
||||
### 1c. Bounded config writes (S–M each)
|
||||
| ID | Capability | gRPC op | Payload | Notes |
|
||||
|---|---|---|---|---|
|
||||
| R1.10 | `RenameTagsAsync` | History rename op | rename request buffer | `AllowRenameTags` already probed |
|
||||
| R1.11 | Extended-property **write** | `History.AddTagExtendedProperties` (+ groups) / `DeleteTagExtendedProperties` | TEP serialize | mirror analog CTagMetadata discipline |
|
||||
| R1.12 | Localized-property **write** | `History.AddTagLocalizedProperties` / `DeleteTagLocalizedProperties` | localized serialize | |
|
||||
| R1.13 | Non-analog tag create (string/discrete) | `History.EnsureTags` | distinct CTagMetadata variant | ⚠ native AddTag rejected some types — confirm server path first; may be GATED |
|
||||
| ~~R1.11~~ | Extended-property **write** | `History.AddTagExtendedProperties` (AddTEx) | ✅ **Add DONE (2026-06-21), live-verified.** `AddTagExtendedPropertiesAsync`/`AddTagExtendedPropertyAsync` (write mode, uppercase handle). inBuff = exact inverse of the R1.5 read framing (`uint32 groupCount + 0x01 + compact-ASCII tag + uint32 propCount + per prop[0x02 + compact-ASCII name + 0x43 VT_BSTR value] + 0x01 trailer + 0x00 terminator`); the trailing `0x00` is required or the server throws. Golden `WcfTagExtendedPropertyWriteProtocolTests` + gated live write/read-back test. **Delete (DelTep): wire format CAPTURED + serializer golden-proven (2026-06-21), but live delete is server-blocked and NOT shipped.** Captured via a two-session trick (add in Run A → fresh-session read-sync → delete in Run B, past the native err-229 client gate); inBuff = same group framing as Add but property-name-only and a `0x00` group trailer. A decisive experiment shows SDK-added properties ARE deletable (the native client deletes one), so SDK-add is complete; the SDK's own DelTep is rejected (`SErrorException` in `CHistStorage::DeleteTagExtendedProperties`) despite matching mode/handle/inBuff + GetTgByNm/GetTepByNm prime + open channel + 60s retries. Root cause: the native multiplexes services over ONE connection (per-connection working set), which the SDK's per-service WCF channels don't reproduce — needs transport-level multiplexing. See `docs/reverse-engineering/wcf-add-tag-extended-properties.md` §Delete. |
|
||||
| ~~R1.12~~ | Localized-property **write** | (no op) | ⛔ **No distinct op on 2020 — closed (mirror of R1.6).** A symbol sweep of `current/*.dll` finds no `AddTagLocalizedProperties` / `DeleteTagLocalizedProperties` / any `*LocalizedPropert*` / `TagLocalized*`; only UI/error-text localization (`GetLocalizedText`/`GetLocalizedMessage`/`LocalizedResourcesDir`). Localized properties are a 2023 R2/gRPC concept. Closed, not throwing. See `docs/reverse-engineering/wcf-tag-extended-properties.md` §R1.12. | 2026-06-21 |
|
||||
| ~~R1.13~~ | Non-analog tag create (string/discrete) | `History.EnsureTags` | distinct CTagMetadata variant | ⛔ **GATED — bounded out (2026-06-21, live-probed).** Native `AddTag` rejects every non-analog type **client-side** (`ErrorCode=ValidationFailed` / "Transaction validation failed", before any WCF op): SingleByteString, DoubleByteString, **and Int1** all fail; Float (control) succeeds. The native `HistorianDataType` enum has **no Discrete/Boolean** and no Int8/UInt8 (SDK-only extensions); `HistorianTag` has **no TagType setter** (type is data-type-derived). So no non-analog wire request is ever emitted → nothing to capture/implement. String/discrete create goes via a different subsystem (config editor / SQL), not this client's AddTag. `EnsureTagAsync` stays analog-only. See `docs/reverse-engineering/wcf-non-analog-tag-create.md`. |
|
||||
|
||||
**Acceptance:** read + browse + metadata + system/status + property R/W + summaries +
|
||||
event-filtered reads + rename all live-verified over gRPC.
|
||||
@@ -125,16 +207,34 @@ event-filtered reads + rename all live-verified over gRPC.
|
||||
|
||||
*Goal: `SendEventAsync(HistorianEvent)`. Path fully mapped in histevents.md; one capture away.*
|
||||
|
||||
| ID | Work | Detail |
|
||||
|---|---|---|
|
||||
| R2.1 | Capture the event value blob | Instrument `CCommonArchestraEventValue::PackToVtq` (or dump the VTQ value bytes) on a live `AddStreamedValue(HistorianEvent)`; save sanitized fixture |
|
||||
| R2.2 | `HistorianEventWriteProtocol` | Serialize header (`ReceivedTime, EventType, EventTime, Id, RevisionVersion, IsUpdate/IsDelete, Namespace`) + typed property bag — **inverse of `HistorianEventRowProtocol`** (reuse typemarkers `0x02/0x10/0x18/0x31/0x43/…`) |
|
||||
| R2.3 | Event write orchestrator | Open **Event** connection (write mode) → register CM_EVENT (already have) → `Storage.AddStreamValues` with the event VTQ |
|
||||
| R2.4 | Public API | `HistorianClient.SendEventAsync(HistorianEvent)` (+ `HistorianEvent` model: Type, EventTime, property bag) |
|
||||
| R2.5 | Round-trip test | Send an event → read it back via `StartEventQuery` / `v_AlarmEventHistory2`; golden-byte on R2.2 |
|
||||
> ✅ **DONE (2026-06-20) — `HistorianClient.SendEventAsync(HistorianEvent)` shipped and
|
||||
> live-accepted over 2020 WCF.** The headline assumption — that event delivery would ride the
|
||||
> non-WCF storage-engine pipe (and so be blocked like revision writes) — was **disproved by
|
||||
> capture**: a native `AddStreamedValue(HistorianEvent)` leaves over WCF as **`AddS2`
|
||||
> (`IHistoryServiceContract2.AddStreamValues2`)**. CM_EVENT is a built-in registered tag, so the
|
||||
> `129 TagNotFoundInCache` gate that blocks `AddS2` for user tags does **not** apply to events.
|
||||
> The full managed chain (Open2 event-mode **0x501** → CM_EVENT RTag2/EnsT2 → AddS2) is accepted
|
||||
> by the server (`AddS2` returns success, empty error buffer). See the event-send field map under
|
||||
> §"Event-send wire format" in `histevents.md` and `HistorianEventWriteProtocol`.
|
||||
>
|
||||
> ⚠️ **Persistence caveat (environment, not SDK):** on the local dev Historian, accepted events
|
||||
> are **not persisted** to the queryable store (`v_AlarmEventHistory2` latest stays at the
|
||||
> pre-test date; count only ages down). The **native** client exhibits the identical behaviour
|
||||
> (its `AddS2` also returns success but nothing lands), so this is the box's event-ingestion
|
||||
> pipeline not being active — not an SDK protocol gap. The SDK emits byte-equivalent `AddS2`
|
||||
> (golden-tested). Full send→store→read-back round-trip awaits a Historian with an active event
|
||||
> storage pipeline.
|
||||
|
||||
**Acceptance:** an event sent from histsdk appears in the historian and is read back with
|
||||
matching Type + properties. **Now practical** — Historian is installed locally.
|
||||
| ID | Work | Status |
|
||||
|---|---|---|
|
||||
| R2.1 | Capture the event value blob | ✅ `scripts/Capture-EventSend.ps1` (event-send harness scenario + instrument-wcf-{write,read}message); two captures diffed to separate constant framing from value fields. Decisive finding: event-send = WCF `AddS2`, not storage pipe. |
|
||||
| R2.2 | `HistorianEventWriteProtocol` | ✅ Serializes the `AddS2` pBuf (storage sample buffer wrapping the event VTQ): "OS" sig + sampleCount + length fields + CM_EVENT tag id + EventTime FILETIME + OpcQuality + opaque descriptor + event Id + ReceivedTime FILETIME + Namespace + EventType + version + typed property bag (string props reuse the read parser's `0x43` encoding). Golden-byte test pins capture A. |
|
||||
| R2.3 | Event write orchestrator | ✅ `HistorianWcfEventOrchestrator.SendEventAsync`: Open2 (0x501) → reuse CM_EVENT RTag2/EnsT2 registration → `AddStreamValues2(handle, pBuf, out err)` on the same /Hist channel + storage-session handle. |
|
||||
| R2.4 | Public API | ✅ `HistorianClient.SendEventAsync(HistorianEvent)`. Original events only (RevisionVersion=0) with string-valued properties; other property types + revision/update/delete throw `ProtocolEvidenceMissingException` until captured. |
|
||||
| R2.5 | Round-trip test | ✅ Golden-byte on R2.2 + gated live test `SendEventAsync_AgainstLocalHistorian_AcceptedByServer` (asserts server acceptance; SQL read-back best-effort given the persistence caveat). |
|
||||
|
||||
**Acceptance:** an event sent from histsdk is accepted by the historian over WCF with a
|
||||
byte-correct `AddS2` (✅). Appears-and-reads-back is environment-gated on event persistence (see caveat).
|
||||
|
||||
---
|
||||
|
||||
@@ -142,14 +242,47 @@ matching Type + properties. **Now practical** — Historian is installed locally
|
||||
|
||||
*Goal: insert original historical VTQs (backfill), the path that is NOT the gated cache push.*
|
||||
|
||||
| ID | Work | gRPC op |
|
||||
|---|---|---|
|
||||
| R3.1 | Decode non-streamed VTQ packet | `Transaction.AddNonStreamValuesBegin/AddNonStreamValues/End` |
|
||||
| R3.2 | `AddHistoricalValuesAsync` | batched begin→values→end |
|
||||
| R3.3 | Ingest-permission validation | confirm the target accepts original-data insert (distinct from `AddS2` cache wall) |
|
||||
> ✅ **gRPC UNLOCK (2026-06-21, LIVE-VERIFIED): the transaction lifecycle is REACHABLE over the
|
||||
> 2023 R2 gRPC front door.** The `grpc-revision-probe` opened a **write-enabled** (`0x401`) gRPC
|
||||
> session and drove `TransactionService.AddNonStreamValuesBegin(storage-GUID **uppercase**)` →
|
||||
> real `strTransactionId` → `AddNonStreamValuesEnd(bCommit=false)` (discarded, no data written).
|
||||
> Where 2020 WCF returns `UnknownClient (51)`, the gRPC `TransactionService` is itself the gateway
|
||||
> to the storage engine, so the Open2 session GUID is accepted directly — **no legacy pipe**. This
|
||||
> answers the M3-over-gRPC question below: **yes**, the non-streamed *original* write transaction is
|
||||
> reachable from the pure-managed SDK. **Not yet shipped:** the `AddNonStreamValues` `btInput` VTQ
|
||||
> buffer must be captured before any value-commit (never guess wire bytes); revision *edits* (R4.2)
|
||||
> remain pipe-only even on gRPC. Full detail + decompile basis:
|
||||
> [`revision-write-path.md`](revision-write-path.md) §"2023 R2 gRPC — the wall is gone".
|
||||
>
|
||||
> ⛔ **BLOCKED on 2020 WCF — re-confirmed by the D2 probe (2026-05-05), see
|
||||
> [`revision-write-path.md`](revision-write-path.md).** The premise above ("the path that is NOT
|
||||
> the gated cache push") was **disproved** *on WCF*: R3.1's op
|
||||
> (`Transaction.AddNonStreamValuesBegin/AddNonStreamValues/End`) is the **same**
|
||||
> `ITransactionServiceContract2.AddNonStreamValuesBegin2` D2 probed, and over WCF it returns
|
||||
> `04 33 00 00 00` = `UnknownClient (51)` for every handle format **and** the full priming chain
|
||||
> (Stat/Hist/Retr/Trx GetV + UpdC3 + 6× GetSystemParameter + RTag2). Root cause (IL-walk:
|
||||
> `CClient.TransactionBegin` → `CHistStorageConnection.StartTransaction` →
|
||||
> `CStorageEngineConsoleClient.StartTransaction`): the real transaction rides a **shared-memory +
|
||||
> named-pipe** channel (`STransactPipeClient2` + `SCrtMemFile`) to `aaStorageEngine.exe`, separate
|
||||
> from WCF. The WCF Trx op is a server-side **relay** that requires a pre-existing storage-engine
|
||||
> pipe session, which no WCF op can establish. So **M3 over 2020 WCF is unimplementable as a
|
||||
> pure-managed SDK** — same architectural wall as R4.2 (revisions) and the `AddS2` cache gate.
|
||||
>
|
||||
> **Only remaining lever:** the **2023 R2 gRPC front door** (HCAL-native, no legacy storage-engine
|
||||
> pipe). Whether the gRPC services expose a non-streamed/revision write that bypasses the pipe is
|
||||
> **untested** — it needs the live 2023 R2 server + a native gRPC capture of the write op, then
|
||||
> decode/implement. Treat as on-demand (no current demand signal); the WCF path is closed.
|
||||
|
||||
**Acceptance:** historical points inserted and read back. Document clearly where this
|
||||
differs from (gated) streaming sample writes.
|
||||
| ID | Work | gRPC op | Status |
|
||||
|---|---|---|---|
|
||||
| R3.1 | Decode non-streamed VTQ packet | `History.AddStreamValues` ("ON" buffer) + `EnsureTags` | ✅ **CAPTURED + VALIDATED 2026-06-21.** Drove the native 2023 R2 client through a committed historical write (sandbox tag) with the IL-rewritten gRPC client dumping every `byte[]`; the value **read back over gRPC**. The path is **NOT** `AddNonStreamValues`/TransactionService — it's **`HistoryService.AddStreamValues`** with an **"ON" storage-sample buffer** (AddS2 "OS" family) + `EnsureTags`. Buffer decoded: `"ON"(0x4E4F) + u16 count + u32 totalLen + u16 payloadLen + 16B tag GUID + FILETIME + u16 quality + u32 type + FILETIME + 8B double`. D2 cache gate does NOT block the primed 2023 R2 client. See [`revision-write-path.md`](revision-write-path.md) §"R3.1 CAPTURED". |
|
||||
| R3.2 | `AddHistoricalValuesAsync` | `History.AddStreamValues` ("ON") + `EnsureTags` | ✅ **SHIPPED + LIVE-VALIDATED 2026-06-21.** `HistorianClient.AddHistoricalValuesAsync(tag, values)` over `RemoteGrpc`: write-enabled session → `GetTagInfosFromName` (resolves the per-tag GUID = tag-info `TypeId`) → `HistoryService.AddStreamValues` ("ON" buffer, golden-tested). The pure-managed SDK wrote a value and read it back live. All five analog types captured + golden-tested + live write/read-back validated — **Float/Double/Int2/Int4/UInt4** (value = `u32(0) + native-width value`, descriptor `C0 10 01 00` constant; width selected from the tag's declared type); other types throw. gRPC-only (non-gRPC throws). |
|
||||
| R3.3 | Ingest-permission validation | confirm the target accepts original-data insert (distinct from `AddS2` cache wall) | ✅ **confirmed** — the D2/AddS2 cache gate (err 129) does NOT block the primed 2023 R2 client; the historical write commits and reads back |
|
||||
|
||||
**Acceptance:** historical points inserted and read back. **WCF path closed (D2).** gRPC path:
|
||||
**transaction lifecycle proven (Begin/End live) + full sequence mapped**; the remaining insert is a
|
||||
focused follow-up — reproduce `StorageService.OpenStorageConnection` (+ `RegisterTags`), then decode
|
||||
the `btInput` VTQ buffer, each a live-production probe loop.
|
||||
|
||||
---
|
||||
|
||||
@@ -159,10 +292,10 @@ Only if the use case demands them. Each is a real subsystem, not an op.
|
||||
|
||||
| ID | Capability | Approach | Risk |
|
||||
|---|---|---|---|
|
||||
| R4.1 | Store-and-forward | **Pragmatic local queue** (durable outbox + replay on reconnect) rather than bit-faithful SF cache + `Forward*Snapshot`. Faithful SF = decode SF cache format + snapshot framing + recovery log | high; consider "good enough" |
|
||||
| R4.1 | Store-and-forward | ✅ **SHIPPED (2026-06-21) — pragmatic durable outbox.** `AVEVA.Historian.Client.StoreForward`: `HistorianStoreForwardWriter` buffers historical-value + event writes to an `IHistorianOutboxStore` (`FileHistorianOutboxStore` = crash-durable atomic JSON-per-entry, FIFO by filename sequence, corrupt-file quarantine; `InMemoryHistorianOutboxStore` for tests) and replays them through an `IHistorianWriteSink` (default `HistorianClientWriteSink`). Background drain loop retries on reconnect; FIFO head-of-line blocking with optional `MaxDeliveryAttempts` dead-lettering; `DropOldest`/`Reject` overflow policy; `GetStatusAsync` snapshot (Pending/Storing/ErrorOccurred mirrors the server SF semantics). 12 unit tests (durability-across-restart, reconnect-drain, head-of-line order, dead-letter, overflow, background loop). **NOT** the bit-faithful native SF cache (`Forward*Snapshot` decode) — that stays deferred; pure client-side, no RE. | high; consider "good enough" |
|
||||
| R4.2 | Revision / edit writes | `AddRevisionValue(s)` go via the **non-WCF storage-engine pipe** (`STransactPipeClient2`) — separate transport RE | high |
|
||||
| R4.3 | Real store-forward **status** | duplex push (`SetStoreForwardEvent`) or a decoded pull endpoint — see store-forward plan | medium |
|
||||
| R4.4 | Multi-historian / redundancy | client-side orchestration over N single-historian sessions (failover, ReSyncTags, partner watchdog) — build last | medium |
|
||||
| R4.3 | Real store-forward **status** | ⚠️ **PARTIAL — measured idle-state SHIPPED (2026-06-21, gRPC); active-SF magnitude D2-blocked.** Re-scoped against the recovered 2023 R2 gRPC contract (the old "duplex push vs pull" risk is gone — `StorageService` exposes SF state as plain *pull* RPCs). Idle-baseline probe (`grpc-sf-status-probe`) against the live 2023 R2 server resolved the open handle question: the direct SF pull RPCs (`GetSFParameter` / `GetRemainingSnapshotsSize`) require the `OpenStorageConnection` storage-engine **console handle** and are **D2-gated** (same wall as R4.2 revisions), so `Storing`/`Pending`/`DataStored` magnitude is unreachable from a pure managed client. But `StatusService.GetHistorianConsoleStatus` IS reachable on the session string handle, so `GetStoreForwardStatusAsync` over gRPC now returns a **measured** idle-state — it actually contacts the server and reports `ErrorOccurred` when unreachable (vs the old blind all-false synthesis), live-verified + gated test. Non-gRPC keeps the synthesized fallback. Active-SF magnitude (path b) stays deferred behind D2 + needs an invasive force-SF capture to decode the console-status enum. See `docs/plans/store-forward-cache-reverse-engineering.md` §9. | medium (idle done; magnitude D2-blocked) |
|
||||
| R4.4 | Multi-historian / redundancy | ✅ **SHIPPED (2026-06-21) — client-side orchestration.** `AVEVA.Historian.Client.Redundancy`: `HistorianRedundantClient` fronts N `IHistorianMember`s (default `HistorianClientMember` over `HistorianClient`) as one logical client. Reads fail over to the next member in priority order — streaming reads only fail over *before the first row* (mid-stream failures propagate to avoid dup/gap); writes fan out (`AllMembers`/`PreferredOnly`) with `All`/`Any` ack policy returning a per-member `HistorianRedundantWriteResult`. Per-member health (`FailureThreshold` demotion) + background watchdog (`CheckHealthAsync`/`PeriodicTimer`) restores recovered members; `GetStatus()` snapshot. Composes with R4.1: back a member's writes with a `HistorianStoreForwardWriter` for the pragmatic ReSyncTags equivalent (down member buffers + replays). 14 unit tests (failover order, mid-stream no-failover, ack policies, fanout modes, watchdog recovery, all-fail aggregation). Pure client-side, no server-side redundancy protocol, no RE. | medium |
|
||||
|
||||
---
|
||||
|
||||
@@ -201,12 +334,29 @@ Recommended first sprint: **CW-1 + M0 (R0.1–R0.6)** → a fully Windows-free,
|
||||
gRPC client at today's capability. Second sprint: **M1a + M2** (cheap wins + the headline
|
||||
event-send). M3/M4 as demand dictates.
|
||||
|
||||
> **Status 2026-06-21:** sprints 1 + 2 are **complete** (M0 gRPC parity, the reachable M1 surface,
|
||||
> and M2 event-send all shipped + live-verified; remaining M1 items are evidence-bounded-out). The
|
||||
> reachable surface on the **available 2020 WCF infrastructure is exhausted**. **M3 update
|
||||
> (2026-06-21):** with the live 2023 R2 server, the **M3 non-streamed write transaction is now
|
||||
> proven reachable over gRPC** — `TransactionService.AddNonStreamValuesBegin/End` round-trips live
|
||||
> (the D2 storage-engine-pipe wall is WCF-only). The remaining M3 work is bounded and concrete:
|
||||
> capture the `AddNonStreamValues` `btInput` VTQ buffer → golden-tested serializer → real
|
||||
> commit+read-back → public `AddHistoricalValuesAsync`. The other levers are unchanged: R4.2 revision
|
||||
> *edits* stay pipe-only even on gRPC, and M4 (SF / redundancy) is a HARD deferred subsystem.
|
||||
>
|
||||
> **M4 update (2026-06-21):** R4.1 store-and-forward, R4.4 redundancy, and R4.3 *measured idle-state*
|
||||
> SF status are all SHIPPED (pragmatic, client-side). What remains deferred sits behind the **D2
|
||||
> storage-engine console-pipe wall**: R4.2 revision edits and the R4.3 *active-SF magnitude*
|
||||
> (`Storing`/`Pending`/`DataStored`) — the SF pull RPCs that carry it need the console handle the
|
||||
> managed client can't obtain. Decoding the active-SF console-status enum additionally needs an
|
||||
> invasive force-SF capture on a sacrificial Historian.
|
||||
|
||||
## One-glance status
|
||||
|
||||
| Milestone | Tier | Effort | Value | When |
|
||||
|---|---|---|---|---|
|
||||
| M0 gRPC parity + capture tooling | foundation | M | unblocks everything, Windows-free | **now** |
|
||||
| M1 cheap surface | TRIVIAL/BOUNDED | M–L | most remaining read/config | next |
|
||||
| M2 event send | CAPTURE | S–M | headline write capability | next |
|
||||
| M3 historical writes | BOUNDED | M | backfill | on demand |
|
||||
| M4 SF / revisions / redundancy | HARD | L×N | parity completeness | defer |
|
||||
| M0 gRPC parity + capture tooling | foundation | M | unblocks everything, Windows-free | ✅ **done** |
|
||||
| M1 cheap surface | TRIVIAL/BOUNDED | M–L | most remaining read/config | ✅ **done** (reachable surface; rest bounded out) |
|
||||
| M2 event send | CAPTURE | S–M | headline write capability | ✅ **done** |
|
||||
| M3 historical writes | BOUNDED | M | backfill | ✅ **SHIPPED + LIVE-VALIDATED (2026-06-21)** — `AddHistoricalValuesAsync` over gRPC = `HistoryService.AddStreamValues` ("ON" buffer) + tag-GUID resolve. Pure-managed SDK write read back live. All 5 analog types (Float/Double/Int2/Int4/UInt4). WCF still blocked (D2) |
|
||||
| M4 SF / revisions / redundancy | HARD | L×N | parity completeness | **R4.1 store-and-forward + R4.4 redundancy + R4.3 measured idle-state SF status SHIPPED** (client-side, 2026-06-21); R4.2 revisions + R4.3 active-SF magnitude deferred behind the same D2 storage-engine-pipe wall (R4.3 magnitude also needs an SF-active server capture) |
|
||||
|
||||
@@ -257,12 +257,51 @@ StartDateTime and EndDateTime"`.
|
||||
|
||||
---
|
||||
|
||||
## Event-send wire format ✅ (captured 2026-06-20)
|
||||
|
||||
`AddStreamedValue(HistorianEvent)` leaves the client over **WCF as `AddS2`
|
||||
(`IHistoryServiceContract2.AddStreamValues2(string handle, byte[] pBuf, out errorBuffer)`)** —
|
||||
**not** the storage-engine pipe. (Captured with the NativeTraceHarness `event-send` scenario +
|
||||
instrument-wcf-writemessage; two events diffed to separate constant framing from value fields.)
|
||||
CM_EVENT is a built-in registered tag, so the `129 TagNotFoundInCache` gate that blocks `AddS2`
|
||||
for user tags doesn't apply. Open2 uses event connection-mode **0x501** (vs 0x402 read / 0x401
|
||||
write). The `pBuf` is a storage **sample buffer** wrapping the event VTQ:
|
||||
|
||||
```
|
||||
0x00 UInt16 0x534F "OS"
|
||||
0x02 UInt16 sampleCount = 1
|
||||
0x04 UInt32 packet length = delivered byte[].Length (= valueBlob.Length + 11)
|
||||
0x08 UInt16 valueBlob.Length + 1
|
||||
0x0A valueBlob:
|
||||
+0x00 GUID CM_EVENT tag id (353b8145-5df0-4d46-a253-871aef49b321)
|
||||
+0x10 Int64 EventTime FILETIME UTC (floored to ms — the VTQ timestamp)
|
||||
+0x18 UInt16 OpcQuality = 192
|
||||
+0x1A UInt16 192
|
||||
+0x1C UInt16 0x118D (opaque CCommonArchestraEventValue descriptor — constant)
|
||||
+0x1E GUID event Id
|
||||
+0x2E Int64 ReceivedTime FILETIME UTC (full 100ns; native uses a unique/monotonic time)
|
||||
+0x36 compact-ASCII string Namespace
|
||||
+.... compact-ASCII string EventType
|
||||
+.... UInt16 eventStructVersion = 5
|
||||
+.... UInt16 propertyCount
|
||||
+.... propertyCount × { compact-ASCII name; value: UInt8 typeMarker, UInt8 len, UInt8 status, len×bytes }
|
||||
trailing: 1 pad byte so byte[].Length == packet length (UInt32 @0x04). The native relies on
|
||||
the MDAS encoder adding this byte (non-deterministic value); the SDK emits 0x00.
|
||||
```
|
||||
|
||||
Property values reuse `HistorianEventRowProtocol`'s typed encoding; only `0x43` (UTF-16 string)
|
||||
is captured on the write side so far. Implemented in `HistorianEventWriteProtocol` (golden-tested)
|
||||
and shipped as `HistorianClient.SendEventAsync`. **Server accepts the `AddS2` (success, empty
|
||||
error buffer); on-box persistence is environment-gated (the native client behaves identically).**
|
||||
|
||||
## Open threads
|
||||
|
||||
- 🔶 Event value blob: field set/order known (§2a); **exact native framing** still needs one
|
||||
`CCommonArchestraEventValue::PackToVtq` output capture + golden-byte test (mirror the read-side
|
||||
`HistorianEventRowProtocol` reverse-engineering). Now feasible locally — the live historian is
|
||||
installed, so the same instrument-and-capture approach used for reads applies.
|
||||
- ⚠️ Event **persistence**: accepted `AddS2` events do not land in `v_AlarmEventHistory2` on the
|
||||
local dev box (native client identical) — the event storage/ingestion pipeline isn't active
|
||||
here. Needs a Historian with active event storage to verify send→store→read-back end-to-end.
|
||||
- 🔶 Non-string event property write encodings (bool/int/filetime/guid/double) and revision/
|
||||
update/delete event sends (RevisionVersion≠0, IsUpdate/IsDelete) are **not captured**; the SDK
|
||||
throws `ProtocolEvidenceMissingException` for them. One capture each to extend.
|
||||
- ❓ `EnqueueEventDataPacket.SerializedBytes` packet framing (header + N event VTQs batched).
|
||||
- ✅ Database-mode store: server writer is `aahEventStorage.exe` loading the managed
|
||||
`ArchestrAEvents.EventStorage.Contract` connection assembly; SQL retrieval surface is the
|
||||
|
||||
@@ -1,7 +1,59 @@
|
||||
# R1.8 / R1.9 — Analog-summary & State-summary queries (implementation plan)
|
||||
|
||||
**Status (2026-06-20): scoped + de-risked. Reachable on 2020 WCF. Decode targets located in
|
||||
the native dll. Ready to implement; not yet started in `src/` (no guessed code shipped).**
|
||||
**Status (2026-06-21): RESOLVED by request + response capture. Conclusion: the rich
|
||||
multi-aggregate analog/state summary struct is NOT delivered over the 2020 WCF binary protocol.
|
||||
The per-cycle aggregate values it would expose are ALREADY shipped via `ReadAggregateAsync`
|
||||
(RetrievalMode → QueryType 5–8). No new `src/` code is warranted for R1.8/R1.9 on 2020 WCF.**
|
||||
|
||||
## RESOLVED — what the response capture proved (2026-06-21)
|
||||
|
||||
The request side was recovered first (table further down), then the `GetNextQueryResultBuffer2`
|
||||
**response** was captured (`instrument-wcf-readmessage`, both hooks chained) and decoded against
|
||||
`AnalogSummaryHistory` SQL ground truth for `SysTimeSec` over a 6 h window / 1 h cycle. Findings:
|
||||
|
||||
1. **The response is the ordinary version-9 row buffer** — same layout the existing raw/aggregate
|
||||
parser (`TryParseGetNextQueryResultBufferAggregateRows`) already handles: `uint16 version=9`,
|
||||
`uint32 rowCount`, then per-row `tagKey + nameLen + name + ValueCount + cycleEnd FILETIME +
|
||||
quality + OpcQuality + Value(double) + PercentGood(double) + trailer(cycleStart FILETIME …)`.
|
||||
The captured 7-row buffer decoded with `Value=31.0`, `PercentGood=100.0`, `ValueCount=1`,
|
||||
`OpcQuality=192` — matching the SQL row exactly.
|
||||
|
||||
2. **There is NO rich `CAnalogSummaryValue` struct on the wire.** Each row carries a *single*
|
||||
value, not Min+Max+First+Last+Avg+Integral together. The all-aggregates-in-one-row shape that
|
||||
`CAnalogSummaryValue` / `AnalogSummaryHistory` represents is the **SQL/OLEDB provider's** shape,
|
||||
not the binary `StartQuery2` retrieval's.
|
||||
|
||||
3. **The single value is selected by `RetrievalMode` (QueryType), not by `ValueSelector`.** Proven
|
||||
against the same constant tag where only the *kind* of aggregate distinguishes the result:
|
||||
- `RetrievalMode=Integral` (QueryType 8) → `Value = 111600.0` (= SQL `Integral`) ✓
|
||||
- `RetrievalMode=TimeWeightedAverage` (QueryType 5) → `Value = 31.0` (= SQL `Average`) ✓
|
||||
- `Cyclic` (QueryType 0) **+ `ValueSelector=Integral`** → `Value = 31.0` (selector **ignored**;
|
||||
the request byte `ValueSelector@0x59=0x04` was confirmed sent, yet the cyclic value came back).
|
||||
|
||||
So `ValueSelector` / `AggregationType` / `MaxStates` are **inert on the WCF retrieval path** —
|
||||
they configure the SQL provider's summary tables, not this binary query.
|
||||
|
||||
4. **Resolution unit is correct in the SDK.** The wire `Resolution` is 100 ns ticks (= ms × 10000).
|
||||
`SerializeFullHistoryRequest` writes `TimeSpan.Ticks`, which the golden test
|
||||
`SerializerMatchesInstrumentedNativeTimeWeightedAverageRequest` already verifies byte-for-byte
|
||||
against native (`FromMinutes(1)` → `600000000`). No bug.
|
||||
|
||||
**Therefore:** "analog summary" over 2020 WCF == the existing aggregate read. To get Min, Max,
|
||||
Average and Integral for a cycle you issue the corresponding `RetrievalMode` queries
|
||||
(`MinimumWithTime` / `MaximumWithTime` / `TimeWeightedAverage` / `Integral`), each returning that
|
||||
one aggregate per cycle — all already implemented, mapped (QueryType 5–8) and golden-tested in
|
||||
`ReadAggregateAsync`. **R1.8/R1.9 need no new protocol code on this server.** A genuine
|
||||
all-aggregates-at-once summary would require the gRPC front door or the SQL provider, neither of
|
||||
which is the 2020 WCF binary path.
|
||||
|
||||
Capture/decode tooling is committed and repeatable: `scripts/Capture-SummaryRequest.ps1`
|
||||
(`-WithResponse` chains ReadMessage), `scripts/decode-summary-capture.py` (request diff),
|
||||
`scripts/decode-summary-response.py <config>` (response decode vs SQL ground truth). Raw captures
|
||||
live under `artifacts/reverse-engineering/instrumented-wcf-writemessage-summary/` (gitignored).
|
||||
|
||||
---
|
||||
|
||||
_Original scoping notes below remain for context. They led to the capture; the conclusion above supersedes their "ready to implement" framing._
|
||||
|
||||
Unlike the M1 *read* items gated by the [string-handle wall](../reverse-engineering/wcf-string-handle-wall.md),
|
||||
summary queries ride the **proven `uint`-handle `StartQuery2`** path — the same call the working
|
||||
@@ -34,43 +86,61 @@ Found via `methods … Summary` + `dnlib-method`:
|
||||
| `CTypeMetadata.IsAnalogSummary` / `IsStateSummary` | `0x060001A4/A5` | server-side type gating |
|
||||
| `INSQL_QUERYTYPE` / `HISTORIAN_SUMMARYTYPE` | enums `0200013F` / `02000191` | the `QueryType` / `SummaryType` values to send |
|
||||
|
||||
## Empirical probe results (2026-06-20, live `SysTimeSec` over `StartQuery2`)
|
||||
## Native request capture (2026-06-21) — request shape RECOVERED
|
||||
|
||||
Swept `QueryType`/`SummaryType`/`ColumnSelectorFlags` against the live 2020 server:
|
||||
The earlier blind probing (sweeping `SummaryType`/`ColumnSelectorFlags` over the managed
|
||||
serializer) was the wrong lever: it returned 0-row buffers because the managed `SummaryType`
|
||||
field is **not** how the native client encodes a summary. A real capture settled it.
|
||||
|
||||
- `QueryType=2 (Full)`, `SummaryType ∈ {0,3,6}` → normal 109-byte version-9 data buffer.
|
||||
- `QueryType=2`, `SummaryType ∈ {1,2,4,5}` → **valid version-9 buffer with 0 rows** (`09 00 00 00 00 00`).
|
||||
The server **accepts** these summary types but yields no rows.
|
||||
- The 0-row result is **unchanged** by `ColumnSelectorFlags` (tried default, all-bits
|
||||
`0xFFFF…FFFF`, high-dword, low-48). So column flags are *not* the unlock.
|
||||
- `QueryType ∈ {15,16}` → `GetNext` blocks/times out (no such INSQL_QUERYTYPE ordinal).
|
||||
**Capture pipeline (now repeatable):** `scripts/Capture-SummaryRequest.ps1` IL-rewrites a copy
|
||||
of `aahClientManaged.dll` (`instrument-wcf-writemessage`), stages it alongside the strong-named
|
||||
`ReverseInstrumentation` logger, then drives the `NativeTraceHarness` history scenario through a
|
||||
candidate matrix while logging every outgoing MDAS body. `scripts/decode-summary-capture.py`
|
||||
extracts the `Retr/StartQuery2` `pRequestBuff` from each and diffs the summary candidates against
|
||||
a tag-matched `baseline-full`. The harness now exposes `--value-selector` / `--aggregation-type`
|
||||
/ `--max-states` / `--filter` so the native `HistoryQueryArgs` summary knobs can be driven.
|
||||
|
||||
**Conclusion:** a summary query is *not* `Full + SummaryType + column flags`. The summary
|
||||
configuration lives elsewhere in the request — almost certainly the **`AutoSummaryParameters`
|
||||
trailer** (`SerializeFullHistoryRequest` currently writes it all-zero via
|
||||
`WriteAutoSummaryParameters`) and/or a native summary `QueryType`. Both are **native-side
|
||||
constants** (`HISTORIAN_SUMMARYTYPE` / `INSQL_QUERYTYPE` are `value__`-only in managed metadata;
|
||||
`CColumnNameMap.LoadColumnNameMap` builds column→bit via native string/const data, not IL
|
||||
`ldstr`/`ldc`). So they cannot be recovered from managed metadata, and blind probing of the
|
||||
obvious fields returns empty.
|
||||
**There is no separate "summary" QueryType or `SummaryType` field.** A summary is an ordinary
|
||||
`StartQuery2` request (`QueryType` = the chosen `RetrievalMode`, e.g. `Cyclic`=0) with three
|
||||
things set: the **ValueSelector** byte, the **AggregationType** byte, a non-zero **Resolution**
|
||||
(which fills the previously-zeroed `AutoSummaryParameters` trailer), and — for state summary —
|
||||
the **MaxStates** field. The server then returns analog- vs state-summary rows based on the tag
|
||||
type plus these fields. Offsets below are **into the StartQuery2 `pRequestBuff`** (229-byte
|
||||
`SysTimeSec` baseline; verified byte-for-byte against the native client):
|
||||
|
||||
**Therefore the right next step is a native request capture, not more probing:** drive the native
|
||||
client (NativeTraceHarness / a real summary query) and capture the `pRequestBuff` bytes via the
|
||||
existing `instrument-wcf-writemessage` pipeline — the same method that produced every other proven
|
||||
request shape (reads, events, EnsT2). Diff that buffer against a normal Full request to read off
|
||||
the exact `QueryType` + `SummaryType` + `AutoSummaryParameters` layout, then implement against it.
|
||||
| Offset | Field | Type | Evidence |
|
||||
|---|---|---|---|
|
||||
| `0x01` | QueryType | uint32 LE | Full→`02`, Cyclic→`00` (matches the verified `RetrievalMode`→`QueryType` map) |
|
||||
| `0x1D` | Resolution | float64 LE | `36e9` ticks → `00 00 00 D0 88 C3 20 42` = `0x4220C388D0000000` (1 h). Zero for non-summary reads |
|
||||
| `0x32` | Timezone | len-prefixed UTF-16 | `"UTC"` |
|
||||
| `0x49` | Filter | len-prefixed UTF-16 | `"NoFilter"` default; driven by `--filter` |
|
||||
| `0x59` | **ValueSelector** | byte | baseline `01` (Auto); `--value-selector Minimum`→`06`, `Maximum`→`07`, `Average`→`08` — exact `HistorianValueSelector` values |
|
||||
| `0x5B` | **AggregationType** | byte | baseline `03`; `--aggregation-type Average`→`02` — exact `HistorianAggregationType` values |
|
||||
| `~0x5F` | ColumnSelectorFlags | bytes | `FF 82 07 00 82 81` — matches the `0x0000_8182_0007_82FF` reads already send; **unchanged** by summary |
|
||||
| `0x6B` | Tag name | len-prefixed UTF-16 | `count, "SysTimeSec"` |
|
||||
| after tag | **MaxStates** | uint16 LE | the `01`-default byte after the tag block; `--max-states 10`→`0A` (state summary, R1.9) |
|
||||
| `~0xAA` | **AutoSummaryParameters** | block | zero for plain reads; `80 1E 08 6B 47 01` when Resolution set (identical across analog *and* state) — the resolution-derived cycle block |
|
||||
|
||||
## Open questions (nail these next, in order)
|
||||
State summary (R1.9) is the **same request** with `MaxStates` > 0 (the analog `ValueSelector`/
|
||||
`AggregationType` bytes stay at their `01`/`03` defaults); the analog-vs-state distinction on the
|
||||
wire is which of those fields is non-default, plus the tag type. Note `MaxStates` is a **UInt16**
|
||||
on `HistoryQueryArgs` (passing UInt32 throws) — the harness casts accordingly.
|
||||
|
||||
1. **Request params.** Recover the exact `QueryType` + `SummaryType` (+ whether `ColumnSelectorFlags`
|
||||
must change) for analog vs state summary. Source: decompile `INSQL_QUERYTYPE`/`HISTORIAN_SUMMARYTYPE`
|
||||
enum members and `Select{Analog,State}SummaryColumns` (the `GetColumnFlag` column-name `ldstr`
|
||||
operands → the flag set). The standard QueryType map (Cyclic=0 … EndBound=14) is already verified;
|
||||
summary is expected to be a `SummaryType`≠0 with an existing base `QueryType`, **not** a new mode
|
||||
ordinal — confirm.
|
||||
2. **Row layout.** Capture a real `GetNextQueryResultBuffer2` buffer for an analog summary of a
|
||||
data-bearing tag (`SysTimeSec`) over a multi-hour window with an interval, then decode against the
|
||||
`CAnalogSummaryValue` field set. Likely each row = StartDateTime FILETIME + the 8 typed fields.
|
||||
Raw captures live under `artifacts/reverse-engineering/instrumented-wcf-writemessage-summary/`
|
||||
(gitignored). Re-run with `scripts/Capture-SummaryRequest.ps1` (analog: `SysTimeSec`; state:
|
||||
`-TagName SysPulse`, the local discrete tag).
|
||||
|
||||
## Open questions (only the row layout remains)
|
||||
|
||||
1. ~~**Request params.**~~ **DONE** — see the table above. ValueSelector @ `0x59`,
|
||||
AggregationType @ `0x5B`, Resolution @ `0x1D` (→ AutoSummaryParameters @ `~0xAA`),
|
||||
MaxStates after the tag block. No new QueryType/SummaryType ordinal involved.
|
||||
2. **Row layout (next concrete step).** Capture the `GetNextQueryResultBuffer2` *response* for an
|
||||
analog summary of `SysTimeSec` over a multi-hour window with a 1 h resolution — instrument
|
||||
`ReadMessage` (`instrument-wcf-readmessage`, symmetric to the WriteMessage capture already
|
||||
wired here) and decode against the `CAnalogSummaryValue` field set
|
||||
(StartDateTime + Min/Max/First/Last/ValueCount/TimeGood/Integral/IntegralOfSquares). The
|
||||
request side is no longer a blocker.
|
||||
|
||||
## Implementation steps (per the project's two-tests discipline)
|
||||
|
||||
@@ -84,9 +154,13 @@ the exact `QueryType` + `SummaryType` + `AutoSummaryParameters` layout, then imp
|
||||
`HistorianStateSummary` (per-state contained/partial/entry-count). Reuse `RunQuery` plumbing.
|
||||
5. Golden-byte test on the parser + gated live test on `localhost` (assert non-empty, fields sane).
|
||||
|
||||
## Why stop here this session
|
||||
## State of play
|
||||
|
||||
`UnpackFromValueBuffer` is reader-call-based (no literal offset table), so a correct parser needs a
|
||||
**captured real buffer** to decode against — that's the next concrete action, not a guess. Per
|
||||
project rule ("never guess wire bytes; leave throwing until evidence supports it") no summary code
|
||||
was added to `src/` yet. Everything above is the evidence needed to implement directly.
|
||||
The **request side is fully recovered** from real bytes (table above) — the managed
|
||||
`HistorianDataQueryRequest` builder can now set `ValueSelector`/`AggregationType`/`Resolution`
|
||||
(+ `MaxStates` for state) against ground truth rather than guesses. What remains is the
|
||||
**response row layout**: `CAnalogSummaryValue.UnpackFromValueBuffer` is reader-call-based (no
|
||||
literal offset table), so the parser needs a captured real *response* buffer to decode against
|
||||
(step 2 in Open questions — `instrument-wcf-readmessage`, already wired alongside the WriteMessage
|
||||
capture). Per project rule ("never guess wire bytes; leave throwing until evidence supports it")
|
||||
no summary code is in `src/` yet — that lands once the response fixture exists.
|
||||
|
||||
@@ -1,6 +1,265 @@
|
||||
# Plan: Revision-Write Path (`AddRevisionValuesBegin/Value/End`)
|
||||
|
||||
Status: **ARCHITECTURALLY BLOCKED — verified 2026-05-05.** Same root
|
||||
Status: **WCF: ARCHITECTURALLY BLOCKED (verified 2026-05-05).** **gRPC (2023 R2): the
|
||||
non-streamed-original transaction is REACHABLE — Begin/End round-trip LIVE-VERIFIED 2026-06-21.**
|
||||
Same root cause on WCF as `AddS2`: the `TransactionService` relay needs a pre-existing
|
||||
storage-engine *pipe* session no WCF op can create. The 2023 R2 gRPC front door removes that wall
|
||||
(see the §"2023 R2 gRPC — the wall is gone" section immediately below); the legacy WCF analysis is
|
||||
preserved unchanged after it.
|
||||
|
||||
## 2023 R2 gRPC — the wall is gone (non-streamed original writes), LIVE-VERIFIED 2026-06-21
|
||||
|
||||
The whole D2 WCF blocker was: `ITransactionServiceContract2.AddNonStreamValuesBegin2` returns
|
||||
`04 33 00 00 00` = `UnknownClient (51)` because the server-side Trx relay requires a storage-engine
|
||||
pipe session (`STransactPipeClient2` → `aaStorageEngine.exe`) that no WCF op establishes. On the
|
||||
**2023 R2 gRPC** transport that relay is replaced by a first-class `TransactionService` gRPC
|
||||
service, and the gRPC server is itself the gateway to the storage engine — so the client passes the
|
||||
**HistoryService Open2 storage-session GUID** straight in as `strHandle` and the transaction opens.
|
||||
|
||||
**Live probe (`grpc-revision-probe` CLI command / `HistorianGrpcRevisionProbe`):** against the real
|
||||
2023 R2 server (History iface 12), over a **write-enabled** (`0x401`) gRPC session —
|
||||
|
||||
| step | result |
|
||||
|---|---|
|
||||
| `HistoryService.OpenConnection` (write-enabled `0x401`) | ✅ `OpenSucceeded`, client handle + storage GUID returned |
|
||||
| `TransactionService.GetTransactionInterfaceVersion` | ✅ error 0, **version 2** |
|
||||
| `TransactionService.AddNonStreamValuesBegin(strHandle = storage GUID **UPPERCASE**)` | ✅ **`BeginSucceeded`** — returns a real `strTransactionId` (e.g. `…-FE0A-4822-…`) on the **first** handle format tried |
|
||||
| `TransactionService.AddNonStreamValuesEnd(handle, txId, bCommit=**false**)` | ✅ `EndDiscardSucceeded` — transaction discarded, **no data written** |
|
||||
|
||||
So the answer to the roadmap's open M3-over-gRPC question ("does the 2023 R2 gRPC front door expose
|
||||
a non-streamed write that bypasses the legacy storage-engine pipe?") is **YES** — Begin/End is
|
||||
reachable from the pure-managed SDK with no pipe, no native wrapper. The probe is committed as the
|
||||
`grpc-revision-probe` CLI command + the gated test
|
||||
`HistorianGrpcIntegrationTests.NonStreamedWriteTransaction_OverGrpc_BeginsAndDiscards`; re-run any
|
||||
time to confirm the path is still open.
|
||||
|
||||
### Decompile basis (handle + op group)
|
||||
|
||||
`Archestra.Historian.GrpcClient.GrpcHistoryClient` drives the identical three-phase sequence
|
||||
(`AddNonStreamValuesBegin(strHandle) → strTransactionId`; `AddNonStreamValues(strHandle,
|
||||
strTransactionId, btInput)`; `AddNonStreamValuesEnd(strHandle, strTransactionId, bCommit)`), passing
|
||||
the Open2 session GUID as `strHandle`. `btInput` is the **same opaque native VTQ buffer** the 2020
|
||||
path uses. Proto: `src/AVEVA.Historian.Client/Grpc/Protos/TransactionService.proto`.
|
||||
|
||||
### What is proven vs. what remains (do NOT ship yet)
|
||||
|
||||
- ✅ **Proven:** the transaction lifecycle (Begin → End/rollback) is reachable over gRPC. The D2
|
||||
architectural wall is specific to the WCF transport.
|
||||
- ⛔ **Not yet captured:** the `AddNonStreamValues` **`btInput` VTQ buffer byte layout**. Per project
|
||||
discipline ("never guess wire bytes; capture first") no value-commit is implemented. The next step
|
||||
to actually *ship* M3 (`AddHistoricalValuesAsync`) is to capture the native gRPC `AddNonStreamValues`
|
||||
`btInput` (or decode the `GrpcHistoryClient` serializer), build a golden-tested serializer, then do a
|
||||
real `bCommit=true` write + SQL read-back against a sandbox tag created by `EnsureTagAsync`.
|
||||
- 🔒 **Scope:** this is **non-streamed ORIGINAL backfill** (`HistorianDataCategory.NonStreamedOriginal`
|
||||
→ `TransactionService.AddNonStreamValues*`). **Revision EDITS** (`AddRevisionValue(s)` /
|
||||
`RevisionInsert*`, the R4.2 path) are NOT on the gRPC contract even in 2023 R2 — the capability
|
||||
matrix confirms they still ride the storage-engine pipe. The gRPC unlock here is original backfill,
|
||||
not after-the-fact edits.
|
||||
|
||||
### R3.1 decode probe (2026-06-21): `AddNonStreamValues` reaches the server-side storage-engine console pipe
|
||||
|
||||
The `btInput` VTQ buffer is assembled in native C++ (`SendNonStreamedValues(batchID)` → a vtable
|
||||
call after values are pooled via native `AddNonStreamedValueAsync(&HISTORIAN_VALUE2)`) and is **not
|
||||
visible in any decompile** — only the 44-byte packed `HISTORIAN_VALUE2` struct is (TagKey@0,
|
||||
FILETIME@4, OpcQuality@20, Type@24=7 numeric, value@33, bVersioned@41, VersionStatus@42). So the
|
||||
framing was probed empirically against the live server with `grpc-nonstream-decode` (every
|
||||
transaction `bCommit=false` → rolled back, nothing written; tag key from `SysTimeSec`).
|
||||
|
||||
**Result — the failure is NOT a buffer-format problem:** six different framings (44–54 bytes:
|
||||
count-prefixed packed struct, struct-only, version+count, OS-wrapped) all returned the **identical**
|
||||
`AddNonStreamValues` error, while an empty buffer returned a *different* error (`04 01 00 00 00`,
|
||||
InvalidParameter). The shared error is a nested `SError` whose detail strings are decisive:
|
||||
|
||||
```
|
||||
aahClientAccessPoint::CHistStorageConnection::StoreNonStreamValues::StoreNonStreamValues
|
||||
\\.\pipe\aahStorageEngine\console,sid(<server storage-engine session GUID>)
|
||||
```
|
||||
|
||||
So non-empty buffers get **past parameter validation into `StoreNonStreamValues`**, which routes to
|
||||
the **`aahStorageEngine` console named pipe** server-side (the same storage engine as D2 — but the
|
||||
gRPC *server* now holds the pipe, not the client). Because the error is identical across every
|
||||
framing, the blocker is **not** the `btInput` layout — it is a **missing storage-engine console
|
||||
session / tag-registration precondition** for the connection.
|
||||
|
||||
**Required call sequence (mapped from the 2023 R2 decompile, corroborates the error above):** the
|
||||
missing precondition is **`StorageService.OpenStorageConnection`** — it creates exactly the
|
||||
`\\.\pipe\aahStorageEngine\console,sid(...)` console session named in the failure. The native
|
||||
non-streamed write path is:
|
||||
|
||||
```
|
||||
HistoryService.OpenConnection (✅ have it — the Open2 handshake)
|
||||
→ StorageService.OpenStorageConnection (⛔ MISSING — opens the console sid session; SEPARATE
|
||||
storage session, returns its own uint handle + new GUID)
|
||||
→ StorageService.RegisterTags (register the tag→storage mapping for the session)
|
||||
→ TransactionService.AddNonStreamValuesBegin (✅ works)
|
||||
→ TransactionService.AddNonStreamValues(btInput) (⛔ currently fails here — no console session yet)
|
||||
→ TransactionService.AddNonStreamValuesEnd(bCommit=true)
|
||||
→ StorageService.CloseStorageConnection / HistoryService.CloseConnection
|
||||
```
|
||||
|
||||
`OpenStorageConnection` (gRPC `StorageService`) takes 12 args — HostName, EnginePath
|
||||
(`\\.\pipe\aahStorageEngine\console`), FreeDiskSpace, ProcessName, ProcessId, UserName, Password(+len),
|
||||
ClientType, ClientVersion, ConnectionMode, ConnectionTimeout, StorageSessionId(in/out) — and returns a
|
||||
**new** storage `Handle` (uint) + a **new** StorageSessionId GUID (distinct from the Open2 GUID).
|
||||
|
||||
**Two hard parts remain, each a separate live-production decode loop (no static shortcut):**
|
||||
1. **Reproduce the `OpenStorageConnection` handshake** — several of the 12 args are only inferable from
|
||||
the decompile (ProcessId, ClientType/Version, ConnectionMode, the password-bytes framing), so the
|
||||
exact values must be confirmed against the live server.
|
||||
2. **Decode the `AddNonStreamValues` `btInput`** — built in C++ (`SendNonStreamedValues` vtable call),
|
||||
**absent from every decompile**; only the 44-byte packed `HISTORIAN_VALUE2` struct is known. Must be
|
||||
decoded empirically once the console session exists (the batch-1 identical-error result could not
|
||||
distinguish framings precisely *because* there was no session — with a session, framings should
|
||||
diverge and the correct one becomes findable).
|
||||
|
||||
Raw decode artifact: `artifacts/reverse-engineering/grpc-nonstream-decode/batch1-decode.txt`
|
||||
(gitignored). Probe command: `grpc-nonstream-decode`; driver:
|
||||
`HistorianGrpcRevisionProbe.ProbeNonStreamedBuffersAsync` (candidate guess-bytes live in the RE tool,
|
||||
not `src/`).
|
||||
|
||||
### R3.1 follow-up (2026-06-21): `OpenStorageConnection` is the WRONG precondition — error 85 = "session not registered"
|
||||
|
||||
The mapped sequence above named `StorageService.OpenStorageConnection` as the missing console-session
|
||||
step. **A live probe (`grpc-open-storage-connection` CLI / `HistorianGrpcStorageConnectionProbe`)
|
||||
disproved that.** Against the real 2023 R2 server, over a write-enabled (`0x401`) session, every
|
||||
`OpenStorageConnection` attempt — sweeping `ConnectionMode` (0x401/0x402/0x1), `StorageSessionId`-in
|
||||
(Open2-GUID-upper / empty), and `FreeDiskSpace` — returned the **identical** error
|
||||
`84 55 00 00 00 …09 15 00 "OpenStorageConnection"` = **type 4 (CustomError, 0x80 detail flag), code
|
||||
`0x55` = 85**, independent of all swept values. So it is a *structural* refusal, not a bad field.
|
||||
|
||||
**Decoding the refusal (two corroborating facts):**
|
||||
1. **Error 85 is the generic "session not registered for this op" code.** The event read path hits the
|
||||
*same* `type=4 code=85` from `GetNextEventQueryResultBuffer` when the session hasn't registered its
|
||||
tag first (see `HistorianWcfEventOrchestrator` xmldoc) — the fix there is front-door `RegisterTags2`
|
||||
(RTag2), NOT a storage connection.
|
||||
2. **`OpenStorageConnection` is not a front-door client op.** In the 2023 R2 decompile it lives on a
|
||||
**separate `GrpcStorageClient`** (`Archestra.Historian.GrpcClient`, `GrpcClientBase` with its own
|
||||
`Initialize(target, port, …)` channel) and the managed `HistorianAccess` non-streamed write goes
|
||||
through the **native C++ `<Module>.HistorianClient.AddNonStreamedValueAsync`**, never this gRPC op.
|
||||
The `StorageService` proto is almost entirely snapshots / blocks / SF params / `SendSnapshot` —
|
||||
it is the **storage engine's store-and-forward / snapshot interface** (`HistorianAccess`
|
||||
documents `OpenStorageConnection`/`CloseStorageConnection` as the SF-snapshot *flush*), reached on
|
||||
a distinct channel under a service identity. A normal Historian client never opens it on 32565.
|
||||
|
||||
**Corrected required sequence — the precondition is front-door tag registration, not a storage conn:**
|
||||
|
||||
```
|
||||
HistoryService.OpenConnection (write-enabled 0x401) ✅ have it
|
||||
→ HistoryService.RegisterTags(strHandle, btTagInfos = TARGET tag) ⛔ the real missing step
|
||||
(front door, string handle — the RTag2 family; same op that subscribes the event session)
|
||||
→ TransactionService.AddNonStreamValuesBegin ✅ works
|
||||
→ TransactionService.AddNonStreamValues(btInput) ⛔ R3.1 batch failed here precisely
|
||||
BECAUSE no tag was registered for the session (StoreNonStreamValues had no tag→storage route)
|
||||
→ TransactionService.AddNonStreamValuesEnd(bCommit)
|
||||
```
|
||||
|
||||
This matches the original 2020-WCF D2 hypothesis ("what populates the session's tag working set is
|
||||
likely a `RegisterTags2` call") — the gRPC front door does expose that op (`HistoryService.RegisterTags`,
|
||||
in our `HistoryService.proto`).
|
||||
|
||||
**Remaining blockers (both need a native gRPC capture — no static shortcut, do NOT guess bytes):**
|
||||
1. **`HistoryService.RegisterTags` `btTagInfos` for a *regular analog* tag.** The only known RTag2
|
||||
buffer is CM_EVENT's (a built-in tag identified by a well-known 16-byte *tag*-GUID,
|
||||
`0x6750` v2 + count + GUID). Regular tags expose only a uint `tagKey` + a *type*-id GUID via
|
||||
`GetTagInfo` (see `ParseTagInfoRecord`) — **no per-tag GUID**, so the regular-tag registration
|
||||
framing (tagKey-based vs tag-GUID-based) is uncaptured.
|
||||
2. **`AddNonStreamValues` `btInput`** — still C++-built and absent from every decompile (unchanged).
|
||||
|
||||
Both require capturing the **native 2023 R2 gRPC client** performing a non-streamed write (it would
|
||||
emit the exact `RegisterTags` `btTagInfos` + `btInput`), or decoding the C++ serializer. Probe:
|
||||
`grpc-open-storage-connection` (committed, regression-safe — it opens nothing persistent and
|
||||
CloseStorageConnections on success). **Status: M3 transaction lifecycle proven; the insert precondition
|
||||
is now correctly identified as front-door `RegisterTags` (NOT `OpenStorageConnection`); shipping
|
||||
`AddHistoricalValuesAsync` is blocked on capturing the regular-tag `RegisterTags` `btTagInfos` +
|
||||
the `AddNonStreamValues` `btInput`.**
|
||||
|
||||
### R3.1 capture plan (2026-06-21): drive the native 2023 R2 gRPC client + IL-rewrite the byte[] payloads
|
||||
|
||||
Feasibility verified end-to-end against `histsdk-2023r2-analysis/bin`:
|
||||
|
||||
- **Self-contained, loadable.** 2023 R2 `aahClientManaged.dll` is a 20 MB **mixed-mode C++/CLI**
|
||||
assembly whose native imports are only Windows + VC++ runtime (`MSVCP140`/`VCRUNTIME140_1`) — **no
|
||||
external AVEVA native dependency / no Historian install required** to load it in a `net481` x64
|
||||
process. The native C++ `HistorianClient` (the `<Module>.HistorianClient.*` globals,
|
||||
e.g. `AddNonStreamedValueAsync(client, &HISTORIAN_VALUE2, &SError)`) is compiled *into* it and is
|
||||
what builds `btInput`; it then hands the `byte[]` to the **managed** gRPC client.
|
||||
- **gRPC routes through managed code → IL-rewrite-able.** `Archestra.Historian.GrpcClient.dll`
|
||||
(`Grpc.Net`-based) is pure managed; `GrpcHistoryClient` holds both `m_historyClient` and
|
||||
`m_transactionClient`. Capture targets:
|
||||
- `GrpcHistoryClient.RegisterTags(string handle, byte[] tagInfos, …)` → dump `tagInfos`
|
||||
- `GrpcHistoryClient.AddNonStreamValues(string handle, string transactionId, byte[] inBuff, …)` → dump `inBuff`
|
||||
Use the existing dnlib IL-rewrite tooling (`tools/AVEVA.Historian.ReverseInstrumentation` +
|
||||
`instrument-wcf-writemessage` pattern), writing rewrites to a copy under
|
||||
`docs/reverse-engineering/dnlib-write-copy/` — never touch `histsdk-2023r2-analysis/bin` originals.
|
||||
- **gRPC runtime deps are available.** `Archestra.Historian.GrpcClient.dll` references `Grpc.Net.Client`,
|
||||
`Grpc.Core.Api`, `Grpc.Net.Client.Web`, `Google.Protobuf`, etc. — the full set is present in
|
||||
`histsdk-2023r2-analysis/msi-extract/ArchestrA/Toolkits/Bin/x64/` (alongside the 5 core DLLs in
|
||||
`…/bin/`). Assemble all of them into the harness runtime dir so `Assembly.LoadFrom` + the sibling
|
||||
resolver can satisfy the gRPC stack.
|
||||
- **Driving the write (reflection, like `NativeTraceHarness`).** `ArchestrA.HistorianAccess.OpenConnection(HistorianConnectionArgs, out err)`
|
||||
with `HistorianConnectionArgs { ServerName, TcpPort=32565, ConnectionMode=HistorianConnectionMode.Historian
|
||||
(the 2023 R2 gRPC mode; `ClassicHistorian`=legacy), ConnectionType=Process, ReadOnly=false,
|
||||
IntegratedSecurity/UserName/Password, AllowUnTrustedConnection=true, SecurityInfo=cert }`, then
|
||||
`AddNonStreamedValue(ConnectionIndex.Process, HistorianDataValue, bVersioned:false, out err)`.
|
||||
- **Cache-gate risk (the D2 blocker).** The C++ `AddNonStreamedValueAsync` has a per-connection
|
||||
`TagNotFoundInCache (129)` gate that, in the 2020 D2 probe, rejected the value **before any bytes
|
||||
left the client**. Mitigation to try: **read the target tag first** (populate the per-connection
|
||||
cache) before `AddNonStreamedValue`. `RegisterTags` is emitted during registration *before* this
|
||||
gate, so its `tagInfos` is capturable **even if** the gate still blocks `btInput`.
|
||||
|
||||
Build order (each live step = prod write, per-action auth): (1) `net481` x64 harness loads the 2023 R2
|
||||
DLL + opens a **read-only** gRPC connection + reads the tag (proves load+connect, no write); (2)
|
||||
IL-rewrite `Archestra.Historian.GrpcClient.dll`; (3) write-enabled run → capture `RegisterTags`
|
||||
`tagInfos` (+ `btInput` if the gate passes); (4) build golden serializer(s) in `src/`; (5) real
|
||||
`bCommit=true` write + SQL read-back on a sandbox tag → ship `AddHistoricalValuesAsync`.
|
||||
|
||||
### R3.1 CAPTURED + VALIDATED (2026-06-21): the write rides `HistoryService.AddStreamValues` ("ON" buffer)
|
||||
|
||||
The capture ran end-to-end against the live server (`AVEVA.Historian.Grpc2023CaptureHarness`,
|
||||
`capture-write` scenario, sandbox tag created by the harness, IL-rewritten `GrpcClient` dumping every
|
||||
`byte[]`). The committed write **persisted and read back over gRPC** (SDK `ReadRawAsync` returned the
|
||||
sample) — fully validated.
|
||||
|
||||
**The roadmap's assumption was wrong.** The native non-streamed (historical backfill) write does **not**
|
||||
use `AddNonStreamValues` / the TransactionService at all. The native `HistorianAccess.AddNonStreamedValue
|
||||
→ SendValues` routes over gRPC as **`HistoryService.AddStreamValues`** carrying an **"ON"
|
||||
storage-sample buffer** (structurally the AddS2 **"OS"** family — same serializer pattern the SDK already
|
||||
has in `HistorianEventWriteProtocol`), preceded by **`EnsureTags`** to register the tag:
|
||||
|
||||
```
|
||||
EnsureTags.tagInfos (144B) = the analog CTagMetadata the SDK's EnsureTagAsync already builds
|
||||
(0x4E marker … fe 00 trailer)
|
||||
AddStreamValues.values (56B) = "ON" (0x4E4F) + u16 sampleCount(1) + u32 totalLen(56)
|
||||
+ u16 payloadLen(46) + 16B tag GUID + FILETIME(sample)
|
||||
+ u16 OpcQuality(192=Good) + u32 type/descriptor
|
||||
+ FILETIME(received/version) + 8B double value
|
||||
```
|
||||
|
||||
The full priming/write sequence that works from the native client (write-enabled session): `OpenConnection`
|
||||
→ `UpdateClientStatus` ×N → `EnsureTags` → `GetTagInfosFromName` (resolve identity) → `AddStreamValues`
|
||||
("ON" buffer). Notes: (a) the **D2 cache gate (err 129) does NOT block** the primed 2023 R2 client —
|
||||
`AddNonStreamedValue` returned success once the session was primed (via `AddTag`/`GetTagInfoByName`) and
|
||||
the server had assigned the tag key; (b) the value is keyed by a **16-byte tag GUID**, not the uint
|
||||
`tagKey` (so the SDK serializer needs the tag's GUID, available from EnsureTags/GetTagInfo, not just
|
||||
`HistorianTagMetadata.Key`); (c) batch lifecycle is `NonStreamedValuesBegin → AddNonStreamedValue →
|
||||
SendValues → AddNonStreamedValuesEnd` (End-before-Send returns err 160 InvalidBatchId).
|
||||
|
||||
**SHIPPED 2026-06-21 — `AddHistoricalValuesAsync`.** `HistorianClient.AddHistoricalValuesAsync(tag, values)`
|
||||
over `RemoteGrpc`: `HistorianGrpcHistoricalWriteOrchestrator` opens a write-enabled session →
|
||||
`GetTagInfosFromName` (resolves the per-tag GUID = the tag-info record's `TypeId`) →
|
||||
`HistoryService.AddStreamValues` ("ON" buffer from `HistorianHistoricalWriteProtocol`, golden-tested) per
|
||||
sample. The pure-managed SDK wrote a value and read it back live (gated test
|
||||
`AddHistoricalValuesAsync_OverGrpc_WritesAndReadsBack`). **All five analog types captured + validated**
|
||||
(Float/Double/Int2/Int4/UInt4): the 4-byte value descriptor `C0 10 01 00` is **constant across types**;
|
||||
the value is `u32(0) + native-width value` (float32 / double64 / int16 / int32 / uint32) selected by the
|
||||
tag's declared type (the orchestrator maps it from the tag-info `NativeDataTypeDescriptor`). gRPC-only.
|
||||
Capture artifacts (gitignored): `artifacts/reverse-engineering/grpc-nonstream-capture/cap-*.ndjson`.
|
||||
|
||||
---
|
||||
|
||||
## Legacy WCF analysis (preserved — still accurate for the 2020 WCF transport)
|
||||
|
||||
Status (WCF only): **ARCHITECTURALLY BLOCKED — verified 2026-05-05.** Same root
|
||||
cause as `AddS2`: client-side cache rejects values for tags that
|
||||
weren't registered through a configured IO server / Application Server
|
||||
pipeline. Documented below; implementation deferred until / unless that
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
# Store/Forward Cache Reverse-Engineering Plan
|
||||
|
||||
Last updated: 2026-05-04
|
||||
Last updated: 2026-06-21
|
||||
|
||||
> **2026-06-21 R4.3 re-scope — read this first.** The original plan below
|
||||
> (2026-05-04) was written against the 2020 Net.TCP/WCF transport, before the
|
||||
> 2023 R2 gRPC transport existed. Its single biggest open risk — *"is SF state
|
||||
> readable via a one-shot pull, or only via a duplex push contract we'd have to
|
||||
> add?"* (Q1/Q2 + §3 Step 3 + Risk 4) — is now **answered: pull, no duplex**.
|
||||
> The recovered gRPC `StorageService` contract exposes SF state as plain
|
||||
> request/response RPCs. The current R4.3 scope and recommended path are in
|
||||
> §9 ("2026-06-21 gRPC re-scope"); the 2020-WCF body below is retained as
|
||||
> background, not the recommended route.
|
||||
|
||||
Original last-updated: 2026-05-04
|
||||
|
||||
This document plans the reverse-engineering effort needed to replace the
|
||||
synthesized `GetStoreForwardStatusAsync` in
|
||||
@@ -499,3 +511,202 @@ Explicitly not part of this plan:
|
||||
- Anything in the
|
||||
`aahClientCommon.CSFConnection.StartStoreforward` /
|
||||
`SetStorageStopped` / `SetTagSynchronized` write surface.
|
||||
|
||||
## 9. 2026-06-21 gRPC re-scope (current R4.3 plan)
|
||||
|
||||
This supersedes the recommended *route* in §2/§3/§4. The deliverable
|
||||
(§1) and success criteria (§6) are unchanged. What changed is the
|
||||
transport and the resolved architecture risk.
|
||||
|
||||
### 9.1 What the recovered gRPC contract already gives us
|
||||
|
||||
The 2023 R2 contract under `src/AVEVA.Historian.Client/Grpc/Protos/`
|
||||
exposes SF state through **first-class pull RPCs** on `StorageService`
|
||||
(`StorageService.proto`) — no duplex/callback contract, no native
|
||||
`HISTORIAN_STORAGE_STATUS` C-struct decode:
|
||||
|
||||
- `GetSFParameter(uint32 Handle, string ParameterName)
|
||||
→ (Status status, string ParamaterValue)` — the direct analogue of the
|
||||
already-shipped `GetSystemParameter`/`GetRuntimeParameter` string-keyed
|
||||
pulls. This is the primary SF-state lever: a name→value read.
|
||||
- `GetRemainingSnapshotsSize(uint32 Handle)
|
||||
→ (Status status, uint64 SnapshotSize)` — the pending-buffer magnitude
|
||||
in one call. Non-zero ⇒ data is queued (`Pending`/`DataStored=true`);
|
||||
zero ⇒ drained. The cleanest single signal for the idle-vs-active split.
|
||||
- `GetInfo(string Request) → (Status status, bytes info)` — generic
|
||||
server info blob; a fallback if a named SF key lives here instead of in
|
||||
`GetSFParameter`.
|
||||
- `OpenStorageConnectionResponse.ServerStatus` (field 5) and the
|
||||
`GetSnapshots`/`StartQuerySnapshot` family — secondary signals.
|
||||
|
||||
`SetSFParameter` exists too but is **out of scope** (read-only mission, §8).
|
||||
|
||||
The `TransactionService.ForwardSnapshot{,Begin,End}` RPCs are the SF
|
||||
cache *replay/transfer* path (write-side), **not** a status read — also
|
||||
out of scope here; they belong to the deferred bit-faithful SF cache work,
|
||||
not to `GetStoreForwardStatusAsync`.
|
||||
|
||||
### 9.2 Plumbing that already exists (reuse, don't rebuild)
|
||||
|
||||
- `HistorianGrpcHandshake.OpenSession` — authenticated gRPC session
|
||||
(`ValidateClientCredential` NTLM loop + Open2) yielding `ClientHandle`
|
||||
(uint) + storage-session GUID. Live-verified against the 2023 R2 box.
|
||||
- `HistorianGrpcStorageConnectionProbe` — already constructs a
|
||||
`StorageService.StorageServiceClient`, primes `GetInterfaceVersion`, and
|
||||
calls `OpenStorageConnection`/`CloseStorageConnection`. The SF-status
|
||||
probe is a near-clone that swaps the `OpenStorageConnection` body for
|
||||
`GetSFParameter`/`GetRemainingSnapshotsSize` calls.
|
||||
- `HistorianGrpcChannelFactory` / `HistorianGrpcConnection` — channel,
|
||||
metadata, deadlines.
|
||||
|
||||
### 9.3 The one open risk that survives: which `Handle`?
|
||||
|
||||
`GetSFParameter`/`GetRemainingSnapshotsSize` both take `uint32 Handle`.
|
||||
Unknown: do they accept the **session `ClientHandle`** (from
|
||||
`OpenSession`, which is cheap and unblocked), or do they require the
|
||||
**storage console `Handle`** returned by `OpenStorageConnection` — which
|
||||
is the D2 wall (`OpenStorageConnection` routes to the
|
||||
`\\.\pipe\aahStorageEngine\console` session and is the same storage-engine
|
||||
pipe that blocks revision writes)? See
|
||||
[[project_roadmap_exhausted_2020wcf]] and `HistorianGrpcStorageConnectionProbe`
|
||||
header.
|
||||
|
||||
- **Best case:** these read-only status RPCs accept the session
|
||||
`ClientHandle` (status reads shouldn't need a console writer session).
|
||||
Then R4.3-over-gRPC is unblocked end-to-end and is a small, shippable
|
||||
feature.
|
||||
- **Worst case:** they require the `OpenStorageConnection` `Handle` ⇒
|
||||
R4.3 inherits the D2 storage-engine-pipe wall and stays blocked on the
|
||||
same root cause as R4.2. Either way the probe answers it in one run.
|
||||
|
||||
### 9.4 Discovery steps (execution order)
|
||||
|
||||
1. **Add `grpc-sf-status-probe` to `tools/AVEVA.Historian.ReverseEngineering`**
|
||||
(mirror `HistorianGrpcStorageConnectionProbe`). Against the live 2023 R2
|
||||
server it:
|
||||
- opens an authenticated session, gets `ClientHandle`;
|
||||
- calls `GetRemainingSnapshotsSize(ClientHandle)` and reports
|
||||
`status.bSuccess` + `SnapshotSize` + any error buffer;
|
||||
- sweeps `GetSFParameter(ClientHandle, name)` over a candidate
|
||||
name list (`Status`, `Storing`, `Pending`, `DataStored`,
|
||||
`SF.Status`, `StoreForwardStatus`, `Forward`, `CacheSize`,
|
||||
`ErrorOccurred`, plus any names surfaced by Workstream A's IL of
|
||||
`ConvertUnmanagedSFStorageStatusToManagedStorageStatus`);
|
||||
- records which names the server accepts and the returned values.
|
||||
- If every call fails with an auth/handle-shaped error, retry once
|
||||
with the `OpenStorageConnection` `Handle` to disambiguate §9.3.
|
||||
2. **Idle baseline first** — run against the server with SF *not* active.
|
||||
Establishes the "no SF / drained" response shape (expected:
|
||||
`SnapshotSize=0`, parameter reads succeed-with-defaults or
|
||||
return a "not configured" sentinel). This alone may be enough to ship
|
||||
an honest idle-state implementation that is strictly better than
|
||||
today's hardcoded all-false synthesis (it would be *measured* false).
|
||||
3. **Active-SF capture** — only if step 2 proves the read works and we
|
||||
need the active-state fixtures. Force SF on the sacrificial Historian
|
||||
VM (stop Runtime DB writer; let the queue spill to SF), re-run the
|
||||
probe, capture the non-zero/`Storing=true` response. This is the one
|
||||
invasive step and the gate on full success criteria §6.1–6.3.
|
||||
4. **Map + implement** — add `GrpcGetStoreForwardStatus` to the gRPC
|
||||
read orchestrator, map the probed fields onto
|
||||
`HistorianStoreForwardStatus`, route `GetStoreForwardStatusAsync`
|
||||
to it when `Transport == RemoteGrpc` (keep the synthesized fallback
|
||||
for non-gRPC transports and for the "no SF configured" sentinel).
|
||||
Add golden-byte fixtures (idle + active) and
|
||||
`WcfStoreForwardStatusProtocolTests`-style parse tests. Gate the live
|
||||
integration test on `HISTORIAN_GRPC_HOST`.
|
||||
|
||||
### 9.5 Effort / feasibility summary
|
||||
|
||||
- **Risk collapsed:** pull-vs-push (the old plan's worst risk) is settled
|
||||
— it's a pull. No duplex WCF/gRPC callback contract.
|
||||
- **No native struct decode:** `GetSFParameter` returns a *string*; we
|
||||
skip the `HISTORIAN_STORAGE_STATUS` C-layout RE entirely (Workstream
|
||||
A.2 / D.1 become "nice-to-have for field names", not blocking).
|
||||
- **Reuses shipped plumbing:** session open + `StorageServiceClient` +
|
||||
channel already exist and are live-verified.
|
||||
- **Remaining unknowns are empirical, one probe-run each:** (a) the
|
||||
accepted parameter-name vocabulary, (b) which `Handle` the status RPCs
|
||||
want (§9.3 — the only thing that could re-block it), (c) the
|
||||
active-SF response shape (needs the invasive force-SF step).
|
||||
- **Net:** Step 1–2 are low-risk and could land a *measured* idle-state
|
||||
`GetStoreForwardStatusAsync` over gRPC quickly. Steps 3–4 (full
|
||||
success criteria) still need the sacrificial-VM force-SF capture and
|
||||
are gated on §9.3 not landing on the D2 wall.
|
||||
|
||||
### 9.6 Out of scope (unchanged from §8, restated for gRPC)
|
||||
|
||||
`SetSFParameter`, `ForwardSnapshot*` (SF replay/transfer), the on-disk
|
||||
cache file format, and redundant-partner SF aggregation all remain out of
|
||||
scope. R4.3 is read-only status, gRPC-first.
|
||||
|
||||
### 9.7 Idle-baseline run — RESULTS (2026-06-21)
|
||||
|
||||
Built `HistorianGrpcStoreForwardStatusProbe` + the `grpc-sf-status-probe`
|
||||
CLI command and ran it against the **live 2023 R2 server** with the
|
||||
historian in its **idle / not-actively-storing** state (storage interface
|
||||
v4, authenticated session opened OK). Tested both read-only (`0x402`) and
|
||||
write-enabled (`0x401`) sessions. Findings, with the §9.3 handle question
|
||||
**resolved**:
|
||||
|
||||
1. **Direct `StorageService` SF pull RPCs are D2-gated — confirmed the
|
||||
§9.3 worst-case branch.**
|
||||
- `GetRemainingSnapshotsSize(session.ClientHandle)` →
|
||||
`bSuccess=false`, error buffer `04 84 00 00 00` (= status `0x84` /
|
||||
**132 `OperationNotEnabled`**). **Identical under `0x401` and
|
||||
`0x402`** — so it is NOT the read/write connection-mode gate; the
|
||||
History-session `ClientHandle` is simply not a valid handle for this
|
||||
op's handle-space.
|
||||
- `GetSFParameter(session.ClientHandle, <name>)` → server-side
|
||||
`RpcException(Unknown, "Exception was thrown by handler")` for **all
|
||||
16** candidate names, both session modes.
|
||||
- These two ops need the **`OpenStorageConnection` console handle**,
|
||||
and `OpenStorageConnection` itself fails with the storage-engine
|
||||
console error (`84 55 00 00 00 01 02 00 09 15 00`
|
||||
+ ASCII `"OpenStorageConnection"`) — the **D2 storage-engine-pipe
|
||||
wall**, the same root cause that blocks R4.2 revision writes. We
|
||||
cannot obtain the console handle, so these two SF RPCs are
|
||||
unreachable from a pure managed client. See
|
||||
[[project_roadmap_exhausted_2020wcf]].
|
||||
|
||||
2. **One reachable session-handle lever found:**
|
||||
`StatusService.GetHistorianConsoleStatus(strHandle)` **SUCCEEDS** with
|
||||
the session string handle (uppercase Open2 GUID) — no console handle
|
||||
needed — and returns `uiConsoleStatus = 3` at idle. This is the only
|
||||
SF-adjacent signal reachable from the managed client. **Its enum
|
||||
semantics are unknown** (3 = presumably "running/normal"); whether it
|
||||
shifts when SF is actively storing is the open question.
|
||||
|
||||
3. `StatusService.GetHistorianInfo(strHandle, btRequest)` → `bSuccess=
|
||||
false` for every `btRequest` candidate (empty / `u32(0)` / ascii+utf16
|
||||
`"StoreForward"`); its request framing is not yet known. Lower-yield
|
||||
than `GetHistorianConsoleStatus`; revisit only if needed.
|
||||
|
||||
**Net idle-baseline conclusion.** R4.3's clean direct route
|
||||
(`GetSFParameter` / `GetRemainingSnapshotsSize`) is **blocked behind the
|
||||
D2 storage-engine console pipe**, exactly like R4.2 — a pure managed
|
||||
client cannot open the console session those ops require. The *only*
|
||||
reachable SF-adjacent signal is `GetHistorianConsoleStatus` → a status
|
||||
uint. Two paths forward:
|
||||
|
||||
- **(a) Ship a measured idle-state only. — SHIPPED + LIVE-VERIFIED 2026-06-21.**
|
||||
`HistorianGrpcStatusClient.GetStoreForwardStatusAsync` opens a session,
|
||||
calls `GetHistorianConsoleStatus`, and returns
|
||||
`HistorianStoreForwardStatus` all-false but *measured*: it actually
|
||||
contacts the server and reports `ErrorOccurred=true` (with the underlying
|
||||
error) when the server is unreachable / the console-status call fails —
|
||||
strictly better than the blind hardcoded synthesis, which never contacts
|
||||
the server. Routed via `Historian2020ProtocolDialect.GetStoreForwardStatusAsync`
|
||||
when `Transport == RemoteGrpc` (non-gRPC keeps the synthesized fallback).
|
||||
Gated live test `HistorianGrpcIntegrationTests.GetStoreForwardStatusAsync_OverGrpc_ReturnsMeasuredIdleState`
|
||||
passes against the real 2023 R2 server. `Storing`/`Pending`/`DataStored`
|
||||
magnitude is intentionally NOT surfaced — it lives behind the D2 wall (see
|
||||
path (b)).
|
||||
- **(b) Full success criteria (§6) stay blocked** on the D2 console-pipe
|
||||
wall. Decoding the active-SF `uiConsoleStatus` value and any
|
||||
`GetSystemParameter` SF keys still needs the invasive force-SF capture
|
||||
on a sacrificial Historian — and even then `Storing`/`DataStored`
|
||||
magnitude is only available via the D2-gated `GetRemainingSnapshotsSize`.
|
||||
|
||||
Probe code: `src/AVEVA.Historian.Client/Grpc/HistorianGrpcStoreForwardStatusProbe.cs`,
|
||||
CLI `grpc-sf-status-probe <host> [port] [--tls] [--dnsid <n>] [--write-session]`.
|
||||
Writes nothing; releases any console session immediately.
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# Event-session reuse spike — live results
|
||||
|
||||
> **Question:** does the 2023 R2 historian honor REUSING one authenticated **v8 Event**
|
||||
> session (ECDH `ExchangeKey` → RC4 token → `ConnectionType=Event`, then `RegisterCmEventTag`)
|
||||
> across multiple `SendEvent` ops, instead of the per-op open+register the SDK does today?
|
||||
> This is the precondition for amortizing the EVENT path (HistorianGateway `pending.md` A1
|
||||
> broadening, Stage B0 / B1).
|
||||
>
|
||||
> **Verdict: GREEN — a v8 Event session reuses across sends, register-once is sufficient,
|
||||
> and the amortization is ~10–16×. Event READS stay gated (C2) and are not a reuse signal.**
|
||||
|
||||
**Date:** 2026-06-25
|
||||
**Branch:** `feat/amortization-broadening`
|
||||
**Server:** live 2023 R2 (`wonder-sql-vd03`), RemoteGrpc transport.
|
||||
**Sandbox identity:** `HISTORIAN_EVENT_SANDBOX_TAG=HistGW.LiveTest.EventSpike` — the CM_EVENT send
|
||||
buffer has **no per-tag routing field** (it registers against a fixed system tag), so the sandbox
|
||||
value is stamped into the event `Type`/`SourceName`/`Namespace` + a `SpikeMarker` property as an
|
||||
**identity marker**; no real tag is written or overwritten.
|
||||
**Harness:** `tests/AVEVA.Historian.Client.Tests/EventSessionReuseSpikeTests.cs` driving the B0a
|
||||
seams `HistorianGrpcEventWriteOrchestrator.OpenAndRegisterEventSession` (open v8 Event session +
|
||||
`RegisterCmEventTag` ONCE) and `SendEventOnSession` (send only — no open/register).
|
||||
|
||||
---
|
||||
|
||||
## 1. Send reuse — GREEN
|
||||
|
||||
`ReusedEventSession_SendsTwice_SecondSkipsHandshake` **passed** (both runs): one
|
||||
`OpenAndRegisterEventSession` then **two `SendEventOnSession` on the same v8 Event session** — both
|
||||
accepted (`AddStreamValues` `BSuccess=true`).
|
||||
|
||||
```
|
||||
open+register (ECDH handshake + RegisterCmEventTag) = 242 ms (run 1: 350 ms)
|
||||
registration diag: RTag=True; EnsT=True
|
||||
reused-send[0] = 23 ms, ok=True
|
||||
reused-send[1] = 22 ms, ok=True
|
||||
```
|
||||
|
||||
The server accepts the same v8 Event client handle across back-to-back sends. The session handle is
|
||||
an immutable `readonly record struct (uint ClientHandle, Guid StorageSessionId)`; the send is
|
||||
stateless on the client side (each call reserializes a fresh `"OS"` buffer), so nothing per-op is
|
||||
baked into the handle.
|
||||
|
||||
## 2. Amortization — ~10–16×
|
||||
|
||||
The open+register (P-256 ECDH `ExchangeKey` → RC4 credential token → v8 `OpenConnection` →
|
||||
`RegisterCmEventTag`) costs ~242–350 ms and is paid **once**; a reused send is ~22 ms. So over a
|
||||
burst of N sends the per-send cost collapses from ~(265 ms open + 22 ms) to ~22 ms — a ~10–16× win
|
||||
on the send path, same shape as the v6 read/write amortization (`handshake-reuse-spike-results.md`).
|
||||
|
||||
## 3. Register-once is sufficient — GREEN
|
||||
|
||||
`ReusedEventSession_RegisterOnce_ThenSendMany` **passed**: `RegisterCmEventTag` run **once** (inside
|
||||
`OpenAndRegisterEventSession`), then **three** sends, all accepted.
|
||||
|
||||
```
|
||||
register-once send[0] = 25 ms, ok=True
|
||||
register-once send[1] = 22 ms, ok=True
|
||||
register-once send[2] = 22 ms, ok=True
|
||||
```
|
||||
|
||||
CM_EVENT registration is **session-scoped, not per-send** — the server holds the registration for the
|
||||
session's lifetime. A reuse pool registers once per warm session, not per op.
|
||||
|
||||
## 4. Idle tolerance — survived ≥25 s (best-effort, single sample)
|
||||
|
||||
`ReusedEventSession_IdleSweep_BestEffort` (log-only): after a send, a **25 s idle gap**, then another
|
||||
send — **the second send succeeded** (`session SURVIVED the idle gap`). Notable: the v6 read session
|
||||
idle-expires at a ≥25 s gap (`handshake-reuse-spike-results.md` §3), but this v8 Event session
|
||||
survived 25 s. This is a single-sample best-effort observation — a keepalive should still ping under
|
||||
the ~20 s floor for safety margin until the v8 Event idle boundary is characterized more finely.
|
||||
|
||||
## 5. Read-after-send — GATED (C2), not a reuse signal
|
||||
|
||||
`ReusedEventSession_ServesReadAfterSend_BestEffort` (log-only, hard-bounded by a 5 s gRPC deadline +
|
||||
an 8 s cancellation): the read-after-send on the same session **did not return data** — it cancels at
|
||||
the bound:
|
||||
|
||||
```
|
||||
read-after-send -> swallowed (RpcException Cancelled / OperationCanceled)
|
||||
=> read gated/unverified over gRPC (expected)
|
||||
```
|
||||
|
||||
This matches the pre-existing C2 gate: event **reads** over gRPC long-poll `GetNext` to a no-data
|
||||
terminal and are unverified. So the spike did **not** prove a one-session-serves-both-kinds property
|
||||
for reads — `SendEvent` is the only trustworthy reuse signal. (An unbounded read hung the first run;
|
||||
the harness now bounds it so the spike is a clean, re-runnable record.)
|
||||
|
||||
---
|
||||
|
||||
## 6. Implications for Stage B1 (the event-pool build)
|
||||
|
||||
GREEN → a **separate event-session pool** (the approved B1 approach) is warranted and high-value:
|
||||
|
||||
1. **Amortize `SendEvent` through a bounded event-session pool.** Open+register a v8 Event session
|
||||
once per warm session; lease it per send op (exclusive, like the v6 pool); reuse across a burst.
|
||||
~10–16× on the send path.
|
||||
2. **Keep the event pool SEPARATE from the v6 pool** (B1, as approved) — different auth (ECDH/v8),
|
||||
heavier re-handshake on drop, and its own idle characteristics.
|
||||
3. **`ReadEvents` stays PER-CALL / gated (C2).** Reads are unverified over gRPC regardless of reuse,
|
||||
so the event pool amortizes **sends only**; `ReadEvents` is unaffected by B1 and stays on the
|
||||
per-call path. (This refines the design's "route SendEvent + ReadEvents through the pool": only
|
||||
`SendEvent` is routed; `ReadEvents` remains per-call because it is gated, not because of reuse.)
|
||||
4. **Keepalive:** ping the warm event session under the idle floor. The cheap keepalive op for the
|
||||
event channel is TBD in B1 (the v6 pool uses `GetSystemParameter`; the event session's equivalent
|
||||
warm-touch needs picking — likely a no-op send or a lightweight event-channel status op).
|
||||
5. **Reactive re-auth:** on an expiry-looking failure, evict + full v8 re-handshake (heavier than the
|
||||
v6 re-auth — one ECDH + register penalty).
|
||||
|
||||
**Gate decision: GREEN → HistorianGateway A1 Stage B1 (a bounded `HistorianEventSessionPool` for
|
||||
`SendEvent`, default-on, parallel to the v6 `HistorianSessionPool`) is warranted and earns its own
|
||||
re-planned design + plan.**
|
||||
@@ -0,0 +1,505 @@
|
||||
# gRPC event-query capture (2026-06-22) — the StartEventQuery request that returns rows
|
||||
|
||||
Captured the stock 2023 R2 client performing a **gRPC event read** that returns rows, to resolve
|
||||
the open item "gRPC event ROW retrieval returns zero rows" (handoff §Current Status item 1). This
|
||||
closes the capture-gate: the working request shape is now known.
|
||||
|
||||
## How it was captured
|
||||
|
||||
`tools/AVEVA.Historian.Grpc2023CaptureHarness` gained a `capture-event` scenario. It loads the
|
||||
self-contained mixed-mode 2023 R2 `aahClientManaged.dll` and drives `HistorianAccess`:
|
||||
|
||||
```
|
||||
OpenConnection(ConnectionMode=Historian /*gRPC*/, ConnectionType=Event, ReadOnly=true)
|
||||
-> CreateEventQuery() // NON-null only on an Event connection
|
||||
-> EventQueryArgs { StartDateTime, EndDateTime, EventCount }
|
||||
-> EventQuery.StartQuery(args) // => GrpcRetrievalClient.StartEventQuery(requestBuffer)
|
||||
-> loop EventQuery.MoveNext() / QueryResult// => GrpcRetrievalClient.GetNextEventQueryResultBuffer
|
||||
-> EventQuery.EndQuery() -> CloseConnection
|
||||
```
|
||||
|
||||
The existing wide-net `instrument-grpc-nonstream` IL rewrite (every `Grpc*Client` `byte[]` method)
|
||||
already covers `GrpcRetrievalClient.StartEventQuery.requestBuffer` (entry) and
|
||||
`GetNextEventQueryResultBuffer.result` (exit) — no new instrument command was needed. Run read-only
|
||||
(non-destructive) against the live 2023 R2 server over the loopback tunnel; the rewrite + capture
|
||||
NDJSON stay under `artifacts/reverse-engineering/grpc-event-capture/` (gitignored — the result
|
||||
buffer carries event identity data).
|
||||
|
||||
Result: **50 events returned over gRPC** (Alarm.Set / Alarm.Clear rows), proving the path works when
|
||||
driven through an Event connection.
|
||||
|
||||
## Two findings
|
||||
|
||||
### 1. The event read needs an **Event-type connection** (`ConnectionIndex 1`)
|
||||
|
||||
`HistorianAccess.CreateEventQuery()` returns `null` unless `IsEventConnectionRequested()` — i.e. the
|
||||
connection was opened with `ConnectionType=Event`, which the native client routes to a *separate*
|
||||
connection (ConnectionIndex 1) from the process/data path. The full captured pre-query sequence on
|
||||
that connection: `OpenConnection` → `ExchangeKey` → `UpdateClientStatus` → `RegisterTags`(CM_EVENT) →
|
||||
`EnsureTags`(CM_EVENT) → `GetHistorianInfo` + 7×`GetSystemParameter` (Stat priming) →
|
||||
`StartEventQuery` → `GetNextEventQueryResultBuffer` (rows) → `EndEventQuery` → `CloseConnection`.
|
||||
|
||||
### 2. The working `StartEventQuery` request is **version 6**, not 5
|
||||
|
||||
Our SDK's `HistorianEventQueryProtocol.CreateNativeFilterAttempt` builds a **version-5** empty-filter
|
||||
buffer; the stock 2023 R2 client sends **version 6**. Diffed byte-for-byte (same query window +
|
||||
eventCount), the two buffers are **identical except**:
|
||||
|
||||
- **byte 0: version `06` vs `05`**
|
||||
- **5 additional trailing zero bytes** (stock = 70 bytes, SDK v5 = 65 bytes)
|
||||
|
||||
The server returns rows for v6 and **zero rows for v5** (the v5 request is *accepted* —
|
||||
`StartEventQuery` succeeds and yields a query handle — but `GetNextEventQueryResultBuffer` then
|
||||
matches nothing). Everything else is shared: the two query-window FILETIMEs, `UInt32 eventCount`,
|
||||
the `UInt32 65536` buffer hint, the `"UTC"` `HistorianString`, and the `01 01000001000001 0000`
|
||||
metadata-namespace block.
|
||||
|
||||
Captured v6 request layout (70 bytes; the FILETIMEs below are just the harness query window — no
|
||||
identity data):
|
||||
|
||||
```
|
||||
[0..1] UInt16 version = 6 // SDK currently sends 5
|
||||
[2..9] Int64 startUtc (FILETIME)
|
||||
[10..17] Int64 endUtc (FILETIME)
|
||||
[18..21] UInt32 eventCount
|
||||
[22..25] UInt32 0
|
||||
[26..27] UInt16 0
|
||||
[28..29] UInt16 1
|
||||
[30..36] 7 bytes 0 // empty-filter block
|
||||
[37..40] UInt32 65536 // buffer-size hint
|
||||
[41..50] HistorianString "UTC" (UInt32 len=3 + UTF-16LE)
|
||||
[51..60] 01 01 00 00 01 00 00 01 00 00 // metadata-namespace block (marker + 3 empty)
|
||||
[61..69] 9 bytes 0 // terminal (SDK v5 writes only 4 here)
|
||||
```
|
||||
|
||||
## Fix part 1 — v6 request (DONE, necessary)
|
||||
|
||||
`HistorianEventQueryProtocol.CreateStartEventQueryAttempts` gained a `version` parameter (default 5 =
|
||||
WCF/2020; the gRPC orchestrator passes 6). v6 emits the leading `06` and the 5-byte trailing pad. The
|
||||
WCF path is unchanged (v5). Golden test `Version6EmptyFilterMatchesCapturedGrpcEnvelope` pins the
|
||||
envelope; 322/322 offline tests pass.
|
||||
|
||||
## Fix part 2 — EVENT connection (the remaining gate, NOT yet implemented)
|
||||
|
||||
Live validation 2026-06-22: with the orchestrator now sending v6 against the event-bearing live
|
||||
server, `GetNextEventQueryResultBuffer` **still long-polls and returns zero rows** (the gated test
|
||||
still throws). So **v6 is necessary but not sufficient** — the read also requires an **Event-type
|
||||
connection**, which our SDK does not open.
|
||||
|
||||
Isolated by diffing the captured `OpenConnection.openParameters` (302 bytes, native format v8) for a
|
||||
**Process** connection (`connect` scenario) vs the **Event** connection (`capture-event`): aside from
|
||||
the per-session auth GUID/credential-hash regions ([22..37], [68..93], which vary between any two
|
||||
sessions), the connection differs in **two clean structural bytes**:
|
||||
|
||||
| offset | Process | Event |
|
||||
|--------|---------|-------|
|
||||
| 95 | `02` | `01` |
|
||||
| 96 | `00` | `01` |
|
||||
|
||||
These correspond to `HistorianConnectionType` (Process vs Event; the native event path runs on
|
||||
`ConnectionIndex 1`). The problem: our SDK opens the session with the **2020 OpenConnection3 v6**
|
||||
buffer (`HistorianNativeHandshake.BuildOpenConnection3Request`, `connectionMode 0x402`), which the
|
||||
2023 R2 server accepts for reads but which carries no event-connection-type marker. `connectionMode`
|
||||
is NOT the discriminator (2020 WCF event reads work with `0x402`); the native client distinguishes
|
||||
event vs process via this separate `ConnectionType` field in its v8 `openParameters`.
|
||||
|
||||
### Diagnosis (2026-06-22): the v6 Open2 format cannot express an event connection
|
||||
|
||||
Decoded the native `openParameters` (302 bytes): **byte 0 = `08` (format version 8)**, then a
|
||||
context GUID, username, a 26-byte session-derived region ([68..93]), machine/client-node/datasource
|
||||
strings, and at **[94] `ClientType=04`** immediately followed by **[95] `ConnectionType`
|
||||
(`01`=Event / `02`=Process)** + **[96] a flag (`01`/`00`)**, then the rest.
|
||||
|
||||
Our SDK builds the **v6** buffer (`HistorianOpen2Protocol.SerializeNativeOpenConnection3Version6`,
|
||||
byte 0 = `06`): it writes `ClientType` (1 byte) **immediately followed by `ConnectionMode` (uint)** —
|
||||
there is **no `ConnectionType` byte at all**. The v8 format *inserts* `ConnectionType` (+flag) between
|
||||
`ClientType` and the rest. So the v6 buffer the SDK sends (accepted by the 2023 R2 server for *reads*)
|
||||
structurally cannot mark the connection as Event, and the server returns event rows only for an Event
|
||||
connection.
|
||||
|
||||
Two further obstacles to simply emitting v8:
|
||||
- the native client authenticated via **`ExchangeKey`** (cert path; 72-byte `btInput`/`btOutput` in
|
||||
the capture) whereas the SDK's gRPC handshake uses **`ValidateClientCredential`** (Negotiate). The
|
||||
v8 `openParameters` [68..93] region is session-derived and tied to that auth flow.
|
||||
- `ConnectionMode` is NOT the lever (2020 WCF event reads work at `0x402`); `ConnectionType` is a
|
||||
distinct field that only exists from format v8.
|
||||
|
||||
Also confirmed a secondary format gap: the native gRPC `EnsureTags` CM_EVENT payload is **86 bytes**
|
||||
vs the SDK's `SerializeCmEventCTagMetadata` **83 bytes** (a 3-byte 2023 R2 bump, parallel to the
|
||||
event-query v5→v6). This is likely benign on its own (CM_EVENT pre-exists; 2020 EnsT2 returns
|
||||
benign-false yet events flow) but should be matched if the event open is ever rebuilt.
|
||||
|
||||
**Conclusion — the event-connection gate is NOT a tweak.** Making event rows flow over gRPC requires
|
||||
the SDK to emit the native **v8 `OpenConnection` format** with `ConnectionType=Event` (a 302-byte
|
||||
buffer whose layout differs from the v6 buffer and includes a session-derived auth region), and
|
||||
likely to adopt the `ExchangeKey` cert auth path. That is a substantial RE+implementation effort
|
||||
comparable to the original Open2 work — scoped as a follow-on, not a quick fix. Until then the gated
|
||||
`ReadEventsAsync_OverGrpc_*` test correctly still pins the no-row throw, and **v6 (part 1) is retained
|
||||
as the captured-correct request format** for when the open is rebuilt.
|
||||
|
||||
Capture artifacts (gitignored): `artifacts/reverse-engineering/grpc-event-capture/` —
|
||||
`event-capture.ndjson` (Event), `process-connect-2.ndjson` (Process).
|
||||
|
||||
## v8 `openParameters` fully decoded (2026-06-23) + the ECDH ExchangeKey finding
|
||||
|
||||
Full byte map of the native Event-connection `openParameters` (302 bytes; identity values
|
||||
redacted — they are session-specific and sit in the gitignored capture):
|
||||
|
||||
```
|
||||
[0] byte 0x08 format version = 8
|
||||
[1] byte 0xf0 constant marker
|
||||
[2..20] 19 × 0x00
|
||||
[21] byte 0x01 constant marker
|
||||
[22..37] 16B GUID per-session client key
|
||||
[38..41] u32 username length (chars)
|
||||
[42..N] UTF-16 username (HistorianString)
|
||||
[..+1] u16 credential-token length (= 26 in the capture)
|
||||
[..] 26B token ECDH-derived credential token <-- see below
|
||||
[94] byte 0x04 ClientType (= our NativeClientType 4)
|
||||
[95] byte ConnectionType 01 = Event / 02 = Process <-- THE GATE
|
||||
[96] byte flag 01 (Event) / 00 (Process)
|
||||
[97..] control bytes (0x03 ... small region, not fully named)
|
||||
[~114..117]u32 FormatVersion=3
|
||||
[..] HistorianString machine/server node name
|
||||
[..] HistorianString client node name "(<ver>)"
|
||||
[..] u32 session-variable (process-ish)
|
||||
[..] u32 / zeros
|
||||
[..] u32 datasource len
|
||||
[..] UTF-16 datasource id e.g. "2023.1219.4004.5"
|
||||
[270..285] 16 × 0xff ShardId (all-FF = unset; our v6 sends Empty)
|
||||
[286..289] u32 client/hcal version int
|
||||
[290..297] i64 FILETIME ClientTimestamp
|
||||
[298..301] u32 0
|
||||
```
|
||||
|
||||
The tail (`FormatVersion` → machine → clientNode → datasource → ShardId → version → timestamp)
|
||||
is the **same `ClientCommonInfo` our v6 already emits**. The new/different parts are: version byte,
|
||||
the `[1]`/`[21]` markers, the GUID position, the **26-byte credential token** (vs v6's fixed-size
|
||||
block), the **`ConnectionType` byte**, and ShardId=FF.
|
||||
|
||||
**The auth is ECDH, not Negotiate.** The capture's `ExchangeKey` buffers begin `45 43 4b 31` =
|
||||
ASCII **`"ECK1"`** + a 64-byte EC public-key point — a Diffie-Hellman key exchange — and the 26-byte
|
||||
`openParameters` token is derived from it. `HistorianSecurityMode` offers only `Disabled` / `None` /
|
||||
`TransportCertificate`; the harness used `TransportCertificate`, which is what drives the ECDH
|
||||
`ExchangeKey`. There is **no TLS+Negotiate mode** on the native client (it couples TLS with the cert
|
||||
ECDH path), so a Negotiate-auth v8 capture cannot be produced from the native client.
|
||||
|
||||
**Key de-risking insight:** our SDK's v6 `OpenConnection` sends a **fully zeroed** 1026-byte
|
||||
credential block (`credentialBlock: new byte[1026]`) and reads still work — because authentication is
|
||||
actually carried by the separate `StorageService.ValidateClientCredential` (Negotiate) handshake, not
|
||||
by the bytes inside `openParameters`. By analogy the v8 `[68..93]` token may likewise be **ignorable**
|
||||
once `ValidateClientCredential` has run. So the first build hypothesis (cheapest, read-only to test):
|
||||
|
||||
> Reuse the SDK's existing `ValidateClientCredential` handshake, then send a **v8 `OpenConnection`
|
||||
> with `ConnectionType=Event` and a zeroed credential token**, and see whether the 2023 R2 server
|
||||
> returns event rows.
|
||||
|
||||
If that works, the ECDH ExchangeKey RE is unnecessary. If it fails, the fallback is full reproduction
|
||||
of the ECDH `ExchangeKey` handshake (curve/KDF/cipher) — a much larger crypto-RE effort. Build path:
|
||||
add `SerializeNativeOpenConnectionVersion8(connectionType)` to `HistorianOpen2Protocol`, wire the gRPC
|
||||
event handshake to use it (events only; reads stay on v6), live-test (non-destructive). Full hex in
|
||||
the gitignored capture.
|
||||
|
||||
### Path A built + live-tested 2026-06-23 — DISPROVEN (v8 is coupled to ExchangeKey)
|
||||
|
||||
Built `HistorianOpen2Protocol.SerializeNativeOpenConnectionVersion8` (golden-tested,
|
||||
`Version8EventSerializerReproducesCapturedNativeStructure` — reproduces the captured 302-byte
|
||||
structure exactly) + `HistorianNativeHandshake.BuildEventOpenConnectionVersion8Request` (zeroed
|
||||
credential token) + an `eventConnection` switch on `HistorianGrpcHandshake.OpenSession`, and live-ran
|
||||
the event read against the server. Result: the v8 `OpenConnection` was **parsed by the server** (got
|
||||
past the byte format) but **rejected at the auth check** with native error
|
||||
|
||||
```
|
||||
type=132 code=34 "aahHcapLib::HistoryService::EstablishConnection — Failed to get client key"
|
||||
```
|
||||
|
||||
i.e. `EstablishConnection` could not find a server-side **client key** for our session. In the v6
|
||||
path that key is established by `StorageService.ValidateClientCredential` (which is why v6 reads
|
||||
work); the v8 path looks it up in the registry that **`HistoryService.ExchangeKey` (ECDH)** populates,
|
||||
and there is **no `ValidateClientCredential` on `HistoryService`** in the gRPC contract. So the server
|
||||
branches on the OpenConnection version: v6 accepts the Negotiate-established key, **v8 requires the
|
||||
ExchangeKey-established key**. The zeroed-token hypothesis is therefore disproven — not because of the
|
||||
token bytes, but because the whole v8 path is gated on `ExchangeKey` having run first.
|
||||
|
||||
**Status:** the v8 serializer/builder are correct and retained (golden-tested), plus the
|
||||
`OpenConnection` failure now decodes the native error (type/code/ASCII). The event orchestrator is
|
||||
reverted to the v6 session (gated test still pins the no-row throw). The remaining route is **Path B:
|
||||
implement `HistoryService.ExchangeKey`** — `"ECK1"` + a 64-byte EC public-key point (P-256 X‖Y, by the
|
||||
size) — using .NET `ECDiffieHellman`, establish the client key, then reissue the v8 `OpenConnection`.
|
||||
Open question for Path B: whether merely *completing* the ECDH key agreement registers the client key
|
||||
(so the zeroed openParameters token still rides through), or whether the token must also be derived
|
||||
from the shared secret (full KDF/cipher RE).
|
||||
|
||||
### Path B started 2026-06-23 — ExchangeKey ECDH works; cleared 2 of 3 layers
|
||||
|
||||
Implemented `HistoryService.ExchangeKey` as a **pure-managed P-256 ECDH** key exchange
|
||||
(`HistorianNativeHandshake.BuildExchangeKeyClientHello` / `DeriveExchangeKeySecret`, .NET
|
||||
`ECDiffieHellman` over `nistP256`; wire format `"ECK1" + u32(32) + X(32) + Y(32)`) and wired it into
|
||||
`HistorianGrpcHandshake.OpenSession(eventConnection: true)` ahead of the v8 `OpenConnection`,
|
||||
on the same context-key handle. Live result against the server: the **`ExchangeKey` RPC succeeds**
|
||||
(the server accepted our public key), and the v8 `OpenConnection` error **moved one layer deeper**:
|
||||
|
||||
```
|
||||
Path A (no ExchangeKey): 132/34 "Failed to get client key"
|
||||
Path B (ExchangeKey ECDH): 132/171 AuthenticationFailed "EstablishConnection — Authentication failed"
|
||||
```
|
||||
|
||||
So the ECDH cleared the client-key check; the remaining blocker is **authentication**: the 26-byte
|
||||
v8 credential token must be a *valid* value derived from the ECDH shared secret (not zeros).
|
||||
|
||||
### Token crypto traced 2026-06-23 (Frida → Windows CNG) — KDF found, token construction still open
|
||||
|
||||
Hooked Windows CNG (`bcrypt.dll`/`ncrypt.dll`) while the native harness ran a real ExchangeKey
|
||||
(`scripts/frida/aahclientmanaged-cng-exchangekey.js` + `artifacts/.../cng-trace.py`). Findings:
|
||||
|
||||
- **The ECDH + KDF are standard CNG, driven by managed `System.Security.Cryptography.ECDiffieHellmanCng`**
|
||||
(backtrace top frame = `System.Core.ni.dll`; the caller is aahClientManaged's C++/CLI `<Module>`):
|
||||
`NCryptSecretAgreement` (P-256) → `NCryptDeriveKey(KDF=HASH, HASH_ALGORITHM=SHA256, 32 bytes)`. So the
|
||||
derived key = **SHA256(ECDH shared secret)** — exactly `ECDiffieHellmanCng{ KeyDerivationFunction=Hash,
|
||||
HashAlgorithm=SHA256 }.DeriveKeyMaterial(...)`. Our managed `DeriveExchangeKeySecret` should switch to
|
||||
this (SHA256 of the raw agreement) to match.
|
||||
- **`"ECK1"` is NOT AVEVA-custom** — it is the standard Windows CNG `BCRYPT_ECCPUBLIC_BLOB` magic for
|
||||
P-256 (`NCryptExportKey`/`ImportKey` emit exactly `ECK1 + len(32) + X(32) + Y(32)`), confirming our
|
||||
`BuildExchangeKeyClientHello` wire format is correct.
|
||||
- **The 26-byte token is a custom construction that is not yet reproduced.** Correlated one run's
|
||||
derived key (`SHA256(secret)`) with that run's token (from the IL openParameters capture): a
|
||||
528-candidate offline cracker (HMAC/SHA/AES-GCM/CBC/CTR over the derived key × request slices ×
|
||||
creds) found **no match**, and the token matches **none** of the traced hash digests. The token
|
||||
starts with a constant `0x8e` marker in both captured runs (so it is structured, not raw cipher
|
||||
output). It is built in managed code between the `DeriveKeyMaterial` call and the openParameters
|
||||
assembly.
|
||||
|
||||
**dnlib IL extraction 2026-06-23 — the token scheme is fully reverse-engineered.** ILSpy can't
|
||||
decompile the mixed-mode assembly (crashes), but loading `dnlib` in PowerShell and scanning the IL
|
||||
recovered the whole construction:
|
||||
|
||||
- **`<Module>::CHistoryConnectionGrpc.GetClientKey`** is the ECDH driver: `new ECDiffieHellmanCng()`
|
||||
→ `KeyDerivationFunction = Hash`, `HashAlgorithm = SHA256`, `KeySize = 256` →
|
||||
`GrpcHistoryClient.ExchangeKey(strHandle, ourPubKey.ToByteArray(), out serverPub, out err)` →
|
||||
`CngKey.Import(serverPub, CngKeyBlobFormat.EccPublicBlob)` → **`DeriveKeyMaterial`** = the 32-byte
|
||||
client key = **`SHA256(ECDH shared secret)`**. (So our managed side should derive the key the same
|
||||
way — `ECDiffieHellman` raw agreement then SHA256, or equivalently `DeriveKeyFromHash(..., SHA256)`.)
|
||||
- **The 26-byte token is built by `aahClientCommon.CClientBase.ConfigureOpenConnection`** (the lone
|
||||
caller of `GetClientKey`) using the **`HistorianCrypto.NRC4_V2.aahCryptV2`** scheme — a custom
|
||||
**MD5-keyed RC4 stream cipher with a version prefix**:
|
||||
- `aahCryptV2.body`/`HashData` = **MD5** (verified: the IL loads MD5 round constants `0xd76aa478`…
|
||||
and rotates 7/12/17/22).
|
||||
- `aahCryptV2.prepare_key` = standard **RC4 KSA** seeding the 256-byte S-box from a **16-byte (MD5)**
|
||||
key (`std.array<unsigned char,16>`).
|
||||
- `aahCryptV2.enc_buffer` = `MD5(...)` → key, then **`rc4encrypt`** the body; `enc` prepends a
|
||||
scheme **prefix** (`NRC4_V2.PrefixV2` / `InnerPrefixV2`) — the constant `0x8e` token marker.
|
||||
- `from_GUID` keys the cipher from a GUID string.
|
||||
|
||||
So the token = `prefix + RC4(plaintext, key = MD5(keyMaterial))`, where the key material ties back to
|
||||
the `SHA256(ECDH secret)` client key. **This is 100% reproducible in pure managed code** (RC4 + MD5
|
||||
are ~40 lines; nothing AVEVA ships).
|
||||
|
||||
**Remaining to finish (next cycle):** read `ConfigureOpenConnection`'s exact wiring (which value is
|
||||
MD5'd for the RC4 key, what plaintext is encrypted, the exact prefix bytes — a little more dnlib IL),
|
||||
implement `aahCryptV2` (RC4+MD5+prefix) managed-side, set the v8 token = that, and live-test
|
||||
(non-destructive). The offline correlation data (one run's derived key + token + openParameters) is
|
||||
captured under `artifacts/.../` to validate the managed reproduction before going live.
|
||||
|
||||
### Token implemented + auth WORKS live (2026-06-23); row retrieval still 0 — proven NOT a payload issue
|
||||
|
||||
`token = RC4(password-UTF16LE, key = MD5(SHA256(ECDH secret)))` was implemented in pure managed C#
|
||||
(`HistorianNativeHandshake.BuildExchangeKeyCredentialToken` + `Rc4`; client key via
|
||||
`DeriveKeyFromHash(SHA256)`), golden-tested (RC4 standard vector + token construction), and
|
||||
**live-verified**: the v8 `OpenConnection` now **authenticates** against the 2023 R2 server (past the
|
||||
`132/171 AuthenticationFailed` wall). Auth is solved.
|
||||
|
||||
The event **query** still returns `version-11 rowCount-0` while the native returns 50 for an
|
||||
**identical** request. Exhaustively ruled out as the cause (all confirmed live, opt-in
|
||||
`EventReadDiagnostic` test + the IL rewrite extended to log string/uint handle fields):
|
||||
|
||||
- `StartEventQuery` request: **byte-identical** to the native (v6 layout)
|
||||
- v8 `OpenConnection` `openParameters`: **byte-identical** to the native (302 bytes) once ClientNodeName
|
||||
is matched — every control byte, ConnectionType, token framing, ShardId, etc.
|
||||
- Handle usage: identical — `ExchangeKey`→contextKey, registration→storage-session GUID (`strHandle`),
|
||||
query→client uint (`uiHandle`); our parsed handles are valid (registration `RTag/EnsT=True`, valid
|
||||
`queryHandle`)
|
||||
- `queryRequestType = 3`, registration sequence/order, gzip metadata header — all match
|
||||
- window (events exist; native returns 50 *now*), eventCount — not it
|
||||
|
||||
So **every observable client-side byte matches the native**, yet the server scopes 0 events to our
|
||||
connection. The event RPCs succeed over our transport and return a valid *empty* result (not a
|
||||
transport error), so it is **not a payload or transport-incompatibility issue** — it is a
|
||||
connection/server-level difference (e.g. session affinity tied to the native `Grpc.Core` HTTP/2
|
||||
connection or a connection-identity the server uses to scope events) that is **invisible to, and
|
||||
unfixable by, client payload matching.** Closing it needs server-side insight or a different angle
|
||||
(e.g. compare the full HTTP/2 connection setup / TLS identity), not more wire-payload RE.
|
||||
|
||||
**Shipped this effort:** the complete ExchangeKey crypto (ECDH + SHA256 + MD5-keyed RC4 token) — the
|
||||
hard wall — pure managed, golden-tested, auth live-verified. Orchestrator stays on the no-row throw;
|
||||
gated test unchanged.
|
||||
|
||||
### NEXT SESSION — the server-side / connection angle (row retrieval pickup)
|
||||
|
||||
Client payloads are exhausted (byte-identical to the native, proven above). The next investigation is
|
||||
**connection-level**, not wire-payload. Pursue in roughly this order; each is concrete and testable.
|
||||
|
||||
**Already proven — do NOT redo:** auth works (ExchangeKey ECDH + RC4 token, live-verified); v8
|
||||
`openParameters`, all handles (str/uint), `StartEventQuery` request, registration (`RTag/EnsT=True` +
|
||||
order), `queryRequestType=3`, gzip header — all byte-match the native. Events exist (native returns 50
|
||||
*now*). The event RPCs succeed over our transport and return a valid version-11 **rowCount-0** (not a
|
||||
transport error). So the server scopes 0 events to *our* connection specifically.
|
||||
|
||||
**Tooling already in place:** opt-in diagnostic test `EventReadDiagnostic_OverGrpc_PrintsJourney`
|
||||
(env `HISTORIAN_GRPC_EVENT_DIAG=1`, prints registration outcomes, handles, result hex, v8 buffer);
|
||||
the `capture-event` harness scenario (native, returns rows); `instrument-grpc-nonstream` now logs
|
||||
string/uint handle fields too; the CNG Frida hook. Live recipe: set `HISTORIAN_GRPC_HOST`/`_PORT
|
||||
32565`/`_TLS true`/`_DNSID` to the 2023 R2 server + domain creds (strip quotes); reach the box per the
|
||||
live-server access reference.
|
||||
|
||||
1. ~~**Transport: native `Grpc.Core` HTTP/2 vs our `Grpc.Net.Client` + `GrpcWebHandler` (gRPC-Web).**~~
|
||||
**DISPROVEN 2026-06-23.** Built `HistorianGrpcChannelFactory.CreateHttp2` (plain HTTP/2 over a
|
||||
`SocketsHttpHandler`, no `GrpcWebHandler` wrap, ALPN `h2` to the TLS server) and wired it into the
|
||||
event orchestrator behind `HISTORIAN_GRPC_EVENT_HTTP2=1` (event path only; reads stay gRPC-Web). Live
|
||||
side-by-side against the event-bearing server, **everything else held constant**:
|
||||
|
||||
| channel | auth | registration | queryHandle | result buffer |
|
||||
|---------|------|--------------|-------------|---------------|
|
||||
| `http2` (native HTTP/2) | ✓ | `RTag=True EnsT=True` | 1057 | `0B00000000001E000000` |
|
||||
| `grpc-web` (default) | ✓ | `RTag=True EnsT=True` | 1058 | `0B00000000001E000000` |
|
||||
|
||||
The complete v8 chain — ExchangeKey ECDH auth, CM_EVENT `RegisterTags`/`EnsureTags`, `StartEventQuery`
|
||||
(valid handle) — runs end-to-end over **plain native HTTP/2**, and the server returns the
|
||||
**byte-identical** version-11 (`0x0B`) rowCount-0 terminal on both transports. So gRPC-Web vs native
|
||||
HTTP/2 is **not** the discriminator — the zero-row scoping is identical regardless of transport. The
|
||||
`CreateHttp2` factory + the `HISTORIAN_GRPC_EVENT_HTTP2` switch + the `EventChannelMode` diagnostic are
|
||||
retained for future connection-level probing. This eliminates the leading hypothesis and tightens the
|
||||
conclusion: the server scopes 0 events to our connection at a layer **above** the gRPC transport.
|
||||
|
||||
2. ~~**TLS client identity / certificate.**~~ **DISPROVEN 2026-06-23 (decompile + capture).** The stock
|
||||
client's `GrpcClientBase.InitializeBase` creates a bare `HttpClientHandler` and sets only
|
||||
`ServerCertificateCustomValidationCallback` — it **never adds a client certificate**. The TLS-tee
|
||||
capture (below) confirms `clientCert=none` on every native connection. So the native presents no client
|
||||
cert; this is not the gate.
|
||||
|
||||
3. ~~**HTTP/2-level / connection-frame capture.**~~ **DONE 2026-06-23 — topology difference found, tested,
|
||||
NULL.** Built a TLS-terminating tee proxy (`artifacts/.../httpcap/`, gitignored: self-signed server
|
||||
cert, forwards through the loopback tunnel, logs decrypted HTTP/1.1 + gRPC-Web both ways) and ran a
|
||||
**native `capture-event` (returns 50 rows) and our SDK diagnostic (0 rows) through the same
|
||||
proxy/upstream**. Note: the stock client is gRPC-Web/HTTP-1.1 (not HTTP/2 — `alpn` empty), so the
|
||||
capture is HTTP/1.1 framing. Findings:
|
||||
- **Connection topology differs.** The native opens **5 TLS connections, one per service** —
|
||||
`HistoryService` (ExchangeKey/OpenConnection/Register/EnsureTags), `StatusService` (×2), and
|
||||
**`RetrievalService` (the event query: GetRetrievalInterfaceVersion → StartEventQuery → GetNext →
|
||||
EndEventQuery) on its own dedicated connection**. Our SDK collapses **every service onto one
|
||||
connection**. (Matches the decompile: stock has a separate `GrpcClientBase` per service.)
|
||||
- **Framing differs** (benign): native uses `content-length` + `Expect: 100-continue`; SDK uses
|
||||
`transfer-encoding: chunked`. The server accepts both (our `StartEventQuery` returns a valid handle),
|
||||
so framing is not the gate. No extra/hidden header on either side; `clientCert=none` throughout.
|
||||
- **TESTED the topology hypothesis (`HISTORIAN_GRPC_EVENT_SPLIT_CHANNEL=1`):** ran
|
||||
`StartEventQuery`/`GetNext`/`EndEventQuery` on a **dedicated RetrievalService connection** (no
|
||||
re-handshake, reusing the session handle — exactly mirroring native conn4), registration staying on
|
||||
the main connection. **Result: still `0B00000000001E000000` (0 rows), `QH=1063`.** Splitting the
|
||||
event query onto its own connection — the one concrete structural difference the capture revealed —
|
||||
**does not make rows flow.** So the server correlates by session handle, not by connection, and the
|
||||
topology is **not** the row-scoping gate. The `CreateHttp2`/`SPLIT_CHANNEL` switches + the
|
||||
`httpcap` proxy are retained as diagnostics.
|
||||
|
||||
4. ~~**Server-side ground truth.**~~ **ANSWERED 2026-06-23 (DISPROVES the data-scoping premise).** Via
|
||||
the SOCKS→SQL relay (read-only; `artifacts/.../sqlschema/`, gitignored), dumped the full event schema
|
||||
on the live `Runtime` DB. Findings:
|
||||
- **No per-connection / per-client / per-session column exists anywhere in the event store.** The only
|
||||
"scoping-like" columns on `Events`/`EventHistory`/snapshots are event *content* — `Source_*` (event
|
||||
origin area/object/PV), `User_*` (who acknowledged), `Provider_NodeName` (alarm provider node),
|
||||
`SourceServer`/`SourceTag` (cross-server replication). None is "which client connection requested
|
||||
this."
|
||||
- **The rich `Events` view is not a relational table — it is served live by the Historian engine via
|
||||
the `INSQL` OLE DB provider** (`sys.servers` shows linked servers `INSQL` + `INSQLD`;
|
||||
`OBJECT_DEFINITION('dbo.Events')` is `NULL` = encrypted remote view). The Historian's own
|
||||
`EventHistory` base table holds just 168 rows / 1 tag (the internal event-tag detector log); the
|
||||
alarm/event journal the gRPC query reads lives in the engine, surfaced through INSQL.
|
||||
- **Decisive: same engine, same `-90d..now` window, two paths diverge.** The `Events` view (via INSQL)
|
||||
returns **71,332 events** for that window — most recent `Alarm.Set` firing seconds before the probe
|
||||
(live, every few seconds) — while gRPC `StartEventQuery` for **our** connection returns **0**. The
|
||||
data is global, abundant, recent, and identical-window-addressable; the engine simply does not hand
|
||||
it to our gRPC connection.
|
||||
|
||||
→ There is **nothing in the data to scope by**, so the zero-row gate is **not** data scoping. It is the
|
||||
gRPC RetrievalService's **per-connection in-process execution state** — the same class of wall as
|
||||
`DeleteTagExtendedProperties` (server-side native in-process working-set, not reconstructable from
|
||||
byte-identical wire requests). Reproduce: `artifacts/.../sqlschema/` (Program.cs = SOCKS5 relay +
|
||||
`Microsoft.Data.SqlClient`; authenticate with the server's SQL login, not the domain Historian acct —
|
||||
creds in the gitignored creds file).
|
||||
|
||||
### Stock managed client decompiled (2026-06-23) — confirms no hidden client-side difference
|
||||
|
||||
Closing the gap that prior cycles left: the zero-rows conclusion had leaned on **wire capture**
|
||||
(`instrument-grpc-nonstream`, which only hooks `byte[]` params on `Grpc*Client` methods) — blind to gRPC
|
||||
metadata/headers, interceptors, channel options, and any non-`byte[]` call. Read the **stock managed
|
||||
client source directly** (`histsdk-2023r2-analysis/decompiled/Archestra.Historian.GrpcClient` +
|
||||
`HistorianAccess`; the pure-managed assemblies decompile cleanly even though the mixed-mode
|
||||
`aahClientManaged.dll` crashes ILSpy). Findings:
|
||||
|
||||
- **`GrpcClientBase.InitializeBase` builds the same channel we do.** `GrpcWebHandler((GrpcWebMode)0,
|
||||
HttpClientHandler)` with `HttpVersion = 1.1` — i.e. **the stock client speaks gRPC-Web over HTTP/1.1,
|
||||
the same transport as our SDK.** This *corrects the premise of hypothesis #1*: there was never a native
|
||||
`Grpc.Core` HTTP/2 path to differ from — the stock client that returns 50 rows is itself gRPC-Web. The
|
||||
HTTP/2 disproof's *conclusion* stands (and is reinforced: identical transport on both sides).
|
||||
- **`m_metadata` passed to every RPC (incl. `StartEventQuery`/`GetNextEventQueryResultBuffer`) is only
|
||||
`grpc-internal-encoding-request: gzip`** — exactly our header set. No connection-id, session token, or
|
||||
auth header rides in gRPC metadata. The **`ClientInterceptor` is a no-op** (`LogCall` is empty; both
|
||||
unary overloads just invoke the continuation). So the "invisible per-connection metadata/header" blind
|
||||
spot is **confirmed empty** — there is no hidden client-side identity the `byte[]` capture missed.
|
||||
- **The event-read query orchestration is genuinely not in managed code.** `CreateEventQuery` /
|
||||
`EventQuery.StartQuery` / `MoveNext` are not in the managed `HistorianAccess`; the managed
|
||||
`GrpcRetrievalClient.StartEventQuery` is a thin one-RPC stub. The query logic lives in the native
|
||||
C++/CLI `HistorianClient` core (the mixed-mode part ILSpy can't decompile) — consistent with the
|
||||
working-set being native/server-side, not a managed step we could read and replicate.
|
||||
|
||||
So **every client-controllable layer is now confirmed identical by reading the stock source**, not just
|
||||
by wire match: request bytes, transport, channel options, gRPC metadata, interceptor. The remaining
|
||||
difference is below the managed surface (native core) / server-side.
|
||||
|
||||
**Conclusion (after #1–#4 + stock client decompiled + TLS-tee capture).** Every angle is now exhausted:
|
||||
- **client payload** — byte-identical (IL capture + decompile);
|
||||
- **transport** — stock client is *also* gRPC-Web/HTTP-1.1; native HTTP/2 makes no difference, both 0 rows;
|
||||
- **client metadata/interceptor/channel** — decompiled: identical gzip-only header, no-op interceptor, no
|
||||
client cert; the TLS-tee capture confirms no hidden header and `clientCert=none`;
|
||||
- **connection topology** — the native splits services across 5 connections and queries on a dedicated
|
||||
RetrievalService connection; replicating that (`SPLIT_CHANNEL`) still returns 0 rows → the server
|
||||
correlates by session handle, not connection;
|
||||
- **data store** — global, unscoped; 71,332 events the engine serves via INSQL but withholds from our
|
||||
gRPC connection.
|
||||
|
||||
The gate is a **server-internal per-connection retrieval working-set** that a pure-managed client cannot
|
||||
reconstruct by matching wire bytes, transport, metadata, topology, or data — and the establishing logic is
|
||||
in the native `HistorianClient` C++ core, not in any decompilable managed step or observable on the wire.
|
||||
**gRPC event-row retrieval stands documented as auth-solved / retrieval-server-gated**; `ReadEventsAsync`
|
||||
over gRPC keeps the honest no-row throw, and event reads use the WCF transport. Diagnostics retained for
|
||||
any future server-side investigation: the `httpcap` TLS-tee proxy, the `CreateHttp2` / `SPLIT_CHANNEL`
|
||||
switches, the `EventReadDiagnostic` test, and the `capture-event` harness (native, returns rows).
|
||||
|
||||
### Verify the parse path against the provided client's real data (2026-06-23) — found + fixed a latent bug
|
||||
|
||||
Used the provided 2023 R2 client as an **oracle**: the `capture-event` harness returns 50 real events
|
||||
(verified live + through the `httpcap` proxy), and the `instrument-grpc-nonstream` rewrite captured the
|
||||
exact `GetNextEventQueryResultBuffer.result` buffer the stock client received — **63,192 bytes, version
|
||||
`0x0B` (11), rowCount 50** (25 `Alarm.Set` + 25 `Alarm.Clear`). Fed that real buffer through our
|
||||
`HistorianEventRowProtocol.Parse` to verify the read path decodes genuine gRPC event data, and it
|
||||
**exposed a latent parser bug**:
|
||||
|
||||
- The real row buffer is `version(2) + rowCount(4) + headerField(4, =0x1E)` then **markerless rows**
|
||||
(`rowFormat(2)=7 + filetime(8) + 8×u16 slots + compact-ascii type + propCount + props`). Our parser
|
||||
wrongly treated the one-time `0x1E` field as a **per-row marker** and re-consumed `[marker+format]`
|
||||
every row — so it parsed only the **first** row of any multi-row buffer and stopped. This is **not
|
||||
gRPC-specific**: the captured **WCF v9** buffer has the identical `0900 <rowCount> 1E000000 0700 …`
|
||||
header, so the shipped WCF event read had the same latent multi-row truncation.
|
||||
- **Fix:** read a 10-byte buffer header (skip the `0x1E` field once) and parse markerless rows; accept
|
||||
container version **9 (WCF) and 11 (gRPC)**. Verified: the real 50-row buffer now decodes to exactly 50
|
||||
events, ending cleanly at end-of-buffer (`Parse_RealStockClientCapture_DecodesAllEvents`, gated on
|
||||
`HISTORIAN_EVENT_CAPTURE_NDJSON`); plus a synthetic v11 golden test. 328 offline tests pass.
|
||||
|
||||
So the **parse path is now verified against the provided client's real event data** — the one remaining
|
||||
gap is strictly the server delivering rows to our gRPC connection (the working-set gate above). If that
|
||||
were ever opened, the decoded events would now flow through correctly on both transports.
|
||||
|
||||
**2 of 3 layers cleared** (key exchange + client key); the 3rd (token construction) is localized to a
|
||||
specific managed method, pending dnlib extraction. ExchangeKey + the v8 serializer are committed; the
|
||||
orchestrator stays on v6 (set `eventConnection: true` to re-arm once the token construction lands). The
|
||||
token-loop routing guardrail (`HistorianGrpcHandshakeRoutingTests`) was scoped to the closure so the
|
||||
legitimate ExchangeKey call is allowed while still pinning that the Negotiate token loop never routes
|
||||
there.
|
||||
@@ -0,0 +1,45 @@
|
||||
# 2023 R2 gRPC Interface-Version Integers (C3a)
|
||||
|
||||
**Captured:** 2026-06-25
|
||||
**Transport:** 2023 R2 gRPC (h2c, unauthenticated `GetInterfaceVersion` RPCs — no credentials required)
|
||||
**Status:** LIVE — integers captured from a real AVEVA Historian 2023 R2 server.
|
||||
|
||||
## Captured Values
|
||||
|
||||
| Service | UiVersion / Version | UiError / Error | Notes |
|
||||
|---------------|---------------------|-----------------|----------------------------------------------------|
|
||||
| History | 12 | 0 | Matches `HistoryInterfaceVersionGrpc2023R2 = 12` |
|
||||
| Retrieval | 4 | 0 | Matches `RetrievalInterfaceVersion = 4` (unchanged from 2020) |
|
||||
| Transaction | 2 | 0 | Matches `TransactionInterfaceVersion = 2` (unchanged from 2020) |
|
||||
| Status | 4 | 0 | Reachability-only; version integer is not version-gated (see note) |
|
||||
|
||||
> **Field-name note:** The History, Retrieval, and Status proto responses use `UiError`/`UiVersion` fields.
|
||||
> The Transaction response uses `Error`/`Version` (different naming convention in the proto). Both are
|
||||
> captured correctly; the table uses a unified column header for readability.
|
||||
|
||||
> **Status note:** `StatusService.GetStatusInterfaceVersion` returned UiVersion=4, UiError=0 on the live
|
||||
> 2023 R2 server. This differs from the historical 0 observed on 2020 WCF — both are reachability-only.
|
||||
> Status is classified as reachability-only: its version integer carries no semantic meaning for the
|
||||
> SDK's byte serializers, so its UiVersion is not gated and not asserted in tests.
|
||||
|
||||
## Evidence Test
|
||||
|
||||
`tests/AVEVA.Historian.Client.Tests/GrpcInterfaceVersionEvidenceTests.cs` —
|
||||
`GrpcInterfaceVersions_LiveServer_MatchAcceptedSet` reads these four RPCs live and asserts:
|
||||
- `history.UiError == 0` and `history.UiVersion ∈ {11, 12}`
|
||||
- `retrieval.UiError == 0` and `retrieval.UiVersion == 4`
|
||||
- `transaction.Error == 0` and `transaction.Version == 2`
|
||||
- `status.UiError == 0` (version not asserted)
|
||||
|
||||
The test skips silently when `HISTORIAN_GRPC_HOST` is absent.
|
||||
|
||||
## Gap Closed
|
||||
|
||||
This document closes the **C3a** gap: "2023 R2 gRPC server-version integers not yet captured."
|
||||
Prior to this capture, the `HistorianServerVersionGate` accepted History=12, Retrieval=4, and
|
||||
Transaction=2 on the basis that they were inferred/expected-to-be-unchanged. All four integers are
|
||||
now confirmed from a live 2023 R2 server over the gRPC transport; no widening of `AcceptedVersions`
|
||||
is required (all captured values were already accepted).
|
||||
|
||||
The 2020 WCF baseline (History=11, Retrieval=4, Transaction=2) was captured earlier via the
|
||||
`wcf-probe` command and is documented in `wcf-probe-remote-latest.json` and `wcf-contract-evidence.md`.
|
||||
@@ -0,0 +1,57 @@
|
||||
# R0.1 browse over gRPC — StartTagQuery takes an OData filter (2026-06-21)
|
||||
|
||||
Live-probed `RetrievalService.StartTagQuery` / `QueryTag` against a real **2023 R2** server over the
|
||||
gRPC front door (string-handle = uppercase Open2 storage GUID). Key result: **browse is feasible on
|
||||
2023 R2** — the 2020 WCF "metadata-server pipe" wall does **not** block here.
|
||||
|
||||
## StartTagQuery — CRACKED
|
||||
|
||||
`StartTagQuery(strHandle, btRequest)` where `btRequest` = the native
|
||||
`marker(26449) + version(1) + WriteHistorianString(filter)` buffer
|
||||
(`HistorianTagQueryProtocol.CreateStartTagQueryAttempt`). The server runs
|
||||
`CMdServer::StartTagQuery::StartActiveTagnamesQuery` over `\\.\pipe\aahMetadataServer\console` and
|
||||
**parses the filter string as OData** (not SQL-LIKE). Swept filters:
|
||||
|
||||
| filter | result |
|
||||
|---|---|
|
||||
| `startswith(TagName,'Sys')` | ✅ success, 8-byte response |
|
||||
| `contains(TagName,'Sys')` | ✅ success |
|
||||
| `TagName eq 'SysTimeSec'` | ✅ success |
|
||||
| `` (empty) | ✅ success (all tags) |
|
||||
| `Sys*` / `*` | ❌ `ODataFilter ... bad token` |
|
||||
| `TagName like 'Sys%'` / `Name like 'Sys%'` | ❌ rejected |
|
||||
|
||||
Success response `btResponse` is the 8-byte `(queryHandle:uint, tagCount:uint)` pair
|
||||
(`ParseStartTagQueryResponse`). Live: `startswith(TagName,'Sys')` → tagCount = 220.
|
||||
|
||||
**Implication for the public API:** browse must translate the SDK's glob filter to OData —
|
||||
`*` → empty, `Pre*` → `startswith(TagName,'Pre')`, `*sub*` → `contains(TagName,'sub')`,
|
||||
exact → `TagName eq '...'`. (Escaping single-quotes in names still TBD.)
|
||||
|
||||
## QueryTag — CRACKED (2026-06-21), browse SHIPPED
|
||||
|
||||
`QueryTag(strHandle, uiQueryHandle, btRequest)` is the paging call that returns the tag-name rows.
|
||||
The blocker was the packet id: every guessed `btRequest` returned native error **type 4 / code 72 =
|
||||
`InvalidPacketId`** (`ArchestrA.CloudHistorian.Contract.ErrorCode.InvalidPacketId`). The generic
|
||||
`0x6751` header that StartTagQuery accepts is the **wrong** id for QueryTag.
|
||||
|
||||
**How it was found (no Ghidra needed):** a `.rdata` **packet-descriptor table** in
|
||||
`aahClientManaged.dll` lists consecutive `{uint marker, uint version}` entries —
|
||||
`{0x6751, 1}` (StartTagQuery) immediately followed by **`{0x6752, 1}`** (the paired op). Found by
|
||||
`pefile` byte-scan of `.rdata` for `51 67 00 00` and dumping the surrounding dwords. Testing `0x6752`
|
||||
live confirmed it.
|
||||
|
||||
**QueryTag wire format (live-verified):**
|
||||
- request `btRequest` = `u16 marker(0x6752) + u16 version(1) + u16 queryType + u32 startIndex + u32 count`
|
||||
— `queryType = 1` returns tag-name rows (`queryType = 0` returns an empty/count-only page).
|
||||
- response `btResonse` = `u32 count + per-name(u32 charCount + UTF-16LE) + trailer`
|
||||
(the trailer is the CloudHistorian `NextIndex`/`TagMetadataBuffer` region — ignored by
|
||||
`HistorianTagQueryProtocol.ParseTagNameQueryPage`).
|
||||
- Semantic fields match `ArchestrA.CloudHistorian.Contract.QueryTagRequest`
|
||||
(`QueryType/StartIndex/TagCount`; the QueryHandle travels in the protobuf `uiQueryHandle`).
|
||||
|
||||
**Browse is shipped:** `HistorianClient.BrowseTagNamesAsync` routes over gRPC when
|
||||
`Transport==RemoteGrpc` via `Grpc/HistorianGrpcTagClient.BrowseTagNamesAsync`
|
||||
(StartTagQuery(OData) → paged QueryTag(0x6752) → EndTagQuery), with the SDK glob filter translated by
|
||||
`GlobToODataFilter`. Golden-byte + glob unit tests and a gated live test
|
||||
(`BrowseTagNamesAsync_OverGrpc_ReturnsSystemTags`) cover it. **M0 gRPC parity is complete.**
|
||||
@@ -1,6 +1,193 @@
|
||||
# AVEVA Historian Managed Driver Handoff
|
||||
|
||||
Last updated: 2026-05-04 (write surface live: EnsT2 + DelT + ApplyScaling)
|
||||
Last updated: 2026-06-23 (event-row parser fix merged; roadmap still exhausted — no actionable pure-code tasks remain)
|
||||
|
||||
> **Current status supersedes the historical blocker narrative below.** The
|
||||
> sections from "Active Blocker" onward are a preserved reverse-engineering
|
||||
> record of how the 2020 WCF read/write/event paths were cracked (2026-05-04).
|
||||
> They are kept for provenance; they are **not** the live state. Start with
|
||||
> "Current Status" immediately below.
|
||||
|
||||
## Current Status (2026-06-22) — roadmap exhausted
|
||||
|
||||
`docs/plans/hcal-roadmap.md` reachable surface is **complete**, and every plan
|
||||
under `docs/plans/` is either DONE or has only gated items left. There are
|
||||
**zero actionable pure-code tasks remaining**. Memory anchor:
|
||||
`project_roadmap_exhausted_2020wcf`.
|
||||
|
||||
**Shipped + live-verified across both transports:**
|
||||
|
||||
- **Reads** (WCF + gRPC): `ProbeAsync`, `ReadRawAsync`, `ReadAggregateAsync`,
|
||||
`ReadAtTimeAsync`, `ReadEventsAsync`, `BrowseTagNamesAsync`,
|
||||
`GetTagMetadataAsync`, status helpers (`GetConnectionStatusAsync`,
|
||||
`GetStoreForwardStatusAsync`, `GetSystemParameterAsync`).
|
||||
- **Writes**: `EnsureTagAsync` (analog Float/Double/Int2/Int4/UInt4, `ApplyScaling`),
|
||||
`DeleteTagAsync`, `RenameTagsAsync`, `AddTagExtendedPropertiesAsync`, and the
|
||||
M3 historical/backfill `AddHistoricalValuesAsync` (**gRPC-only**, all five
|
||||
analog types golden-tested + live write/read-back).
|
||||
- **Config reads** (mostly gRPC): `GetRuntimeParameterAsync`,
|
||||
`GetTagExtendedPropertiesAsync`, `ExecuteSqlCommandAsync` (WCF; gRPC
|
||||
server-walled), `GetServerTimeZoneAsync` (gRPC-only).
|
||||
- **Client-side**: M4 R4.1 store-and-forward outbox, R4.4 redundancy,
|
||||
R4.3 measured-idle SF status.
|
||||
|
||||
The 2023 R2 **gRPC transport** (`HistorianTransport.RemoteGrpc`, port 32565)
|
||||
reuses the proven 2020 WCF byte serializers/parsers unchanged inside protobuf
|
||||
`bytes` fields, keyed by the Open2 session handle. Live-verified against a real
|
||||
2023 R2 server (History interface v12) — see `reference_2023r2_live_server_access`.
|
||||
|
||||
**Everything still open is gated — none is a pure-code task:**
|
||||
|
||||
1. **gRPC event ROW retrieval** (`ReadEventsAsync` #2) — **AUTH-SOLVED / PARSE-VERIFIED /
|
||||
RETRIEVAL-SERVER-GATED (every client-side angle exhausted 2026-06-23, merged `6faf8a5`).**
|
||||
The v8 OpenConnection crypto wall is fully cracked + live-verified: the event connection
|
||||
authenticates via **`HistoryService.ExchangeKey` (P-256 ECDH) → client key =
|
||||
`SHA256(shared secret)` → credential token = `RC4(password-UTF16LE, key=MD5(client key))`**
|
||||
(the native `HistorianCrypto.NRC4_V2.aahCryptV2` MD5-keyed RC4 scheme). RE'd via Frida CNG
|
||||
hooks + dnlib IL extraction + an offline cracker; implemented pure-managed, golden-tested,
|
||||
auth live-PASSES. The `StartEventQuery` v6 request and the Event-type v8 `OpenConnection`
|
||||
(`ConnectionType=Event`) are shipped. **BUT** the query still returns **rowCount-0** while
|
||||
the native returns 50 for a byte-identical request — and **all four next-session angles are
|
||||
now tested and ruled out** (`grpc-event-query-capture.md`):
|
||||
- **transport** — the stock client is *also* gRPC-Web/HTTP-1.1 (decompiled); plain native
|
||||
HTTP/2 (`CreateHttp2`) returns the same 0 rows;
|
||||
- **client metadata/cert** — decompiled + TLS-tee captured: gzip-only metadata, no-op
|
||||
interceptor, **no TLS client cert** on either side;
|
||||
- **connection topology** — the native splits services across 5 connections and queries on
|
||||
a dedicated RetrievalService connection; replicating that (`HISTORIAN_GRPC_EVENT_SPLIT_CHANNEL=1`)
|
||||
still returns 0 rows → the server correlates by **session handle, not connection**;
|
||||
- **data store** — via SOCKS→SQL: the event store is global/unscoped (no per-connection
|
||||
column); the `Events` view (served by the engine via the INSQL provider) returns 71,332
|
||||
events for the same window the gRPC query gets 0.
|
||||
|
||||
So the gate is a **server-internal per-connection retrieval working-set** in the native
|
||||
`HistorianClient` C++ core — not reconstructable from a pure-managed client. **PARSE PATH NOW
|
||||
VERIFIED + a latent bug FIXED:** fed the provided stock client's real captured result buffer
|
||||
(63,192 B, 50 events) through `HistorianEventRowProtocol.Parse` — it exposed that the parser
|
||||
treated the one-time `0x1E` buffer header field as a per-row marker, decoding only the **first
|
||||
row of any multi-row buffer**. This also hit the **shipped WCF event read** (identical
|
||||
`0900 <rowCount> 1E000000 0700` header). Fixed to a 10-byte buffer header + **markerless rows**,
|
||||
accepting container version 9 (WCF) and 11 (gRPC); the real 50-row buffer now decodes to exactly
|
||||
50 events (`Parse_RealStockClientCapture_DecodesAllEvents`, gated on `HISTORIAN_EVENT_CAPTURE_NDJSON`).
|
||||
So if the server gate ever opens, decoded events flow through correctly on both transports; until
|
||||
then the orchestrator stays on the no-row throw (`eventConnection: true` wired; opt-in
|
||||
`EventReadDiagnostic` test, `HISTORIAN_GRPC_EVENT_DIAG=1`). Diagnostics retained: the `httpcap`
|
||||
TLS-tee proxy, `CreateHttp2`/`SPLIT_CHANNEL` switches, the `sqlschema` SOCKS→SQL probe, the
|
||||
`capture-event` harness (native, returns rows).
|
||||
2. **R4.3 active-SF magnitude** — needs an **SF-active server** (D2 storage-engine
|
||||
console handle).
|
||||
3. **SendEvent over gRPC** — ✅ **SHIPPED + LIVE-VALIDATED 2026-06-23.** `SendEventAsync`
|
||||
now routes over `RemoteGrpc` (`HistorianGrpcEventWriteOrchestrator`). Captured the native
|
||||
client live (`capture-send-event` harness scenario): the send rides
|
||||
`HistoryService.AddStreamValues` with the **same "OS" (0x534F) buffer the WCF path uses**
|
||||
(`HistorianEventWriteProtocol` — "no distinct RPC" confirmed true), on a v8 Event session +
|
||||
CM_EVENT registration. The write-enabled Event open is **byte-identical** to the read-only one
|
||||
(diffed live — only per-session crypto differs), so the existing event-open path is reused
|
||||
unchanged. End-to-end: pure-managed SDK send → `BSuccess=true` → event read back from the live
|
||||
server (markers `SdkSendProbe`/`SdkCaptureProbe` confirmed in returned rows). Golden-tested
|
||||
(`GrpcEventSendProtocolTests`) + gated live test (`SendEventAsync_OverGrpc_AcceptsEvent`,
|
||||
opt-in `HISTORIAN_GRPC_EVENT_SEND=1`).
|
||||
**PERSISTENCE RESOLVED:** the earlier WCF/M2 caveat was "server accepts AddS2 but the local
|
||||
dev box does NOT persist the event to `v_AlarmEventHistory2`" — we couldn't tell if that was an
|
||||
SDK gap or the environment. The gRPC read-back against the live 2023 R2 server **proves the event
|
||||
persists** (sent via the pure-managed SDK, then independently read back from the server's event
|
||||
history). So non-persistence on the local box was purely the dev-box event-ingestion environment,
|
||||
**not an SDK gap** — the event-send path (WCF and gRPC, same "OS" buffer) is durably correct.
|
||||
4. **ExecuteSqlCommand over gRPC** — **server-walled** (`CSrvDbConnection`;
|
||||
RegisterTags prime doesn't help). Use WCF for SQL.
|
||||
5. **R4.2 revision EDITS** — storage-engine-pipe-only on BOTH transports (the D2 wall).
|
||||
6. **ReadBlocks** (`StartBlockRetrievalQuery`) — never captured on either transport.
|
||||
7. **DeleteTagExtendedProperties** — server-blocked; **confirmed walled by capturing the NATIVE
|
||||
client 2026-06-23.** The agent's "use `deleteFromServer=true`" angle is moot: the native
|
||||
`HistorianAccess.DeleteTagExtendedPropertiesByName(...,deleteFromServer:true)`, driven with the
|
||||
cross-session sync trick that gets it past the client-side err-229 sync gate
|
||||
(`Capture-DeleteTagExtendedProperties.ps1`), returns **`Success=true` / ErrorCode=Success** —
|
||||
yet across repeated fresh sessions the property is **re-fetchable and re-deletable every time**,
|
||||
i.e. it is **never durably removed**. So the native client itself only performs an optimistic
|
||||
client-side cache delete; the server does not durably honor it (matches the HCAL cache-sync
|
||||
model the decompile shows). Shipping a `DeleteTagExtendedPropertiesAsync` would return a
|
||||
misleading success while the property persists, so it correctly stays **unshipped**. (Earlier
|
||||
gRPC multiplexed-channel hypothesis also PROBED + DISPROVEN 2026-06-22, merge `c88260c`; pinned
|
||||
by `DeleteTagExtendedProperties_OverGrpc_ProbeMultiplexedChannel`.)
|
||||
8. **Deferred-by-design** items (`write-commands` D1–D3, non-analog tag create,
|
||||
etc.) — bounded out until an explicit customer/user demand signal.
|
||||
|
||||
To move any remaining item you need a **server-side / connection-level angle**
|
||||
(item 1 — v8 event auth is solved; row retrieval is connection-gated, see the
|
||||
NEXT SESSION section of `grpc-event-query-capture.md`), a **different server**
|
||||
(SF-active for item 2), or a **demand signal** to unlock a deferred item.
|
||||
(SendEvent over gRPC — formerly item 3 — is now SHIPPED + live-validated.)
|
||||
Live-server gRPC probe recipe: set
|
||||
`HISTORIAN_GRPC_HOST`/`_PORT 32565`/`_TLS true`/`_DNSID` + domain creds (strip
|
||||
quotes — `reference_wonder_sql_vd03_credentials`) and run the gated
|
||||
`HistorianGrpcIntegrationTests`.
|
||||
|
||||
### 2023 R2 stock-client binary dive (2026-06-23) — sharpened verdicts
|
||||
|
||||
Re-read the full decompiled stock 2023 R2 managed client
|
||||
(`histsdk-2023r2-analysis/decompiled/`: `Archestra.Historian.GrpcClient`,
|
||||
`ArchestrA.HistorianAccess`, `Archestra.Grpc.Contract`, `HistorianEvent`,
|
||||
`HistorianAccessUtil`) as the oracle for every still-pending item. **Governing
|
||||
fact:** `ArchestrA.HistorianAccess.dll` is a C++/CLI mixed assembly — every
|
||||
data/config/write method is a thin shim into native `<Module>.HistorianClient.*`,
|
||||
and the managed `Grpc*Client` wrappers are instantiated by **nothing** in the
|
||||
decompiled set (`new Grpc*Client(` → zero call sites). So the buffer-building and
|
||||
RPC-dispatch sequencing for these items lives in native C++ not present in the
|
||||
binaries. That confirms the "gated" calls were not from missing managed steps —
|
||||
with these refinements:
|
||||
|
||||
- **Item 1 (gRPC event rows)** — **confirmed native/server-side.** Stock event
|
||||
call graph is provably identical to ours (transport, per-service channels,
|
||||
gzip-only metadata, CM_EVENT registration, v8 ECDH Event-open, `StartEventQuery`
|
||||
request bytes). `EventQuery.StartQuery`/`MoveNext` dispatch straight into native
|
||||
`HistorianClient.StartEventQuery`/`GetNextRow`; the query orchestration that
|
||||
would differ is native and not on the wire. One untested low-effort check
|
||||
remains: byte-diff a captured **Event-connection** EnsureTags/RegisterTags
|
||||
against our replay (the 83-vs-86-byte EnsT gap was never actually compared).
|
||||
- **Item 3 (SendEvent over gRPC)** — ✅ **SHIPPED + LIVE-VALIDATED 2026-06-23** (was
|
||||
"capturable"). RPC confirmed = `HistoryService.AddStreamValues` (the "no distinct RPC"
|
||||
note is TRUE). The `btValues` VTQ buffer turned out to be already-owned: our M2
|
||||
`HistorianEventWriteProtocol.SerializeAddStreamValuesBuffer` ("OS" buffer, decoded from
|
||||
the WCF event-send) is the transport-independent `PackToVtq` equivalent and the gRPC send
|
||||
uses it **verbatim** (live capture: sig `OS`/0x534F, CM_EVENT GUID, identical framing — NOT
|
||||
the historical write's "ON" buffer). The write-enabled Event open is byte-identical to the
|
||||
read-only one (live diff). So SendEvent-over-gRPC was pure assembly:
|
||||
`HistorianGrpcEventWriteOrchestrator` = existing v8 Event open + existing CM_EVENT
|
||||
registration + `AddStreamValues`(OS buffer). End-to-end live-validated (send → `BSuccess`
|
||||
→ read back from the live server). Golden-tested + gated live test.
|
||||
- **Item 4 (ExecuteSql over gRPC)** — **confirmed walled + explained.** The stock
|
||||
client gates SQL **out client-side**: `HistorianAccess.ExecuteSqlCommand` returns
|
||||
`OperationNotSupported` when `IsManagedHistorian(node)` or `!IsProcessConnectionRequested()`
|
||||
(decompile ~:6198/:6214) and never sends the RPC. SQL-over-gRPC is unsupported by
|
||||
design on a managed/gRPC historian; our `ProtocolEvidenceMissingException` is correct.
|
||||
- **Item 5 (R4.2 revision edits)** — **confirmed HARD.** There is **no Revision RPC
|
||||
in the gRPC contract** (zero "Revision" message types); the stock client reaches a
|
||||
revision edit only via the native `HistorianClient.AddRevisionValuesBegin/AddRevisionValue/
|
||||
AddRevisionValuesEnd` transaction trio over the storage-engine channel. NOTE: this is
|
||||
a **distinct capability** from `AddNonStreamValues` (non-streamed original insert) —
|
||||
`HistorianGrpcRevisionProbe` probes the latter; its doc comment was corrected to say so.
|
||||
- **Item 6 (ReadBlocks/LoadBlocks)** — `LoadBlocks` request is a trivial
|
||||
handle+sequence cursor but the `historyBlocks` response is a native blob with no
|
||||
managed decoder, and it needs the D2-blocked `OpenStorageConnection` console handle.
|
||||
Walled.
|
||||
- **Item 7 (DeleteTagExtendedProperties)** — **capture done 2026-06-23; CONFIRMED WALLED, don't
|
||||
ship.** RPC + string handle are correct; ADD/DELETE are structurally identical and neither uses
|
||||
`StartJob`. The `deleteFromServer`-flag hypothesis is now tested and moot: the native
|
||||
`DeleteTagExtendedPropertiesByName(...,deleteFromServer:true)`, driven past the err-229 client
|
||||
sync gate with the cross-session trick (`Capture-DeleteTagExtendedProperties.ps1`), returns
|
||||
`Success=true` — yet the property is **re-fetchable + re-deletable across repeated fresh sessions
|
||||
(never durably removed)**. So the native client only does an optimistic client-side cache delete
|
||||
the server doesn't durably honor (the HCAL cache-sync model). Shipping
|
||||
`DeleteTagExtendedPropertiesAsync` would return a misleading success, so it stays unshipped.
|
||||
- **SF/snapshot/shard/ForwardSnapshot ops** — only `Get/SetSFParameter` are managed-built
|
||||
(typed strings); all others carry opaque native buffers and need the storage console
|
||||
handle. Walled / tooling-internal.
|
||||
|
||||
**Net:** 3 items hard-confirmed walled with real explanations (4, 5, 6 + OpenStorageConnection),
|
||||
and **2 moved to a precise, local-box-capturable target**: **SendEvent** (`PackToVtq` output)
|
||||
and **DeleteTEP** (`BtInput` with `deleteFromServer=true`). Both need native instrumentation of
|
||||
`aahClientManaged.dll` (Frida / IL-rewrite — repo tooling exists under
|
||||
`tools/AVEVA.Historian.NativeTraceHarness` + `scripts/frida/`), not a special server.
|
||||
|
||||
## Project Direction
|
||||
|
||||
@@ -73,10 +260,14 @@ dotnet build .\Histsdk.slnx --no-restore
|
||||
dotnet test .\Histsdk.slnx --no-build --logger "console;verbosity=minimal"
|
||||
```
|
||||
|
||||
Current known-good result:
|
||||
Current known-good result (2026-06-23):
|
||||
|
||||
- Build succeeds.
|
||||
- Unit tests pass: 55/55.
|
||||
- Build succeeds (0 warnings / 0 errors).
|
||||
- Offline tests pass: 328/328 (live gRPC/integration tests skip cleanly without
|
||||
their env vars). Gated live tests add to this when `HISTORIAN_*` /
|
||||
`HISTORIAN_GRPC_*` are set. The +7 over the prior 321 are the event-row parser
|
||||
fix's golden + gated-capture coverage (`HistorianEventRowProtocolTests`:
|
||||
markerless multi-row, the v11 gRPC header, and the 50-event stock-client capture).
|
||||
|
||||
The workspace is a Git working tree (origin: gitea.dohertylan.com). Use
|
||||
normal git workflow for change tracking; the prior "no working tree, use
|
||||
@@ -227,7 +418,10 @@ Negative evidence:
|
||||
- Running the same managed `ValCl` path through .NET Framework also fails, so
|
||||
this is not just a .NET 10 WCF behavior difference.
|
||||
|
||||
## Active Blocker
|
||||
## Historical: Read-Path Blocker (resolved 2026-05-04)
|
||||
|
||||
> Preserved RE record. This was the *original* active blocker; it is long
|
||||
> resolved and is not the live state — see "Current Status" at the top.
|
||||
|
||||
**Resolved on `2026-05-04`.** The previous blocker — managed `ValCl`
|
||||
rejected by the server — had two causes, both now fixed:
|
||||
@@ -951,27 +1145,35 @@ record 5 of `instrumented-wcf-readmessage/readmessage-capture-event-latest.ndjso
|
||||
|
||||
### Event-row parser
|
||||
|
||||
`Wcf/HistorianEventRowProtocol.Parse(ReadOnlySpan<byte>)` parses the
|
||||
version-9 row buffer:
|
||||
> **CORRECTED 2026-06-23 (merged `6faf8a5`).** The skeleton below mis-read the `0x1E`
|
||||
> as a *per-row* marker. Verifying the parser against the provided stock client's real
|
||||
> 50-event buffer proved `0x1E` is a **one-time buffer-level header field**, and the rows
|
||||
> are **markerless** — so the original parser silently returned only the **first** row of
|
||||
> any multi-row buffer (on WCF too). The corrected layout and behaviour are below.
|
||||
|
||||
`Wcf/HistorianEventRowProtocol.Parse(ReadOnlySpan<byte>)` parses the row buffer
|
||||
(container version 9 for WCF, 11 for 2023 R2 gRPC — both accepted):
|
||||
|
||||
```text
|
||||
UInt16 version = 9
|
||||
UInt16 version = 9 (WCF) | 11 (gRPC)
|
||||
UInt32 rowCount
|
||||
N rows, each:
|
||||
UInt32 rowMarker = 0x1E
|
||||
UInt32 headerField = 0x1E // ONE buffer-level field, NOT a per-row marker
|
||||
N rows, each (MARKERLESS):
|
||||
UInt16 rowFormat = 7
|
||||
Int64 filetimeUtc (event time)
|
||||
UInt16 × 8 fieldOffsets (opaque — purpose not fully decoded)
|
||||
Property bag (sequence of name=value pairs; first name is the event type)
|
||||
UInt16 propertyCount
|
||||
Property bag (propertyCount × name=value pairs; first field is the event type)
|
||||
```
|
||||
|
||||
The parser extracts `EventTimeUtc` and `Type` (the first compact-ASCII-string
|
||||
in the property bag) for each row, and seeks forward to the next row by
|
||||
scanning for the next `1E 00 00 00 07 00` marker. Property-bag value
|
||||
encoding is partially decoded (compact ASCII `09 LEN 00 …`, UTF-16 strings
|
||||
`43 UInt32 LEN × UInt16`, integers with markers in the `0x88–0x8B` range,
|
||||
8-byte FILETIMEs) but **value parsing is intentionally not implemented yet**
|
||||
— it requires more reverse-engineering and would need sanitized fixtures.
|
||||
The parser reads the 10-byte buffer header (skipping the `0x1E` field once), then walks
|
||||
each markerless row by length: `rowFormat(2) + filetime(8) + 8×UInt16 slots + compact-ASCII
|
||||
type + propertyCount + propertyCount × (name + value)`. Value encoding **is** implemented
|
||||
(compact ASCII `09 LEN 00 …`, Boolean `0x02`, GUID `0x10`, FILETIME `0x18`, Int32 `0x31`,
|
||||
UTF-16 `0x43`; unknown markers preserve raw bytes). Verified against the provided client's
|
||||
real buffer: `Parse_RealStockClientCapture_DecodesAllEvents` decodes all 50 events (25
|
||||
Alarm.Set + 25 Alarm.Clear) to end-of-buffer (gated on `HISTORIAN_EVENT_CAPTURE_NDJSON`),
|
||||
plus a synthetic v11 golden test.
|
||||
|
||||
5 unit tests in `HistorianEventRowProtocolTests.cs` cover empty buffer,
|
||||
zero-row, wrong-version, two-row synthetic, and missing-marker. Test count
|
||||
@@ -1069,10 +1271,12 @@ Typemarker dispatch:
|
||||
Unknown markers preserve the raw `length` value bytes as a `byte[]` in
|
||||
the property dictionary.
|
||||
|
||||
Each row layout (refines the earlier skeleton):
|
||||
Each row layout (**corrected 2026-06-23** — see the "Event-row parser" note above; the
|
||||
`0x1E` is a one-time buffer header field, NOT a per-row marker, and rows are markerless):
|
||||
|
||||
```text
|
||||
UInt32 rowMarker = 0x1E
|
||||
buffer header: UInt16 version (9|11) + UInt32 rowCount + UInt32 headerField (0x1E)
|
||||
each row (markerless):
|
||||
UInt16 rowFormat = 7
|
||||
Int64 eventTimeUtcFiletime
|
||||
UInt16 × 8 // purpose unclear
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# Handshake / session-reuse spike — live results
|
||||
|
||||
> **Question:** does the 2023 R2 historian honor REUSING one authenticated session
|
||||
> (channel + `OpenConnection` client handle) across multiple operations, instead of
|
||||
> the per-operation Create+handshake the SDK does today? This is the precondition for
|
||||
> "handshake amortization" (HistorianGateway `pending.md` A1).
|
||||
>
|
||||
> **Verdict: GREEN — reuse works and the win is large — but the server idle-expires a
|
||||
> session in ~20–25 s, so a reuse pool must keep sessions warm.**
|
||||
|
||||
**Date:** 2026-06-25
|
||||
**Branch:** `spike/handshake-reuse`
|
||||
**Server:** live 2023 R2 (`wonder-sql-vd03`), RemoteGrpc transport, read-only test tag.
|
||||
**Harness:** `tests/AVEVA.Historian.Client.Tests/HandshakeReuseSpikeTests.cs` driving the new
|
||||
internal seam `HistorianGrpcReadOrchestrator.RunRawQueryOnSession(connection, clientHandle, …)`
|
||||
(runs a raw query against an externally-supplied, already-authenticated connection + handle —
|
||||
no Create, no handshake).
|
||||
|
||||
---
|
||||
|
||||
## 1. Reuse validity — GREEN
|
||||
|
||||
`ReusedSession_RunsManyReads_AllSucceed` **passed**: one `HistorianGrpcChannelFactory.Create`
|
||||
+ one `HistorianGrpcHandshake.OpenSession`, then **5 consecutive `RunRawQueryOnSession` reads on
|
||||
the same `ClientHandle`** — all returned rows.
|
||||
|
||||
```
|
||||
open-session (handshake) = 325 ms
|
||||
reused-read[0] = 96 ms, rows=8
|
||||
reused-read[1] = 101 ms, rows=8
|
||||
reused-read[2] = 179 ms, rows=8
|
||||
reused-read[3] = 92 ms, rows=8
|
||||
reused-read[4] = 95 ms, rows=8
|
||||
```
|
||||
|
||||
The server accepts the same client handle across back-to-back `StartQuery`/`GetNextQueryResultBuffer`/
|
||||
`EndQuery` cycles. Per-query handles are opened/closed each op; the **session** handle is the reused
|
||||
artifact.
|
||||
|
||||
## 2. Win magnitude — large (~4.7×)
|
||||
|
||||
`ReusedSession_VsPerCallPath_LogsLatencyDelta` (logged, not asserted):
|
||||
|
||||
```
|
||||
per-call (5 ops) = 2626 ms # fresh Create + full handshake + query, ×5
|
||||
amortized (5 ops) = 561 ms # one handshake + 5 reused reads
|
||||
saving over 5 ops = 2065 ms
|
||||
```
|
||||
|
||||
The handshake (`GetInterfaceVersion` → `ValidateClientCredential` NTLM token loop →
|
||||
`OpenConnection`, ~325 ms) dominates per-call cost. Amortized, a read is ~110 ms vs ~525 ms
|
||||
per-call. **Amortization is clearly worth the refactor for any burst of activity.**
|
||||
|
||||
## 3. Expiry — idle timeout ~20–25 s (NOT an absolute TTL)
|
||||
|
||||
`ReusedSession_IdleSweep_SurfacesExpiryTier` rethrows at the first idle gap the server rejects.
|
||||
|
||||
Coarse sweep `[0, 30]`: `idle 0s → OK`, `idle 30s → BROKE`.
|
||||
Fine sweep `[0,5,10,15,20,25,30]`:
|
||||
|
||||
```
|
||||
idle 0s -> OK (rows=8)
|
||||
idle 5s -> OK
|
||||
idle 10s -> OK
|
||||
idle 15s -> OK
|
||||
idle 20s -> OK # session age here ≈ 50 s cumulative, still alive
|
||||
idle 25s -> BROKE (InvalidOperationException: gRPC StartQuery (raw) failed, errorLen=5)
|
||||
```
|
||||
|
||||
**Key inference — it's an idle timeout, not a fixed session lifetime.** The reads at gaps of
|
||||
5/10/15/20 s kept succeeding even though the cumulative session age reached ~50 s by the 20 s-gap
|
||||
read. The session only died after a **≥25 s idle gap**. So a session survives indefinitely as long
|
||||
as operations are spaced under ~20 s apart; a quiet gap of ≥25 s invalidates it.
|
||||
|
||||
Expired-session failure mode on the wire: `StartQuery` returns `BSuccess=false` with a 5-byte error
|
||||
buffer, surfaced by the SDK as `InvalidOperationException: gRPC StartQuery (raw) failed
|
||||
(errorLen=5)`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Implications for Phase 1 (the full amortization refactor)
|
||||
|
||||
A reuse pool is viable and high-value, with two requirements driven by §3:
|
||||
|
||||
1. **Keep sessions warm.** Ping each pooled session well under the ~20 s idle floor (e.g. a
|
||||
~10–15 s keepalive — a cheap handle-using op such as `GetSystemParameter`) so a steady-state
|
||||
session never crosses the idle timeout. Without a keepalive, amortization only helps within a
|
||||
<~20 s activity burst.
|
||||
2. **Reactive re-auth on expiry.** Treat `StartQuery failed (errorLen=5)` (and the equivalent on
|
||||
other handle ops) as an expired-session signal: evict the session and re-handshake on next use
|
||||
(one handshake penalty). In HistorianGateway this maps onto the existing
|
||||
`IHistorianConnectionPool.ReportFaulted` eviction seam.
|
||||
|
||||
**Concurrency note (unchanged guidance):** lease a session exclusively per-op from a bounded pool —
|
||||
this validity test only exercised *sequential* reuse, so concurrent use of one handle (esp.
|
||||
streaming cursors) remains unproven and should be avoided by exclusive leasing.
|
||||
|
||||
**Gate decision:** GREEN → HistorianGateway A1 Phase 1 (HistorianSession primitive + orchestrator
|
||||
acquire/execute split + re-vendor + leased-session pool with keepalive) is warranted and earns its
|
||||
own design + plan.
|
||||
|
||||
---
|
||||
|
||||
## 5. Write-spike addendum (Phase 1 Stage 0) — 2026-06-25
|
||||
|
||||
Extends the harness to the write path via the `RunWriteOnSession` seam on
|
||||
`HistorianGrpcHistoricalWriteOrchestrator`. Read + bounded writes to `HISTORIAN_WRITE_SANDBOX_TAG`
|
||||
only.
|
||||
|
||||
```
|
||||
reused-write[0] = 377 ms, ok=True
|
||||
reused-write[1] = 76 ms, ok=True # 2nd write reuses the same 0x401 session — no handshake
|
||||
read-on-0x401 -> OK (rows=3) # a WRITE-enabled session ALSO serves reads
|
||||
```
|
||||
|
||||
**Findings:**
|
||||
- **Write-session reuse — GREEN.** Two historical writes on one reused `0x401` (write-enabled)
|
||||
session both succeed; the 2nd skips the Create+handshake.
|
||||
- **One-kind pool — CONFIRMED.** A `0x401` session served a `StartQuery` read (`session.ClientHandle`)
|
||||
successfully. So a single **write-enabled** session serves both reads and writes — the gateway pool
|
||||
needs **one session kind**, not two. (`0x401` "unlocks write capability" and is a superset of the
|
||||
`0x402` read-only mode, as the vendored comment hinted.)
|
||||
|
||||
**Decision for Phase 1 Stage 3:** the gateway always opens `WriteEnabled` sessions; the
|
||||
`HistorianSessionPool` is a **single warm pool** (no per-kind keying). `HistorianSessionKind` still
|
||||
exists upstream for API clarity, but the gateway uses only `WriteEnabled`.
|
||||
@@ -0,0 +1,131 @@
|
||||
# Extended-property write over 2020 WCF — AddTEx (HCAL R1.11)
|
||||
|
||||
**Status: ✅ Add DONE + live-verified (2026-06-21). Delete (DelTep) deferred — see below.**
|
||||
`HistorianClient.AddTagExtendedPropertiesAsync` / `AddTagExtendedPropertyAsync` writes user-defined
|
||||
extended properties onto an existing tag via the 2020 WCF **`AddTEx`** (AddTagExtendedProperties) op,
|
||||
and they read back via the R1.5 `GetTagExtendedPropertiesAsync` path. Verified end-to-end from the
|
||||
pure-managed .NET 10 client against the local 2020 Historian (create tag → add property → read back →
|
||||
delete tag).
|
||||
|
||||
## The op
|
||||
|
||||
```
|
||||
bool AddTagExtendedProperties(string handle, byte[] inBuff, out byte[] errorBuffer) // AddTEx
|
||||
```
|
||||
|
||||
On `IHistoryServiceContract2` (History service). Requires a **write-enabled** connection (Open2 mode
|
||||
`0x401`) and the uppercase storage-session GUID handle — the SDK reuses the write orchestrator's
|
||||
open + priming chain (the same one used by EnsT2/DelT). The tag is referenced by name inside `inBuff`;
|
||||
no extra per-connection tag registration was needed (the server resolves it).
|
||||
|
||||
## The inBuff — the exact inverse of the R1.5 read response
|
||||
|
||||
The native `AddTagExtendedProperties(TagExtendedPropertyGroupList, out err)` packs its groups into the
|
||||
`AddTEx` `inBuff` with the **same framing the R1.5 `GetTepByNm` response uses**, so the write serializer
|
||||
is the inverse of `HistorianTagExtendedPropertyProtocol.ParseResponse`:
|
||||
|
||||
```
|
||||
uint32 groupCount (= 1)
|
||||
byte 0x01 (group marker)
|
||||
0x09 + uint16 byteLen + ASCII tagName (compact-ASCII string)
|
||||
uint32 propertyCount
|
||||
repeated propertyCount times:
|
||||
byte 0x02 (property marker)
|
||||
0x09 + uint16 byteLen + ASCII propertyName
|
||||
0x43 + uint16 payloadLen + uint16 charCount + UTF-16LE value (VT_BSTR variant; payloadLen = 2 + charCount*2)
|
||||
byte 0x01 (group trailer)
|
||||
byte 0x00 (buffer terminator)
|
||||
```
|
||||
|
||||
⚠️ **The trailing `0x01 0x00`** matters: the group trailer is `0x01` (as in the read parser) **plus a
|
||||
final `0x00` buffer terminator**. Omitting the `0x00` makes `inBuff` one byte short and the server throws
|
||||
`SErrorException` in `aahClientAccessPoint::CHistStorage::AddTagExtendedProperties` (AddTEx returns
|
||||
false). The read parser tolerates the extra byte because it only consumes one trailing byte per group.
|
||||
|
||||
Only the string (`0x43` VT_BSTR) value variant is evidence-backed (matching the read path). The raw
|
||||
instrument capture mangles the final byte with MDAS chunk markers, so the golden fixture pins the
|
||||
**clean** byte[] the SDK handed the channel (dumped via `AVEVA_HISTORIAN_TEP_DUMP`) — the exact buffer
|
||||
the live server accepted.
|
||||
|
||||
## Delete (DelTep) — wire format captured + serializer proven; live delete server-blocked
|
||||
|
||||
**Status (2026-06-21): the `DelTep` wire format is captured and decoded, the serializer is
|
||||
golden-verified against a server-accepted buffer, and SDK-added properties are confirmed deletable —
|
||||
but the SDK's own delete is rejected server-side and is therefore NOT exposed publicly.** This is a
|
||||
much deeper result than the earlier "couldn't capture the inBuff" deferral.
|
||||
|
||||
### Capturing it: the cross-session trick
|
||||
|
||||
The native `DeleteTagExtendedPropertiesByName(tag, propertyNames, deleteFromServer, out err)` performs
|
||||
a **client-side sync check** and returns error **229 ("Tag extended property not synchronized with
|
||||
server")** when deleting a *just-added* property — so a same-session add→delete never reaches the wire.
|
||||
`AddTEx` success does **not** mark the local cache entry as server-synchronized; only a *server fetch*
|
||||
(`GetTepByNm`) does. So the capture (`scripts/Capture-DeleteTagExtendedProperties.ps1`) runs two
|
||||
separate harness processes against one instrumented DLL:
|
||||
|
||||
- **Run A**: `add-tep` creates the sandbox tag and adds the property (now server-synced).
|
||||
- **Run B**: a fresh process opens a new connection, fetches the property
|
||||
(`GetTagExtendedPropertiesByName`, which seeds the local cache as synced), then deletes it — so
|
||||
`DeleteTagExtendedPropertiesByName` passes the client gate and `DelTep` reaches the wire.
|
||||
|
||||
### The inBuff — same group framing as Add, names only
|
||||
|
||||
```
|
||||
uint32 groupCount (= 1)
|
||||
byte 0x01 (group marker)
|
||||
0x09 + uint16 byteLen + ASCII tagName
|
||||
uint32 propertyCount
|
||||
repeated propertyCount times:
|
||||
byte 0x02 (property marker) + 0x09 + uint16 byteLen + ASCII propertyName ← NO value variant
|
||||
byte 0x00 (group trailer) ← 0x00 for delete, vs 0x01 for add
|
||||
byte 0x00 (buffer terminator)
|
||||
```
|
||||
|
||||
The native `deleteFromServer` argument is **not** in the buffer — it is the client-side flag that
|
||||
decides whether the wire op fires at all (`true` ⇒ a `DelTep` call). `HistorianTagExtendedProperty
|
||||
Protocol.SerializeDeleteRequest` produces this exactly; `WcfTagExtendedPropertyWriteProtocolTests`
|
||||
pins the server-accepted bytes.
|
||||
|
||||
### Why the SDK delete is server-blocked
|
||||
|
||||
The SDK's `DelTep` is rejected by the server with `SErrorException` in
|
||||
`aahClientAccessPoint::CHistStorage::DeleteTagExtendedProperties`, even though:
|
||||
|
||||
- the **inBuff is byte-identical** to the server-accepted native capture (golden-verified);
|
||||
- the **Open2 connection mode matches** the native (`0x401`, confirmed from the capture at offset
|
||||
0x4a4);
|
||||
- the **handles match** (uppercase storage-session GUID for `DelTep`/`GetTepByNm`, uint client handle
|
||||
for `GetTgByNm`);
|
||||
- the SDK first **primes the session** with `GetTgByNm` (tag identity, returns 140 bytes of tag info)
|
||||
and `GetTepByNm` (returns the property), keeping the Retrieval prime channel **open across** the
|
||||
`DelTep` call;
|
||||
- retried with backoff for **60 s** (ruling out a storage-tier sync delay).
|
||||
|
||||
A decisive experiment localizes the gap: an **SDK-added** property *is* deletable — the native client
|
||||
read-syncs and deletes it (`Success:true`). So the SDK's **add is complete**; only the SDK's **delete
|
||||
session** is the problem. The native client multiplexes Hist/Retr/Stat/Trx over **one connection**
|
||||
under a single `HistorianAccess` session, so its `GetTepByNm` populates a **per-connection working set**
|
||||
that the same-connection `DelTep` consults. The SDK uses **separate WCF channels per service** (the
|
||||
proven read pattern), so the borrowed-GUID Retrieval prime doesn't satisfy that server-side check.
|
||||
Reproducing it requires transport-level connection multiplexing — a substantial change beyond this op.
|
||||
|
||||
The investigated-but-blocked orchestration is kept (internal
|
||||
`HistorianWcfTagWriteOrchestrator.DeleteTagExtendedPropertiesAsync`, the `PrimeThenDelete` helper) for
|
||||
follow-up, but `HistorianClient` deliberately exposes **no** public delete to avoid a silently-failing
|
||||
write API. Capture/decode tooling: `scripts/Capture-DeleteTagExtendedProperties.ps1` +
|
||||
`scripts/decode-del-tep-capture.py`, harness `add-tep` scenario with `--tep-skip-add` / `--tep-delete`.
|
||||
|
||||
## Shipped surface
|
||||
|
||||
- `HistorianClient.AddTagExtendedPropertiesAsync(tag, IReadOnlyList<HistorianTagExtendedProperty>)` and
|
||||
`AddTagExtendedPropertyAsync(tag, name, value)`.
|
||||
- `HistorianTagExtendedPropertyProtocol.SerializeAddRequest` (the inBuff serializer; lives beside the
|
||||
R1.5 read parser); orchestrator path in `HistorianWcfTagWriteOrchestrator`.
|
||||
- Golden `WcfTagExtendedPropertyWriteProtocolTests` (pins the server-accepted buffer + layout); gated
|
||||
live test `AddTagExtendedPropertiesAsync_AgainstLocalHistorian_WritesAndReadsBack`.
|
||||
|
||||
## Capture / decode tooling
|
||||
|
||||
`scripts/Capture-AddTagExtendedProperties.ps1` (native-harness `add-tep` scenario +
|
||||
instrument-wcf-{write,read}message; sandbox-guarded create→add→[optional delete]) and
|
||||
`scripts/decode-add-tep-capture.py`.
|
||||
@@ -0,0 +1,75 @@
|
||||
# WCF event-read spike — live result (2026-06-25/26): transport+auth viable, row-retrieval server-gated
|
||||
|
||||
Settles the open question behind **C2** ("event reads over gRPC are gated; the only listed unblock is
|
||||
*route event reads via WCF*"). The gRPC event-read path is a proven server-side dead-end
|
||||
(`grpc-event-query-capture.md`: auth fully solved, every client-controllable layer byte-matched to the
|
||||
stock client, yet the server scopes 0 rows to our connection). This spike resolved the **WCF** leg.
|
||||
|
||||
> **Correction to an earlier draft of this doc.** A first pass concluded "the 2023 R2 historian does not
|
||||
> serve the legacy WCF transport (connection reset at framing)." **That was a test error, not a server
|
||||
> fact.** It connected to the historian's real WCF port `32568` *directly* and used the Windows-integrated
|
||||
> transport. In this environment the historian is reached through a **reverse SSH tunnel** (local
|
||||
> `42568` → historian `32568`), and integrated/Kerberos auth does not work through that tunnel. The
|
||||
> socket-RST was the tunnel/transport mismatch, not an absent listener. Corrected below.
|
||||
|
||||
## What was run
|
||||
|
||||
A Windows-only-by-default, env-gated diagnostic (`tests/AVEVA.Historian.Client.Tests/WcfEventReadSpikeTests.cs`)
|
||||
drives `HistorianWcfEventOrchestrator.ReadEventsAsync` directly. The decisive run was **cross-platform,
|
||||
direct** (no tunnel): from the VPN-holding host straight to the historian's real WCF endpoint
|
||||
`net.tcp://<historian>:32568/HistCert`, using the **certificate transport** (`RemoteTcpCertificate`,
|
||||
TLS, `AllowUntrustedServerCertificate`) and `NegotiateAuthentication` (cross-platform, explicit domain
|
||||
credentials). The SDK's interface-version gate was bypassed (`VerifyServerInterfaceVersion=false`) —
|
||||
the 2023 R2 WCF **History interface reports version 13** (this SDK's serializers target 11/12).
|
||||
|
||||
## Result — transport+auth viable; row-retrieval server-gated (sanitized)
|
||||
|
||||
Progression of the live errors as the addressing/transport was corrected:
|
||||
|
||||
| attempt | error |
|
||||
|---|---|
|
||||
| direct `:32568`, integrated | `SocketException` "forcibly closed" (wrong port + transport for the tunnel) |
|
||||
| tunnel `:42568`, integrated | `ProtocolException` at the security UpgradeResponse (integrated can't negotiate through the tunnel) |
|
||||
| tunnel `:42568`, certificate | reached the WCF dispatcher → `AddressFilter` mismatch (tunnel rewrites the port) |
|
||||
| **direct `:32568`, certificate, cross-platform** | **past auth** → `ProtocolEvidenceMissingException`: History interface version **13** |
|
||||
| + `VerifyServerInterfaceVersion=false` | **full chain runs**; query returns a 10-byte **0-row** header, then `GetNext` long-polls |
|
||||
|
||||
Connection-mode experiment (certificate transport, direct, version-bypassed, a 1-day window that holds
|
||||
events), comparing the native OpenConnection mode used for the event-read chain:
|
||||
|
||||
| connMode | RegisterTags (RTag2) | EnsureTags (EnsT2) | result buffer | events |
|
||||
|---|---|---|---|---|
|
||||
| `0x501` (event) | **0 — success** | 1 (benign-false, as in the 2020 flow) | 10 bytes (0-row header) | **0** |
|
||||
| `0x401` (write) | 1 (fail) | 1 | 10 bytes | 0 |
|
||||
| `0x402` (read-only, default) | 1 (fail) | 1 | 10 bytes | 0 |
|
||||
|
||||
## Conclusion
|
||||
|
||||
1. **WCF transport + auth ARE viable on 2023 R2.** The certificate (TLS) transport negotiates and the
|
||||
`NegotiateAuthentication` app-level handshake authenticates — **cross-platform** (proven from a
|
||||
non-Windows VPN host). The earlier "WCF not served" conclusion was wrong. (Integrated/Windows
|
||||
transport security is not usable through the reverse tunnel — `net.tcp` Kerberos does not tunnel.)
|
||||
2. **The event-read chain needs the `0x501` event connection mode.** With it, CM_EVENT `RegisterTags`
|
||||
**succeeds** (it fails on `0x402`/`0x401`). `EnsureTags` returns false, but that is documented as
|
||||
benign in the 2020 flow that *did* return rows.
|
||||
3. **Row retrieval is server-gated — same as gRPC.** Even with auth solved and `RegisterTags` succeeding,
|
||||
over a window that holds events, `StartEventQuery` succeeds but `GetNextEventQueryResultBuffer` returns
|
||||
a **0-row** header (10 bytes) and long-polls. Registration and window are ruled out as the cause; the
|
||||
server simply does not scope event rows to a managed connection. This is the **identical** server-side
|
||||
per-connection retrieval working-set gate proven for gRPC in `grpc-event-query-capture.md`.
|
||||
|
||||
**Therefore event reads do not return rows on the 2023 R2 historian over either transport** — gRPC
|
||||
(retrieval-server-gated) and WCF (transport+auth work, but the same server-side row gate). The only
|
||||
remaining theoretical unblock is server-side (AVEVA exposing event-row retrieval to a managed
|
||||
connection) — not client-fixable. **C2 stays closed won't-fix**, for this (corrected) reason.
|
||||
|
||||
## SDK additions from this investigation (retained, build-clean, golden where applicable)
|
||||
|
||||
- `HistorianClientOptions.ConnectViaAddress` — WCF `Via` (connect to a tunnel/proxy while addressing the
|
||||
SOAP `To` the real endpoint), so a port-forward whose local port differs from the server's real port
|
||||
satisfies the server-side WCF AddressFilter.
|
||||
- `HistorianClientOptions.EventReadConnectionModeOverride` — diagnostic override of the event-read
|
||||
OpenConnection mode (the `0x501` finding above).
|
||||
- The C2 spike is now transport-selectable (integrated|certificate), cross-platform for the cert
|
||||
transport, bounded (per-call timeout + overall budget with a phase-diagnostic dump), and version-gate
|
||||
bypassable. Output stays sanitized (counts, native return codes, buffer lengths, sha256).
|
||||
@@ -0,0 +1,86 @@
|
||||
# SQL command execution over 2020 WCF — ExeC + GetR (HCAL R1.1)
|
||||
|
||||
**Status: ✅ DONE + live-verified (2026-06-20).** `HistorianClient.ExecuteSqlCommandAsync(sql)` runs a
|
||||
SQL command against the Historian over the 2020 WCF ops `aa/Retr/ExeC` (ExecuteSqlCommand) +
|
||||
`aa/Retr/GetR` (GetRecordSetByteStream) and returns the record set as a `HistorianSqlResult` (the
|
||||
managed equivalent of the native `DataTable`). Live-verified end-to-end from the pure-managed .NET 10
|
||||
client against the local 2020 Historian (single-cell, multi-column/multi-row, and NULL cases).
|
||||
|
||||
## The ops
|
||||
|
||||
Both are on the Retrieval service (`IRetrievalServiceContract3`), **string-handle** ops reached with
|
||||
the Open2 storage-session GUID formatted `storageSessionId.ToString("D").ToUpperInvariant()` (the same
|
||||
uppercase handle that unlocked GETRP/GETHI; see `wcf-string-handle-wall.md`). `Retr.GetV` is primed
|
||||
first.
|
||||
|
||||
```
|
||||
bool ExecuteSqlCommand(string handle, string command, uint option,
|
||||
ref uint queryHandle, out int retValue, out uint errorSize, out byte[] errorBuffer)
|
||||
|
||||
bool GetRecordSetByteStream(string handle, uint queryHandle, ref uint sequence,
|
||||
out uint resultSize, out byte[] pResultBuff, out uint errorSize, out byte[] errorBuffer)
|
||||
```
|
||||
|
||||
- **`command`** is sent as a plain string (MDAS-encoded ASCII), e.g. `SELECT 1 AS ProbeValue`.
|
||||
- **`option`** = `HistorianSqlExecuteOption` (`ExecuteRecord=0` is the captured/proven record-set path).
|
||||
- **`ExeC`** returns the assigned `queryHandle` (and `retValue`); pass it to `GetR`.
|
||||
- **`GetR` returns `false` even on success** — the byte stream is in `pResultBuff` regardless; a
|
||||
`false` result just signals the final page. So the orchestrator always consumes `pResultBuff`, then
|
||||
stops on a `false` result or an empty page. (`sequence` is the paging cursor; small record sets
|
||||
return everything in one call.)
|
||||
|
||||
## The result stream — NRBF-wrapped DataTable (no BinaryFormatter)
|
||||
|
||||
`GetR`'s `pResultBuff` is a **.NET Remoting Binary Format (NRBF)** stream wrapping a
|
||||
`System.Data.DataTable` serialized with `SerializationFormat.Xml`. Stream shape (captured):
|
||||
|
||||
```
|
||||
SerializationHeader (00 01 00 00 00 FF FF FF FF 01 00 00 00 00 00 00 00)
|
||||
BinaryLibrary (0C): "System.Data, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"
|
||||
ClassWithMembersAndTypes (05): System.Data.DataTable, members:
|
||||
DataTable.RemotingVersion -> System.Version object
|
||||
XmlSchema -> BinaryObjectString (XSD: column names + xs types)
|
||||
XmlDiffGram -> BinaryObjectString (ADO.NET diffgram: the rows)
|
||||
System.Version object (_Major/_Minor/_Build/_Revision)
|
||||
MessageEnd (0B)
|
||||
```
|
||||
|
||||
**BinaryFormatter is removed from .NET 10**, so the stream is decoded read-only with
|
||||
`System.Formats.Nrbf.NrbfDecoder` (the sanctioned successor — it parses records without instantiating
|
||||
or executing any payload type; added as a managed `PackageReference`). The two embedded XML strings are
|
||||
then parsed with `XDocument`:
|
||||
|
||||
- **XmlSchema** → columns (name + XSD type) under the `msdata:MainDataTable` element's sequence.
|
||||
- **XmlDiffGram** → rows (each row element under the dataset; cells are child elements or attributes
|
||||
matching column names). NULL cells are simply absent → parsed as `null`.
|
||||
|
||||
Values are typed per the XSD type (`xs:int`→int, `xs:string`→string, `xs:dateTime`→DateTime, …),
|
||||
falling back to the raw string for unrecognized types. Only the `SerializationFormat.Xml` DataTable
|
||||
shape is evidence-backed; a stream whose root is not a DataTable class record, or that lacks the two
|
||||
XML members, throws `ProtocolEvidenceMissingException`.
|
||||
|
||||
## Capture / decode tooling
|
||||
|
||||
`scripts/Capture-ExecSql.ps1` (NativeTraceHarness `exec-sql` scenario + instrument-wcf-{write,read}message)
|
||||
captures the ExeC/GetR exchange. ⚠️ A **raw** instrument-wcf capture interleaves MDAS transport chunk
|
||||
markers (`0x9F`/`0x9E`) into a large `pResultBuff`, so raw byte-slicing yields a corrupted NRBF stream.
|
||||
The **clean** contract-level byte[] (what the WCF channel reassembles) is dumped via the
|
||||
`AVEVA_HISTORIAN_SQL_DUMP` env var on `HistorianWcfSqlClient` — that is the golden fixture in
|
||||
`WcfSqlResultProtocolTests` (the benign `SELECT 1 AS ProbeValue`, no sensitive data).
|
||||
|
||||
## Shipped surface
|
||||
|
||||
- `HistorianClient.ExecuteSqlCommandAsync(command, option = ExecuteRecord)` → `HistorianSqlResult`
|
||||
(`Columns` name+type, `Rows` typed values, `ReturnValue`).
|
||||
- Models `HistorianSqlResult` / `HistorianSqlColumn` / `HistorianSqlExecuteOption`;
|
||||
`HistorianSqlResultProtocol` (NRBF + diffgram parser); `HistorianWcfSqlClient` (ExeC/GetR
|
||||
orchestration); golden `WcfSqlResultProtocolTests`; gated live tests
|
||||
(`ExecuteSqlCommandAsync_AgainstLocalHistorian_ReturnsRecordSet` and `_MultiColumnMultiRow`).
|
||||
|
||||
## Scope notes
|
||||
|
||||
- `ExecuteRecord` (record set) is the evidence-backed path. `ExecuteNonQuery`/`ExecuteScalar`/
|
||||
`ExecuteRecordDirect` are accepted via the option enum but their non-record-set return shapes are not
|
||||
separately captured — a non-record result yields empty `Columns`/`Rows` with the `ReturnValue` set.
|
||||
- The command is whatever the Historian's SQL surface accepts (it routes to the Runtime DB). No
|
||||
client-side SQL validation is performed.
|
||||
@@ -0,0 +1,81 @@
|
||||
# GetHistorianInfo over 2020 WCF — GETHI is named-value-only (HCAL R1.4)
|
||||
|
||||
**Status: ⛔ Bounded out on BOTH 2020 WCF and 2023 R2 gRPC (2026-06-20; gRPC live-confirmed
|
||||
2026-06-21).** `GetHistorianInfoAsync` is **not shipped on any transport**: the one field that
|
||||
motivates it — `EventStorageMode` — is **not on the wire** on either transport (it lives only in
|
||||
the C++ HCAL's in-memory 518-byte struct, filled via a native vtable+648 call — see the §gRPC
|
||||
conclusion below). The version field GETHI *does* return is already exposed (`ProbeAsync`,
|
||||
`GetRuntimeParameterAsync("HistorianVersion")`), so there is nothing new to ship. Note: R1.3
|
||||
(`GetServerTimeZone`) — once paired with this as "2023R2-only" — **diverged**: it returns a real
|
||||
value over gRPC and **shipped** 2026-06-21 (`GetServerTimeZoneAsync`); R1.4 did not.
|
||||
|
||||
## What the capture showed
|
||||
|
||||
`scripts/Capture-HistorianInfo.ps1` drives the native `HistorianAccess.GetHistorianInfo(out
|
||||
HistorianInfo, out error)` through the instrumented (`instrument-wcf-{write,read}message`)
|
||||
`current/aahClientManaged.dll`. The native call **succeeds** and returns
|
||||
`EventStorageMode = Blocks`, `ServerVersion = 20,0,000,000`, no error.
|
||||
|
||||
But the wire tells a different story (`scripts/decode-historian-info-capture.py`):
|
||||
|
||||
- The only `GETHI` op on the wire is **`aa/Stat/GETHI(handle, pRequestBuff)`** with
|
||||
`pRequestBuff = 53 67 02 00` (sig `0x6753` + version `2`) `+ uint charCount(16) + UTF-16
|
||||
"HistorianVersion"` — i.e. the **named-value request**, identical to the GETRP/version shape.
|
||||
- Its response `pResponseBuff` is **~30 bytes**: `uint charCount(12) + UTF-16 "20,0,000,000"`
|
||||
(+ a `02 00 01 00` trailer). **Just the version** — not a 518-byte struct.
|
||||
- The post-GETHI ops in the same capture are `Hist/UpdC3` + a run of `Stat/GetSystemParameter`
|
||||
(`AllowOriginals`, `HistorianPartner`, `HistorianVersion`, `MaxCyclicStorageTimeout`,
|
||||
`RealTimeWindow`, `FutureTimeThreshold`, `AllowRenameTags`). **None carries a storage-mode
|
||||
value.** So the native wrapper's `EventStorageMode` is derived by the C++ HCAL **outside the
|
||||
WCF wire**, not fetched over it.
|
||||
|
||||
## Probe: does GETHI expose storage mode under any name?
|
||||
|
||||
`StringHandleProbeDiagnosticTests.GETHI_CandidateInfoNames_AgainstLocalHistorian` (gated on
|
||||
`HISTORIAN_HOST=localhost`) issues GETHI for `HistorianVersion` plus seven storage-mode name
|
||||
guesses. Result on the live 2020 server:
|
||||
|
||||
| GETHI parameter name | result |
|
||||
|---|---|
|
||||
| `HistorianVersion` | **ok=True**, respLen=32 (version) |
|
||||
| `EventStorageMode`, `EventStorageType`, `StorageType`, `HistorianEventStorageMode`, `EventStorage`, `StorageMode`, `HistorianInfo` | **ok=False**, errLen=5, empty |
|
||||
|
||||
So GETHI on 2020 WCF is a strict named-value lookup with exactly one known-good key
|
||||
(`HistorianVersion`). There is no storage-mode key, no full-struct request.
|
||||
|
||||
## Why the 518-byte struct doesn't apply here
|
||||
|
||||
The 2023 R2 decompiled `ArchestrA.HistorianAccess.GetHistorianInfo` (analysis folder) allocates
|
||||
a **518-byte `HISTORIAN_INFO`** struct, pre-inits `int32 @514` to `-1`, calls native HCAL
|
||||
(vtable+648) which fills it, then reads version (UTF-16 @0) + `EventStorageMode` (`@514`:
|
||||
`-1`=Unsupported, `0`=Database, else=Blocks). That is the **HCAL-native / 2023R2 gRPC**
|
||||
front-door model (`StatusService.GetHistorianInfo` returns `bytes btHistorianInfo`). On **2020
|
||||
WCF** that struct is never marshaled across the wire — only the version named-value is. The
|
||||
native client's `EventStorageMode` therefore comes from C++-internal state the managed WCF
|
||||
replay cannot observe or reproduce.
|
||||
|
||||
## Conclusion / where it lands
|
||||
|
||||
- **2020 WCF:** `GetHistorianInfoAsync` would add nothing over existing surface (version only) and
|
||||
could not report a real `EventStorageMode` — so it is intentionally **not shipped** (no hollow
|
||||
`Unsupported`-returning API; project discipline: don't ship misleading behavior).
|
||||
- **2023 R2 gRPC — LIVE-PROBED 2026-06-21, also bounded out.** The earlier expectation that
|
||||
`Status.GetHistorianInfo` returns the full 518-byte `btHistorianInfo` over gRPC was **wrong**. On
|
||||
the real 2023 R2 server (History iface 12), the gRPC `GetHistorianInfo` is the
|
||||
**same named-value query** as 2020 WCF: only `HistorianVersion` resolves (→ `"23,1,000,000"` +
|
||||
`02 00 01 00` trailer); `EventStorageMode` and seven name variants return `success=false` on
|
||||
**both** `GetHistorianInfo` and `GetSystemParameter`. The 518-byte struct is **not on the gRPC
|
||||
wire** — the 2023 R2 decompile confirms managed `HistorianAccess.GetHistorianInfo` fills it via a
|
||||
**native vtable+648 HCAL call** (`IClientCommon*` + offset 648), not the gRPC op, so
|
||||
`EventStorageMode` is derived inside the C++ HCAL outside the wire on gRPC exactly as on WCF.
|
||||
**Conclusion: `GetHistorianInfoAsync` is not shipped on any transport** (the only wire-reachable
|
||||
field, version, is already exposed). No `HistorianInfo` / `HistorianEventStorageMode` public type
|
||||
was added. Probe: the (now-deleted) `GrpcStatusInfoProbeTests`; raw dump under
|
||||
`artifacts/reverse-engineering/grpc-status-info-probe/` (gitignored).
|
||||
|
||||
## Tooling kept as RE aids
|
||||
|
||||
- `tools/AVEVA.Historian.NativeTraceHarness` `historian-info` scenario (drives the native call).
|
||||
- `scripts/Capture-HistorianInfo.ps1` + `scripts/decode-historian-info-capture.py`.
|
||||
- `StringHandleProbeDiagnosticTests.GETHI_CandidateInfoNames_AgainstLocalHistorian` (locks the
|
||||
named-value-only finding; gated).
|
||||
@@ -0,0 +1,67 @@
|
||||
# Non-analog tag create over 2020 WCF — GATED (HCAL R1.13)
|
||||
|
||||
**Status: ⛔ bounded out (2026-06-21). No non-analog (string / discrete / wide-integer) tag-create
|
||||
path is reachable on 2020 — the native managed client rejects every non-analog type *client-side*,
|
||||
before any WCF op, so there is no wire format to capture and nothing to implement against.**
|
||||
`EnsureTagAsync` stays analog-only (Float, Double, Int2, UInt2, Int4, UInt4); unsupported types throw
|
||||
`ProtocolEvidenceMissingException` from `HistorianTagWriteProtocol.GetAnalogDataTypeCode`.
|
||||
|
||||
## What R1.13 asked for
|
||||
|
||||
Create string / discrete (non-analog) tags via `History.EnsureTags`, with a distinct `CTagMetadata`
|
||||
variant. The roadmap flagged it "⚠ native AddTag rejected some types — confirm server path first;
|
||||
may be GATED."
|
||||
|
||||
## Findings (live-probed against the local 2020 Historian)
|
||||
|
||||
1. **No discrete/boolean data type exists.** The native `ArchestrA.HistorianDataType` enum
|
||||
(`current/aahClientManaged.dll`, dumped via `enum-dump`) has exactly 12 members: `Int1, Int2,
|
||||
UInt2, Int4, UInt4, Float, Double, SingleByteString, DoubleByteString, Event, Structure`. There is
|
||||
no `Discrete`/`Boolean`, and no `Int8`/`UInt8`/`UInt1`/`Guid`/`FileTime` (those are SDK-only
|
||||
extensions in `Models/HistorianDataType`, recovered from the C++ `CDataType` predicate IL — they
|
||||
are not settable on the managed `HistorianTag`).
|
||||
|
||||
2. **Tag type is data-type-derived, not separately settable.** `ArchestrA.HistorianTag`
|
||||
(`--dump-type-members`) has **no** `TagType` property — only `TagDataType`. It does carry the
|
||||
discrete/string-shaped fields (`MessageOn`/`MessageOff`, `RolloverValue`, dead-band/interpolation),
|
||||
and the type exposes `ValidateAnalog*`, `ValidateDiscreteGeneralProperties`, and
|
||||
`ValidateDiscreteAndStringStorageProperties` — but the analog-vs-discrete-vs-string decision is made
|
||||
internally from the data type, with no way to request "discrete."
|
||||
|
||||
3. **Native AddTag rejects every non-analog type client-side.** Driving the native
|
||||
`HistorianAccess.AddTag(HistorianTag, …)` (harness `write` scenario, `--write-data-type`) against
|
||||
the live server:
|
||||
|
||||
| Data type | AddTag.Success | ErrorCode | ErrorType |
|
||||
|---|---|---|---|
|
||||
| SingleByteString | **false** | ValidationFailed | CustomError |
|
||||
| DoubleByteString | **false** | ValidationFailed | CustomError |
|
||||
| Int1 | **false** | ValidationFailed | CustomError |
|
||||
| Int8 / UInt8 | n/a | *not in the native enum* | — |
|
||||
| Float (control) | true | Success | — |
|
||||
|
||||
The error — `ErrorType=CustomError`, `ErrorCode=ValidationFailed`, `ErrorDescription="Transaction
|
||||
validation failed"` — is raised by the client's own `Validate*` chain **before any WCF message is
|
||||
sent** (the wrapper even auto-populates discrete defaults `MessageOn=ON`/`MessageOff=OFF`, then
|
||||
fails validation). So the native client never emits a non-analog `EnsT2`/`AddTag` request.
|
||||
|
||||
## Why it's not deliverable here
|
||||
|
||||
Because the native client refuses non-analog types client-side, **no wire request exists to
|
||||
reverse-engineer** — there is no captured `CTagMetadata` variant for string or discrete tags, and the
|
||||
SDK does not guess wire bytes. The rejection is not string-specific: `Int1` (a non-string integer
|
||||
outside the analog set `{Float, Double, Int2, UInt2, Int4, UInt4}`) fails identically, so the boundary
|
||||
is "the analog set" rather than "strings only." Creating string/discrete tags on 2020 evidently goes
|
||||
through a different subsystem (e.g. the configuration editor / SQL config path), not this client's
|
||||
`AddTag`. R1.13 is closed as GATED, consistent with the mission note that these types "fail at native
|
||||
AddTag — likely require a different path and are intentionally not supported."
|
||||
|
||||
## Probe commands (read-only / sandbox-guarded)
|
||||
|
||||
```
|
||||
dotnet run --project tools\AVEVA.Historian.ReverseEngineering -- enum-dump current\aahClientManaged.dll HistorianDataType
|
||||
dotnet run --project tools\AVEVA.Historian.ReverseEngineering -- dnlib-method current\aahClientManaged.dll HistorianTag.ValidateDataType
|
||||
# native AddTag probe (sandbox tag must start with RetestSdkWrite; --write-skip-add-value avoids the blocked value path):
|
||||
dotnet run --project tools\AVEVA.Historian.NativeTraceHarness -- --scenario write --server-name localhost --tcp-port 32568 \
|
||||
--write-sandbox-tag RetestSdkWriteNADoubleByteString --write-data-type DoubleByteString --write-skip-add-value
|
||||
```
|
||||
@@ -0,0 +1,82 @@
|
||||
# Tag rename over 2020 WCF — StartJob (StJb) rename job (HCAL R1.10)
|
||||
|
||||
**Status: ✅ DONE + live-verified (2026-06-21).** `HistorianClient.RenameTagsAsync` /
|
||||
`RenameTagAsync` renames tags by submitting an asynchronous rename **job** to the Historian. Decoded
|
||||
from an instrumented native `RenameTags` capture and verified end-to-end from the pure-managed .NET 10
|
||||
client against the local 2020 Historian (sandbox tag created → renamed → new name visible → cleaned up).
|
||||
|
||||
## The op — rename rides the generic job framework
|
||||
|
||||
There is **no dedicated rename WCF operation**. The native `RenameTags(Tuple<string,string>[] pairs,
|
||||
ref HistorianTagRenameStatus, out error)` packs the batch into the generic History **`StartJob`**
|
||||
(`StJb`) buffer; the server returns a job id and applies the renames in the background. The native
|
||||
client then polls `GetJobStatus` (`GtJb`) until the job reports done.
|
||||
|
||||
```
|
||||
bool StartJob(string handle, byte[] jobBuffer, out string jobId, out byte[] errorBuffer) // StJb
|
||||
bool GetJobStatus(string handle, string jobId, out byte[] jobStatus, out byte[] errorBuffer) // GtJb
|
||||
```
|
||||
|
||||
Both already existed in `IHistoryServiceContract2`. `StartJob` takes a **string handle** = the Open2
|
||||
storage-session GUID formatted `storageSessionId.ToString("D").ToUpperInvariant()` (the same uppercase
|
||||
handle used by the other string-handle ops). The connection must be **write-enabled** (Open2 mode
|
||||
`0x401`); the SDK reuses the write orchestrator's open + priming chain.
|
||||
|
||||
## The rename jobBuffer (decoded + server-validated)
|
||||
|
||||
```
|
||||
byte[7] reserved / job-descriptor prefix (all zero in every capture)
|
||||
uint32 pairCount
|
||||
repeated pairCount times:
|
||||
uint32 oldNameCharCount + UTF-16LE oldName (the tag being renamed)
|
||||
uint32 newNameCharCount + UTF-16LE newName (its new name)
|
||||
```
|
||||
|
||||
Char counts are UTF-16 code-unit counts. Pair order is **(old, new)**. ⚠️ A **raw** instrument
|
||||
capture mangles the buffer's final byte with MDAS chunk markers (`9E`/`9F`) — the same hazard noted
|
||||
for R1.1. So the golden fixture is the **clean** byte[] the SDK hands the WCF channel, dumped via the
|
||||
`AVEVA_HISTORIAN_RENAME_DUMP` env hook on `HistorianWcfTagWriteOrchestrator`. That exact buffer was
|
||||
accepted by the live server and the tag was renamed, so it is server-validated, not hand-stitched.
|
||||
|
||||
## Server gate — `AllowRenameTags`
|
||||
|
||||
Rename is gated by the **`AllowRenameTags`** system parameter (default **0/disabled**). When disabled,
|
||||
the **native** client library rejects the call *before the wire* (`error 132 OperationNotEnabled`,
|
||||
component `aahClientCommon::CClientCommon::RenameTags`); the managed SDK has no such pre-check, so a
|
||||
disabled gate surfaces as `StartJob` returning false (reported as `Accepted = false`).
|
||||
|
||||
To enable for testing: `EXEC Runtime.dbo.aaSystemParameterUpdate @name='AllowRenameTags', @value=1`
|
||||
**and reload the Historian config** — the running services cache system parameters, so the value only
|
||||
takes effect after the Historian reloads (a Historian restart; a storage-engine-only restart is **not**
|
||||
enough — the value is served from the `InSQLConfiguration` cache). Restore to `0` when done.
|
||||
|
||||
## Async completion
|
||||
|
||||
`RenameTagsAsync` submits the job and returns `HistorianTagRenameResult { Accepted, JobId, PairCount,
|
||||
Error }`. The renames apply asynchronously server-side (observed: the native `GetTagRenameStatus` went
|
||||
`Pending=true` → `false` within ~1.5 s for a single rename on the local box). The SDK does **not** poll
|
||||
`GtJb` for completion: only the *pending* `jobstatus` buffer (6 zero bytes) was captured — the
|
||||
done-state encoding was not, so polling is intentionally left out rather than guessing it. The gated
|
||||
live test confirms completion by polling the new name's metadata after submission.
|
||||
|
||||
## Shipped surface
|
||||
|
||||
- `HistorianClient.RenameTagAsync(old, new)` / `RenameTagsAsync(IReadOnlyList<(string,string)>)`
|
||||
→ `HistorianTagRenameResult`.
|
||||
- `HistorianTagRenameProtocol.SerializeRenameJob` (the jobBuffer serializer);
|
||||
`HistorianWcfTagWriteOrchestrator.RenameTags`/`SendStartJobRename` (open → write-priming → StJb).
|
||||
- Golden `WcfTagRenameProtocolTests` (pins the server-accepted buffer + layout); gated live test
|
||||
`RenameTagsAsync_AgainstLocalHistorian_RenamesSandboxTag` (needs `HISTORIAN_HOST=localhost`,
|
||||
`HISTORIAN_RENAME_SANDBOX=RetestSdkWrite…`, and `AllowRenameTags` enabled).
|
||||
|
||||
## Capture / decode tooling
|
||||
|
||||
`scripts/Capture-RenameTags.ps1` (native-harness `rename` scenario + instrument-wcf-{write,read}message;
|
||||
sandbox-guarded create→rename→cleanup) and `scripts/decode-rename-capture.py`.
|
||||
|
||||
## Scope notes
|
||||
|
||||
- String-valued, original tag renames only. The multi-pair batch framing is captured (count-prefixed)
|
||||
and unit-tested; the live test exercises a single pair.
|
||||
- `RenameSourceTags` (replication/source-server rename) is **not** shipped — different op signature
|
||||
(adds a source-server string), not captured.
|
||||
@@ -48,3 +48,70 @@ Interpretation:
|
||||
- **`GetServerTimeZoneAsync` (roadmap R1.3) is NOT a trivial WCF op on 2020** — it
|
||||
is a stub returning empty. Do not ship it over the 2020 WCF transport. Deliver
|
||||
it only against a live 2023 R2 gRPC server. Reclassified in `docs/plans/hcal-roadmap.md`.
|
||||
|
||||
## GETRP / GetRuntimeParameter (roadmap R1.2) — DONE, live-verified 2026-06-20
|
||||
|
||||
Captured the native `HistorianAccess.GetRuntimeParameter(List<string>, out List<object>)`
|
||||
WCF traffic with `scripts/Capture-RuntimeParam.ps1` (instrument-wcf-{write,read}message).
|
||||
Findings:
|
||||
|
||||
- The WCF op is **`aa/Stat/GETRP`** — `bool GETRP(string handle, byte[] pRequestBuff,
|
||||
out byte[] pResponseBuff, out byte[] errorBuffer)`, i.e. the **same string-handle +
|
||||
request/response-buffer shape as GETHI**, *not* the simple `GetSystemParameter(uint, string)`
|
||||
shape the roadmap originally assumed.
|
||||
- The `string handle` is the **Open2 storage-session GUID** (the value
|
||||
`ParseOpenConnectionResponse` reads from `outBuff[5..21]`), sent **UPPERCASE, dash-separated,
|
||||
no braces** (`ToString("D").ToUpperInvariant()`).
|
||||
- Unlike GETHI (which the earlier probe found blocked), **GETRP succeeds from the pure-managed
|
||||
client** with that handle: `GetRuntimeParameter("HistorianVersion")` → `20,0,000,000`.
|
||||
- `pRequestBuff` = `54 67 01 00` (sig+version) + uint nameCount + per name(uint charCount +
|
||||
UTF-16LE). `pResponseBuff` = version(1) + uint resultCount + CRetVariant(`0x43` VT_BSTR +
|
||||
uint16 payloadLen + uint16 charCount + UTF-16LE).
|
||||
|
||||
Shipped as `HistorianClient.GetRuntimeParameterAsync(name)`. See
|
||||
`HistorianRuntimeParameterProtocol`, golden `WcfRuntimeParameterProtocolTests`, and the
|
||||
handle-format lead in `wcf-string-handle-wall.md` §Update (retry GETHI/ExeC uppercased).
|
||||
|
||||
## R1.3 timezone + R1.4 EventStorageMode — re-confirmed bounded out (2026-06-21)
|
||||
|
||||
Both were already classified 2023R2/gRPC-only; re-verified from two *fresh* angles that corroborate it
|
||||
more strongly than the original op-level probes:
|
||||
|
||||
- **Runtime DB schema** (`Runtime.dbo`, the server's own source of truth): the `SystemParameter` table
|
||||
has **no** timezone parameter and **no `EventStorageMode`** (only `EventStorageDuration` /
|
||||
`EventStorageLogPath`). The server timezone exists only as **per-block storage artifacts**
|
||||
(`HistoryBlock.TimeZoneOffset` = e.g. 240 min, `wwTimeZone` = e.g. "Eastern Daylight Time") and a
|
||||
`TimeZone` reference/lookup table; `StorageShard.TimeZoneId` is NULL. So the timezone is a
|
||||
DST-specific, SQL-only, OS-derived value, not a clean server-config field exposed by any op.
|
||||
- **Parameter-op probe** (`StringHandleProbeDiagnosticTests.TimezoneAndStorageMode_ParameterProbe`):
|
||||
`GetSystemParameter` and `GetRuntimeParameter` (GETRP) were asked for every timezone candidate
|
||||
(`TimeZone`/`ServerTimeZone`/`SystemTimeZone`/`TimeZoneName`/`SystemTimeZoneName`/`TimeStampRule`/
|
||||
`ServerTime`) and every storage-mode candidate (`EventStorageMode`/`StorageMode`/`EventStorage`/
|
||||
`EventStorageDuration`). **All returned null (GetSystemParameter) or threw
|
||||
`ProtocolEvidenceMissingException` (GETRP — non-string/empty response)**; only the `HistorianVersion`
|
||||
control returned a value (`20,0,000,000`). Note: `TimeStampRule`/`EventStorageDuration` *do* exist in
|
||||
the `SystemParameter` table yet `GetSystemParameterAsync` returns null for them — the shipped op only
|
||||
surfaces a whitelisted subset (a possible future widening, unrelated to R1.3/R1.4).
|
||||
|
||||
Conclusion: **R1.3 `GetServerTimeZoneAsync` and R1.4 `GetHistorianInfoAsync` (EventStorageMode) are not
|
||||
deliverable as server ops on 2020.** The only 2020 route to the timezone is a SQL read of
|
||||
`HistoryBlock`/`TimeZone` via `ExecuteSqlCommand` (R1.1) — a DST-specific value over a different
|
||||
mechanism than the roadmap's `Status.GetSystemTimeZoneName`. `EventStorageMode` has no 2020
|
||||
representation at all (it is a 2023 R2 event-storage-architecture field). Deliver both only against a
|
||||
live 2023 R2 gRPC server.
|
||||
|
||||
## Resolution against the live 2023 R2 gRPC server (2026-06-21) — the two diverged
|
||||
|
||||
Both ops were taken to the real 2023 R2 box (History iface 12) over the gRPC
|
||||
StatusService:
|
||||
|
||||
- **R1.3 `GetServerTimeZoneAsync` — SHIPPED.** `StatusService.GetSystemTimeZoneName(uiHandle)`
|
||||
returns the real Windows zone name **"Eastern Daylight Time"** (the 2020 stub returned empty).
|
||||
`HistorianClient.GetServerTimeZoneAsync` routes over `RemoteGrpc`; the non-gRPC transports throw
|
||||
`ProtocolEvidenceMissingException` (fail-closed, no empty-string lie). Golden message-shape +
|
||||
non-gRPC guardrail unit tests + gated live test.
|
||||
- **R1.4 `GetHistorianInfoAsync` (`EventStorageMode`) — bounded out on gRPC too.** Over gRPC,
|
||||
`GetHistorianInfo` is the **same named-value query** as 2020 WCF (only `HistorianVersion`
|
||||
resolves); `EventStorageMode` + 7 variants fail on both `GetHistorianInfo` and
|
||||
`GetSystemParameter`. The 518-byte struct is C++-HCAL-internal (native vtable+648), not on the
|
||||
wire. Not shipped on any transport. See `wcf-historian-info.md`.
|
||||
|
||||
@@ -1,10 +1,45 @@
|
||||
# The 2020 WCF string-handle wall (2026-06-20)
|
||||
|
||||
> ## ✅✅ RESOLVED (2026-06-20): the "wall" was a handle-FORMAT bug, not a registration wall.
|
||||
>
|
||||
> The string-handle ops are reachable from the pure-managed client after all. The Open2
|
||||
> storage-session GUID must be passed as the `string handle` **UPPERCASE, dash-separated,
|
||||
> no braces** — `storageSessionId.ToString("D").ToUpperInvariant()`. The earlier probes that
|
||||
> "proved" the wall passed the GUID in .NET's default **lowercase** `ToString("D")`, which the
|
||||
> server's session table does not match. Live-verified end-to-end against the local 2020 server:
|
||||
> - **GETRP** (R1.2) → returns the runtime `HistorianVersion` (shipped).
|
||||
> - **GETHI** (R1.4) → `returned=True`, returns the version buffer (`0C000000` + UTF-16 "20,0,000,000").
|
||||
> - **ExeC** (R1.1) → `returned=True`, `Retr.GetV` prime + `ExeC("SELECT 1 AS ProbeValue", option=0)`
|
||||
> yields `queryHandle`, then `GetR(handle, queryHandle, sequence=0)` returns a 1232-byte result =
|
||||
> a **BinaryFormatter-serialized .NET DataTable** (stream header `…System.Data, Version=4.0.0.0…`).
|
||||
>
|
||||
> Probes: gated `StringHandleProbeDiagnosticTests` (GETHI + ExeC). Captures:
|
||||
> `scripts/Capture-RuntimeParam.ps1`, `scripts/Capture-ExecSql.ps1`. The handle for ExeC/GetR is the
|
||||
> **same** Open2 storage-session GUID (confirmed = `outBuff[5..21]`). The original analysis below is
|
||||
> retained for history; treat its "blocked" conclusions as **superseded** — the only missing piece
|
||||
> was the uppercase format.
|
||||
>
|
||||
> **Update 2026-06-20 — R1.5 `GetTepByNm` shipped; QTB nuance.** `GetTagExtendedPropertiesFromName`
|
||||
> (`GetTepByNm`) is now **shipped + live-verified** with the uppercase handle
|
||||
> (`GetTagExtendedPropertiesAsync`; see `wcf-tag-extended-properties.md`). It confirms the
|
||||
> string-handle Retrieval family is reachable (and `GetTgByNm`/GetTagInfosFromName was observed
|
||||
> succeeding alongside it). **But not every string-handle op is just a format fix:** `QTB`
|
||||
> (`StartTagQuery`) was captured being sent with a correctly-**uppercase** handle and still failed
|
||||
> with `error 1` *server-side* (`CMdServer::StartTagQuery::StartActiveTagnamesQuery` over
|
||||
> `\\.\pipe\aahMetadataServer\console`). So QTB/QTG (the active-tagnames query family) are blocked by
|
||||
> the metadata server, not the handle format — distinct from the handle-format wall. **R1.6
|
||||
> (localized properties) has no distinct op** and collapses into R1.5.
|
||||
|
||||
---
|
||||
|
||||
Live-probing the local **Historian 2020** (WCF, port 32568) for HCAL roadmap M1
|
||||
surfaced a clean structural boundary on what the pure-managed client can call. It
|
||||
explains why R1.1/R1.4/R1.5 all fail and identifies the single RE target that
|
||||
unblocks the rest of the M1 read surface.
|
||||
|
||||
> ⚠️ **Superseded — see the RESOLVED banner above.** The boundary below is real *only* when the
|
||||
> handle is sent lowercase. With the uppercased storage GUID the string-handle ops succeed.
|
||||
|
||||
## The dichotomy
|
||||
|
||||
Retrieval/Status/History ops split by the **type of their first (handle) parameter**:
|
||||
@@ -56,3 +91,48 @@ once and the whole family unlocks. Until then, the alternatives are:
|
||||
|
||||
Do **not** ship any string-handle op via guessed calls (project discipline:
|
||||
"leave them throwing until evidence supports an implementation").
|
||||
|
||||
## ⚠️ Update (2026-06-20): GETRP punches through — the wall is not absolute
|
||||
|
||||
Roadmap **R1.2 `GetRuntimeParameterAsync`** turned out to be a **`string`-handle op**
|
||||
(`aa/Stat/GETRP(string handle, byte[] pRequestBuff) → (bool, byte[] pResponseBuff,
|
||||
byte[] errorBuffer)`) — the **same shape as GETHI**, and in the same native session it
|
||||
uses the **same handle GUID** as GETHI (confirmed: the GUID equals the Open2 `outBuff`
|
||||
storage-session id at `[5..21]`, the value the managed `ParseOpenConnectionResponse`
|
||||
already extracts as `StorageSessionId`).
|
||||
|
||||
Yet GETRP **works from the pure-managed client** — live-verified, returns the runtime
|
||||
`HistorianVersion` value `20,0,000,000`. The only material difference from the failed
|
||||
GETHI probe is the **handle string format**: the native client sends the GUID
|
||||
**UPPERCASE, dash-separated, no braces** (format example
|
||||
`XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX`, all hex upper), i.e.
|
||||
`storageSessionId.ToString("D").ToUpperInvariant()`. `.NET Guid.ToString("D")` is
|
||||
lowercase, so a probe that passed the GUID without upcasing would not byte-match what
|
||||
the server's session table is keyed on.
|
||||
|
||||
**Implication — CONFIRMED, the wall is largely a handle-format bug.** The follow-up was done:
|
||||
GETHI and **ExeC both return data with the uppercased storage-session GUID**.
|
||||
|
||||
- **R1.1 `ExecuteSqlCommandAsync` (ExeC + GetR) — SHIPPED + live-verified (2026-06-20).**
|
||||
`ExecuteSqlCommandAsync(sql)` → `HistorianSqlResult`. `Retr.GetV` prime → `ExeC(handle,
|
||||
sql, option=0, ref queryHandle)` → `GetR` loop. Note: **`GetR` returns false even on
|
||||
success** (the byte stream is in `pResultBuff` regardless; false = final page). `pResultBuff`
|
||||
is an **NRBF `DataTable`** (`SerializationFormat.Xml`: `XmlSchema` + `XmlDiffGram`), decoded
|
||||
read-only with `System.Formats.Nrbf` + `XDocument` (BinaryFormatter is gone from .NET 10).
|
||||
Shipped: `HistorianSqlResultProtocol`, `HistorianWcfSqlClient`, golden `WcfSqlResultProtocolTests`,
|
||||
gated live tests. See `docs/reverse-engineering/wcf-exec-sql.md`.
|
||||
- **GETHI (R1.4)** returns data with the uppercase handle, **but only the named `HistorianVersion`
|
||||
value** — over 2020 WCF GETHI is a named-value query (the only working key), *not* a full-struct
|
||||
read. `EventStorageMode` (the 518-byte-struct `@514` field) is **not on the 2020 WCF wire**; it is
|
||||
the 2023R2 HCAL-native/gRPC model. So R1.4 is **bounded out on WCF / gRPC-2023R2-only** and the
|
||||
public API is intentionally not shipped. Full analysis: `docs/reverse-engineering/wcf-historian-info.md`.
|
||||
|
||||
So the "wall" collapses to the handle **format** for the Retrieval/Status string-handle ops.
|
||||
**Exception — QTB/QTG:** `StartTagQuery` does *not* punch through; captured with a correctly
|
||||
uppercase handle it still fails `error 1` **server-side** (`CMdServer::StartActiveTagnamesQuery`
|
||||
over `\\.\pipe\aahMetadataServer\console`) — a metadata-server blocker, independent of handle
|
||||
format. Name-based ops route around it.
|
||||
|
||||
See `HistorianRuntimeParameterProtocol`, `IStatusServiceContract2.GetRuntimeParameter`,
|
||||
golden `WcfRuntimeParameterProtocolTests`, and capture tooling
|
||||
`scripts/Capture-RuntimeParam.ps1` + `scripts/decode-runtime-param-capture.py`.
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# Tag extended properties over 2020 WCF — GetTepByNm (HCAL R1.5)
|
||||
|
||||
**Status: ✅ DONE + live-verified (2026-06-20).** `HistorianClient.GetTagExtendedPropertiesAsync(tag)`
|
||||
reads a tag's extended (user-defined) properties over the 2020 WCF op
|
||||
`aa/Retr/GetTagExtendedPropertiesFromName` (`GetTepByNm`). Live-verified end-to-end from the
|
||||
pure-managed .NET 10 client against the local 2020 Historian.
|
||||
|
||||
## The op
|
||||
|
||||
`GetTepByNm` is on the Retrieval service (`IRetrievalServiceContract4`):
|
||||
|
||||
```
|
||||
bool GetTagExtendedPropertiesFromName(
|
||||
string handle, // Open2 storage-session GUID, UPPERCASE dash-no-braces
|
||||
byte[] tagNames, // [MessageParameter pRequestBuff-style]
|
||||
ref uint sequence, // paging cursor (0 on first call)
|
||||
out byte[] tagExtendedProperties, // result buffer
|
||||
out byte[] errorBuffer)
|
||||
```
|
||||
|
||||
It is a **string-handle** op — reachable from the managed client because the handle is the Open2
|
||||
storage-session GUID formatted `storageSessionId.ToString("D").ToUpperInvariant()` (the same handle
|
||||
format that unlocked GETRP/GETHI/ExeC; see `wcf-string-handle-wall.md`). The Retrieval service
|
||||
version handshake (`Retr.GetV`) is primed first, as the native client does.
|
||||
|
||||
## Why the name-based path (not the TagQuery path)
|
||||
|
||||
There are two managed entry points:
|
||||
|
||||
- **Index-based** `TagQuery.GetTagExtendedPropertyInfo(start, count, …)` — requires a prior
|
||||
`StartTagQuery` (`QTB`). On this 2020 box **QTB fails server-side** (`error 1` from
|
||||
`CMdServer::StartTagQuery::StartActiveTagnamesQuery` over `\\.\pipe\aahMetadataServer\console`),
|
||||
so this path is dead here regardless of handle format.
|
||||
- **Name-based** `HistorianAccess.GetTagExtendedPropertiesByName(tagName, fetchFromServer, …)` —
|
||||
issues `GetTepByNm` directly with the tag name in `tagNames`, no QTB needed. Its second arg
|
||||
forces a **server fetch** when true; when false the C++ client reads a local cache and returns
|
||||
`error 41 (Requested item not found)` without any WCF round-trip. The SDK reproduces the
|
||||
name-based path.
|
||||
|
||||
## Wire format (captured)
|
||||
|
||||
`scripts/Capture-TagExtendedProperties.ps1` (NativeTraceHarness `tag-extended-properties` scenario +
|
||||
instrument-wcf-{write,read}message) → decode with `scripts/decode-tag-properties-capture.py`.
|
||||
Golden-pinned in `WcfTagExtendedPropertyProtocolTests`.
|
||||
|
||||
### Request — `tagNames` buffer
|
||||
|
||||
```
|
||||
uint32 count
|
||||
per name: uint32 charCount + UTF-16LE chars
|
||||
```
|
||||
|
||||
(For one tag: `01 00 00 00` + `LL 00 00 00` + UTF-16 name.)
|
||||
|
||||
### Response — `tagExtendedProperties` buffer
|
||||
|
||||
```
|
||||
uint32 tagCount
|
||||
per tag:
|
||||
byte groupMarker (observed 0x01)
|
||||
0x09 + uint16 byteLen + ASCII tagName (compact-ASCII string)
|
||||
uint32 propertyCount
|
||||
per property:
|
||||
byte propMarker (observed 0x02 — likely the value type)
|
||||
0x09 + uint16 byteLen + ASCII propertyName
|
||||
0x43 + uint16 payloadLen + uint16 charCount + UTF-16LE value (VT_BSTR CRetVariant)
|
||||
byte trailingMarker (observed 0x01)
|
||||
```
|
||||
|
||||
`payloadLen` counts the `charCount` field (2 bytes) + the UTF-16 value bytes. Only the string value
|
||||
variant (`0x43`) is evidence-backed; other variant types throw `ProtocolEvidenceMissingException`
|
||||
(same discipline as GETRP). The single-byte `0x01`/`0x02`/`0x01` markers are pinned as observed
|
||||
constants from a single capture; their full semantics are not independently disambiguated.
|
||||
|
||||
Example (sanitized — real capture used a dev tag/value):
|
||||
|
||||
```
|
||||
tag "Reactor.Temp1" → property "Location" = "Plant/AreaA"
|
||||
```
|
||||
|
||||
### Paging
|
||||
|
||||
`GetTepByNm` is sequence-paged like `GetNextQueryResultBuffer`: call with `sequence = 0`, parse the
|
||||
buffer, then re-call with the returned `sequence`. A small result returns everything on the first
|
||||
call; the next call returns an empty/`nil` buffer (with a benign `CClientUtil::FillBufferFromVector`
|
||||
terminator) — that is the stop signal. The SDK loops until the buffer carries no rows.
|
||||
|
||||
## R1.6 (localized properties) — no distinct op on 2020
|
||||
|
||||
There is **no** `GetTagLocalizedPropertiesFromName` / `GetTlpByNm` op or
|
||||
`GetTagLocalizedPropertiesByName` method in `current/aahClientManaged.dll` — the only "localized"
|
||||
surfaces are `ClientApp.GetLocalizedText` and `SMessageTextMap.GetLocalizedMessage` (error-message /
|
||||
UI-text localization), not tag properties. So R1.6 **collapses into R1.5**: extended properties
|
||||
(`GetTepByNm`) are the user-defined tag-property read surface on 2020. R1.6 is closed as
|
||||
"no separate op," not left throwing.
|
||||
|
||||
## R1.12 (localized-property write) — no distinct op on 2020 (mirror of R1.6)
|
||||
|
||||
Symmetric to R1.6 on the write side: there is **no** `AddTagLocalizedProperties` /
|
||||
`DeleteTagLocalizedProperties` (or any `*LocalizedPropert*` / `TagLocalized*`) symbol in **any**
|
||||
`current/*.dll`. A full symbol sweep of the shipped client DLLs surfaces only `GetLocalizedText`,
|
||||
`GetLocalizedMessage`, and `LocalizedResourcesDir` — all UI/error-message-text localization, not tag
|
||||
data. (The sweep does find the real write op `AddTagExtendedProperties` and the whole `AddTag*`
|
||||
family, so the absence of a localized op is a true negative, not a grep miss.) R1.12 is therefore
|
||||
**closed as "no separate op"** — the same conclusion as R1.6's read side. Extended-property write
|
||||
(R1.11 `AddTEx`) is the user-defined tag-property write surface on 2020; localized properties are a
|
||||
2023 R2 / gRPC-only concept. Not left throwing.
|
||||
|
||||
## Shipped surface
|
||||
|
||||
- `HistorianClient.GetTagExtendedPropertiesAsync(tag)` → `IReadOnlyList<HistorianTagExtendedProperty>`
|
||||
(`Name`/`Value` pairs; empty when the tag has none).
|
||||
- `HistorianTagExtendedPropertyProtocol` (serializer/parser), `HistorianWcfTagExtendedPropertyClient`
|
||||
(orchestration), golden `WcfTagExtendedPropertyProtocolTests`, gated live
|
||||
`GetTagExtendedPropertiesAsync_AgainstLocalHistorian_ReturnsProperties` (set `HISTORIAN_TEP_TAG`
|
||||
to a tag with extended properties).
|
||||
@@ -0,0 +1,104 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Captures the native AVEVA client's AddTagExtendedProperties / DeleteTagExtendedProperties wire
|
||||
traffic (HCAL roadmap R1.11) so the AddTEx / DelTep inBuff layout can be decoded, not guessed.
|
||||
|
||||
.DESCRIPTION
|
||||
Drives the .NET-Framework NativeTraceHarness's `add-tep` scenario against the live Historian with
|
||||
an IL-rewritten copy of aahClientManaged.dll whose ClientMessageEncoder.WriteMessage AND
|
||||
ReadMessage are instrumented. The harness opens a WRITE-enabled connection, creates a sandbox tag
|
||||
(RetestSdkWrite...), and calls AddTagExtendedProperties(TagExtendedPropertyGroupList, out err) with
|
||||
one string property (and DeleteTagExtendedPropertiesByName when -Delete).
|
||||
|
||||
Decode with scripts/decode-add-tep-capture.py: the WCF.WriteMessage.Body whose op is AddTEx carries
|
||||
the inBuff (tag name + property name/value); DelTep carries the delete inBuff (tag + property names).
|
||||
|
||||
SAFETY: sandbox-guarded — the tag MUST start with 'RetestSdkWrite'. Default run leaves the tag +
|
||||
property in place (unless -Delete); pass -Delete to also capture DelTep and remove the property.
|
||||
|
||||
.NOTES
|
||||
Artifacts are diagnostic and gitignored. Sanitize before copying into docs/.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ServerName = "localhost",
|
||||
[int]$TcpPort = 32568,
|
||||
[string]$TepTag = "RetestSdkWriteTepTag",
|
||||
[string]$PropName = "SdkTestProp",
|
||||
[string]$PropValue = "SdkTestValue",
|
||||
[switch]$Delete,
|
||||
[string]$Configuration = "Debug"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
Set-Location $repoRoot
|
||||
if (-not $TepTag.StartsWith("RetestSdkWrite")) { throw "-TepTag must start with 'RetestSdkWrite' (sandbox guard)." }
|
||||
|
||||
$reProj = Join-Path $repoRoot "tools\AVEVA.Historian.ReverseEngineering\AVEVA.Historian.ReverseEngineering.csproj"
|
||||
$harnessProj = Join-Path $repoRoot "tools\AVEVA.Historian.NativeTraceHarness\AVEVA.Historian.NativeTraceHarness.csproj"
|
||||
$instrProj = Join-Path $repoRoot "tools\AVEVA.Historian.ReverseInstrumentation\AVEVA.Historian.ReverseInstrumentation.csproj"
|
||||
|
||||
$captureDir = Join-Path $repoRoot "artifacts\reverse-engineering\instrumented-wcf-add-tep"
|
||||
$currentCopy = Join-Path $captureDir "current-copy"
|
||||
$instrDll = Join-Path $captureDir "aahClientManaged.dll"
|
||||
$capturePath = Join-Path $captureDir "add-tep-capture-latest.ndjson"
|
||||
|
||||
Write-Host "== Building tooling ($Configuration) ==" -ForegroundColor Cyan
|
||||
dotnet build $reProj -c $Configuration --nologo -v q | Out-Null
|
||||
dotnet build $instrProj -c $Configuration --nologo -v q | Out-Null
|
||||
dotnet build $harnessProj -c $Configuration --nologo -v q | Out-Null
|
||||
|
||||
$instrSourceDll = Get-ChildItem -Recurse (Join-Path $repoRoot "tools\AVEVA.Historian.ReverseInstrumentation\bin\$Configuration") `
|
||||
-Filter "AVEVA.Historian.ReverseInstrumentation.dll" | Select-Object -First 1 -ExpandProperty FullName
|
||||
if (-not $instrSourceDll) { throw "ReverseInstrumentation.dll not found under bin\$Configuration." }
|
||||
|
||||
Write-Host "== Instrumenting WriteMessage + ReadMessage ==" -ForegroundColor Cyan
|
||||
New-Item -ItemType Directory -Force -Path $captureDir | Out-Null
|
||||
$writeOnly = Join-Path $captureDir "aahClientManaged.write.dll"
|
||||
dotnet run --no-build -c $Configuration --project $reProj -- `
|
||||
instrument-wcf-writemessage (Join-Path $repoRoot "current\aahClientManaged.dll") $writeOnly | Out-Null
|
||||
dotnet run --no-build -c $Configuration --project $reProj -- `
|
||||
instrument-wcf-readmessage $writeOnly $instrDll | Out-Null
|
||||
|
||||
Write-Host "== Staging current-copy ==" -ForegroundColor Cyan
|
||||
robocopy (Join-Path $repoRoot "current") $currentCopy /MIR /NJH /NJS /NDL /NP /NC /NS | Out-Null
|
||||
Copy-Item -Force $instrDll (Join-Path $currentCopy "aahClientManaged.dll")
|
||||
Copy-Item -Force $instrSourceDll (Join-Path $currentCopy "AVEVA.Historian.ReverseInstrumentation.dll")
|
||||
|
||||
$harnessDll = Join-Path $currentCopy "aahClientManaged.dll"
|
||||
if (Test-Path $capturePath) { Remove-Item -Force $capturePath }
|
||||
$env:AVEVA_HISTORIAN_RE_CAPTURE = $capturePath
|
||||
|
||||
Write-Host "== Capturing add-tep ($TepTag : $PropName=$PropValue) ==" -ForegroundColor Green
|
||||
$harnessArgs = @(
|
||||
"--scenario", "add-tep",
|
||||
"--server-name", $ServerName,
|
||||
"--tcp-port", "$TcpPort",
|
||||
"--tep-tag", $TepTag,
|
||||
"--tep-name", $PropName,
|
||||
"--tep-value", $PropValue,
|
||||
"--current-dir", $currentCopy,
|
||||
"--managed-dll-path", $harnessDll
|
||||
)
|
||||
if ($Delete) { $harnessArgs += "--tep-delete" }
|
||||
|
||||
$harnessJson = $null
|
||||
try {
|
||||
$prevEap = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
$harnessJson = & dotnet run --no-build -c $Configuration --project $harnessProj -- @harnessArgs 2>&1
|
||||
} catch {
|
||||
Write-Host " (add-tep raised: $($_.Exception.Message))" -ForegroundColor Yellow
|
||||
} finally {
|
||||
$ErrorActionPreference = $prevEap
|
||||
}
|
||||
|
||||
Remove-Item Env:\AVEVA_HISTORIAN_RE_CAPTURE -ErrorAction SilentlyContinue
|
||||
|
||||
$recCount = if (Test-Path $capturePath) { (Get-Content $capturePath | Where-Object { $_.Trim() }).Count } else { 0 }
|
||||
Write-Host "`n== Capture summary ==" -ForegroundColor Cyan
|
||||
Write-Host " -> $recCount records -> $capturePath"
|
||||
Write-Host "Harness output (AddTagExtendedProperties / Rows):" -ForegroundColor Cyan
|
||||
$harnessJson | Select-Object -Last 24
|
||||
Write-Host "`nDecode with: python scripts\decode-add-tep-capture.py" -ForegroundColor Cyan
|
||||
@@ -0,0 +1,111 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Captures the native AVEVA client's DeleteTagExtendedProperties (DelTep) wire traffic (HCAL R1.11
|
||||
delete half) using the CROSS-SESSION trick so the delete passes the client-side sync gate.
|
||||
|
||||
.DESCRIPTION
|
||||
DeleteTagExtendedPropertiesByName does a CLIENT-SIDE sync check and returns err 229 ("Tag extended
|
||||
property not synchronized with server") for any property the local cache doesn't see as
|
||||
server-synchronized — so a just-added property can't be deleted in the same session and its DelTep
|
||||
inBuff never reaches the wire. This script captures it in two SEPARATE harness processes (= two
|
||||
sessions) against one instrumented aahClientManaged.dll:
|
||||
|
||||
Run A: add-tep (create sandbox tag + AddTagExtendedProperties) -> property now server-synced
|
||||
Run B: add-tep --tep-skip-create --tep-skip-add --tep-delete -> fresh connection: fetch the
|
||||
property from the server (seeds the local cache as SYNCED), then
|
||||
DeleteTagExtendedPropertiesByName, which now reaches the wire as DelTep.
|
||||
|
||||
The capture file is cleared BETWEEN the two runs so it contains only Run B (the GetTepByNm
|
||||
read-for-sync + the DelTep delete). Decode with scripts/decode-del-tep-capture.py.
|
||||
|
||||
SAFETY: sandbox-guarded — the tag MUST start with 'RetestSdkWrite'. The run leaves the sandbox tag
|
||||
in place (property removed); delete the tag afterwards with the supported aaDeleteTag proc.
|
||||
|
||||
.NOTES
|
||||
Artifacts are diagnostic and gitignored. Sanitize before copying into docs/.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ServerName = "localhost",
|
||||
[int]$TcpPort = 32568,
|
||||
[string]$TepTag = "RetestSdkWriteDelTepSdk",
|
||||
[string]$PropName = "SdkDelProp",
|
||||
[string]$PropValue = "SdkDelValue",
|
||||
[string]$Configuration = "Debug"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
Set-Location $repoRoot
|
||||
if (-not $TepTag.StartsWith("RetestSdkWrite")) { throw "-TepTag must start with 'RetestSdkWrite' (sandbox guard)." }
|
||||
|
||||
$reProj = Join-Path $repoRoot "tools\AVEVA.Historian.ReverseEngineering\AVEVA.Historian.ReverseEngineering.csproj"
|
||||
$harnessProj = Join-Path $repoRoot "tools\AVEVA.Historian.NativeTraceHarness\AVEVA.Historian.NativeTraceHarness.csproj"
|
||||
$instrProj = Join-Path $repoRoot "tools\AVEVA.Historian.ReverseInstrumentation\AVEVA.Historian.ReverseInstrumentation.csproj"
|
||||
|
||||
$captureDir = Join-Path $repoRoot "artifacts\reverse-engineering\instrumented-wcf-del-tep"
|
||||
$currentCopy = Join-Path $captureDir "current-copy"
|
||||
$instrDll = Join-Path $captureDir "aahClientManaged.dll"
|
||||
$capturePath = Join-Path $captureDir "del-tep-capture-latest.ndjson"
|
||||
|
||||
Write-Host "== Building tooling ($Configuration) ==" -ForegroundColor Cyan
|
||||
dotnet build $reProj -c $Configuration --nologo -v q | Out-Null
|
||||
dotnet build $instrProj -c $Configuration --nologo -v q | Out-Null
|
||||
dotnet build $harnessProj -c $Configuration --nologo -v q | Out-Null
|
||||
|
||||
$instrSourceDll = Get-ChildItem -Recurse (Join-Path $repoRoot "tools\AVEVA.Historian.ReverseInstrumentation\bin\$Configuration") `
|
||||
-Filter "AVEVA.Historian.ReverseInstrumentation.dll" | Select-Object -First 1 -ExpandProperty FullName
|
||||
if (-not $instrSourceDll) { throw "ReverseInstrumentation.dll not found under bin\$Configuration." }
|
||||
|
||||
Write-Host "== Instrumenting WriteMessage + ReadMessage ==" -ForegroundColor Cyan
|
||||
New-Item -ItemType Directory -Force -Path $captureDir | Out-Null
|
||||
$writeOnly = Join-Path $captureDir "aahClientManaged.write.dll"
|
||||
dotnet run --no-build -c $Configuration --project $reProj -- `
|
||||
instrument-wcf-writemessage (Join-Path $repoRoot "current\aahClientManaged.dll") $writeOnly | Out-Null
|
||||
dotnet run --no-build -c $Configuration --project $reProj -- `
|
||||
instrument-wcf-readmessage $writeOnly $instrDll | Out-Null
|
||||
|
||||
Write-Host "== Staging current-copy ==" -ForegroundColor Cyan
|
||||
robocopy (Join-Path $repoRoot "current") $currentCopy /MIR /NJH /NJS /NDL /NP /NC /NS | Out-Null
|
||||
Copy-Item -Force $instrDll (Join-Path $currentCopy "aahClientManaged.dll")
|
||||
Copy-Item -Force $instrSourceDll (Join-Path $currentCopy "AVEVA.Historian.ReverseInstrumentation.dll")
|
||||
|
||||
$harnessDll = Join-Path $currentCopy "aahClientManaged.dll"
|
||||
$env:AVEVA_HISTORIAN_RE_CAPTURE = $capturePath
|
||||
|
||||
function Invoke-Harness([string[]]$extraArgs, [string]$label) {
|
||||
Write-Host "== $label ==" -ForegroundColor Green
|
||||
$harnessArgs = @(
|
||||
"--scenario", "add-tep",
|
||||
"--server-name", $ServerName,
|
||||
"--tcp-port", "$TcpPort",
|
||||
"--tep-tag", $TepTag,
|
||||
"--tep-name", $PropName,
|
||||
"--tep-value", $PropValue,
|
||||
"--current-dir", $currentCopy,
|
||||
"--managed-dll-path", $harnessDll
|
||||
) + $extraArgs
|
||||
$json = $null
|
||||
try {
|
||||
$prevEap = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
$json = & dotnet run --no-build -c $Configuration --project $harnessProj -- @harnessArgs 2>&1
|
||||
} finally { $ErrorActionPreference = $prevEap }
|
||||
$json | Select-Object -Last 20
|
||||
}
|
||||
|
||||
# Run A: create the sandbox tag + add the property (server-synced afterwards).
|
||||
Invoke-Harness @() "Run A: create + AddTagExtendedProperties ($TepTag : $PropName=$PropValue)"
|
||||
|
||||
# Clear the capture so the file contains only Run B (read-for-sync + DelTep).
|
||||
if (Test-Path $capturePath) { Remove-Item -Force $capturePath }
|
||||
|
||||
# Run B: FRESH session — fetch (sync the local cache) then DeleteTagExtendedPropertiesByName.
|
||||
Invoke-Harness @("--tep-skip-create", "--tep-skip-add", "--tep-delete") "Run B: fresh session -> read-for-sync -> DelTep"
|
||||
|
||||
Remove-Item Env:\AVEVA_HISTORIAN_RE_CAPTURE -ErrorAction SilentlyContinue
|
||||
|
||||
$recCount = if (Test-Path $capturePath) { (Get-Content $capturePath | Where-Object { $_.Trim() }).Count } else { 0 }
|
||||
Write-Host "`n== Capture summary (Run B only) ==" -ForegroundColor Cyan
|
||||
Write-Host " -> $recCount records -> $capturePath"
|
||||
Write-Host "`nDecode with: python scripts\decode-del-tep-capture.py" -ForegroundColor Cyan
|
||||
@@ -0,0 +1,101 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Captures the native client's StartEventQuery request bytes WITH and WITHOUT an event filter
|
||||
(HCAL roadmap R1.7) so the filter-predicate encoding can be decoded against the empty-filter
|
||||
baseline instead of guessed.
|
||||
|
||||
.DESCRIPTION
|
||||
Drives the NativeTraceHarness `event` scenario against the live Historian under an
|
||||
IL-rewritten aahClientManaged.dll whose ClientMessageEncoder.WriteMessage is instrumented to
|
||||
log every outgoing MDAS body. Runs twice:
|
||||
- baseline : no filter (the known empty-filter StartEventQuery)
|
||||
- filtered : EventQuery.AddEventFilter("Area", Equal, "RetestFilterArea") before StartQuery
|
||||
|
||||
Diff the two StartEventQuery request buffers (scripts/decode-event-filter-capture.py) to read
|
||||
off the exact filter-block bytes (property name / comparison op / value) the native client
|
||||
emits, then implement the managed predicate against that.
|
||||
|
||||
.NOTES
|
||||
Artifacts are diagnostic and gitignored. Sanitize before copying into docs/. Never commit raw
|
||||
capture NDJSON, credentials, hostnames, or customer tag names.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ServerName = "localhost",
|
||||
[int]$TcpPort = 32568,
|
||||
[int]$LookbackMinutes = 43200,
|
||||
# Property:Op:Value (Op = a HistorianComparisionType name, e.g. Equal/Contains/GreaterThan).
|
||||
[string]$Filter = "Area:Equal:RetestFilterArea",
|
||||
[string]$Configuration = "Debug"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
Set-Location $repoRoot
|
||||
|
||||
$reProj = Join-Path $repoRoot "tools\AVEVA.Historian.ReverseEngineering\AVEVA.Historian.ReverseEngineering.csproj"
|
||||
$harnessProj = Join-Path $repoRoot "tools\AVEVA.Historian.NativeTraceHarness\AVEVA.Historian.NativeTraceHarness.csproj"
|
||||
$instrProj = Join-Path $repoRoot "tools\AVEVA.Historian.ReverseInstrumentation\AVEVA.Historian.ReverseInstrumentation.csproj"
|
||||
|
||||
$captureDir = Join-Path $repoRoot "artifacts\reverse-engineering\instrumented-wcf-event-filter"
|
||||
$currentCopy = Join-Path $captureDir "current-copy"
|
||||
$instrDll = Join-Path $captureDir "aahClientManaged.dll"
|
||||
|
||||
Write-Host "== Building tooling ($Configuration) ==" -ForegroundColor Cyan
|
||||
dotnet build $reProj -c $Configuration --nologo -v q | Out-Null
|
||||
dotnet build $instrProj -c $Configuration --nologo -v q | Out-Null
|
||||
dotnet build $harnessProj -c $Configuration --nologo -v q | Out-Null
|
||||
|
||||
$instrSourceDll = Get-ChildItem -Recurse (Join-Path $repoRoot "tools\AVEVA.Historian.ReverseInstrumentation\bin\$Configuration") `
|
||||
-Filter "AVEVA.Historian.ReverseInstrumentation.dll" | Select-Object -First 1 -ExpandProperty FullName
|
||||
if (-not $instrSourceDll) { throw "ReverseInstrumentation.dll not found under bin\$Configuration." }
|
||||
|
||||
Write-Host "== Instrumenting WriteMessage ==" -ForegroundColor Cyan
|
||||
New-Item -ItemType Directory -Force -Path $captureDir | Out-Null
|
||||
dotnet run --no-build -c $Configuration --project $reProj -- `
|
||||
instrument-wcf-writemessage (Join-Path $repoRoot "current\aahClientManaged.dll") $instrDll | Out-Null
|
||||
|
||||
Write-Host "== Staging current-copy ==" -ForegroundColor Cyan
|
||||
robocopy (Join-Path $repoRoot "current") $currentCopy /MIR /NJH /NJS /NDL /NP /NC /NS | Out-Null
|
||||
Copy-Item -Force $instrDll (Join-Path $currentCopy "aahClientManaged.dll")
|
||||
Copy-Item -Force $instrSourceDll (Join-Path $currentCopy "AVEVA.Historian.ReverseInstrumentation.dll")
|
||||
|
||||
$harnessDll = Join-Path $currentCopy "aahClientManaged.dll"
|
||||
|
||||
$matrix = @(
|
||||
@{ Name = "baseline"; Args = @() },
|
||||
@{ Name = "filtered"; Args = @("--event-filter", $Filter) }
|
||||
)
|
||||
|
||||
foreach ($cfg in $matrix) {
|
||||
$capturePath = Join-Path $captureDir "event-filter-capture-$($cfg.Name)-latest.ndjson"
|
||||
if (Test-Path $capturePath) { Remove-Item -Force $capturePath }
|
||||
$env:AVEVA_HISTORIAN_RE_CAPTURE = $capturePath
|
||||
|
||||
Write-Host "== Capturing: $($cfg.Name) ==" -ForegroundColor Green
|
||||
$harnessArgs = @(
|
||||
"--scenario", "event",
|
||||
"--server-name", $ServerName,
|
||||
"--tcp-port", "$TcpPort",
|
||||
"--lookback-minutes", "$LookbackMinutes",
|
||||
"--max-rows", "1",
|
||||
"--current-dir", $currentCopy,
|
||||
"--managed-dll-path", $harnessDll
|
||||
) + $cfg.Args
|
||||
|
||||
try {
|
||||
$prevEap = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
& dotnet run --no-build -c $Configuration --project $harnessProj -- @harnessArgs 2>&1 | Out-Null
|
||||
} catch {
|
||||
Write-Host " ($($cfg.Name) raised: $($_.Exception.Message))" -ForegroundColor Yellow
|
||||
} finally {
|
||||
$ErrorActionPreference = $prevEap
|
||||
}
|
||||
|
||||
$recCount = if (Test-Path $capturePath) { (Get-Content $capturePath | Where-Object { $_.Trim() }).Count } else { 0 }
|
||||
Write-Host " -> $recCount records -> $capturePath"
|
||||
}
|
||||
|
||||
Remove-Item Env:\AVEVA_HISTORIAN_RE_CAPTURE -ErrorAction SilentlyContinue
|
||||
Write-Host "`nDecode with: python scripts\decode-event-filter-capture.py" -ForegroundColor Cyan
|
||||
@@ -0,0 +1,110 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Captures the native AVEVA client's event-SEND wire traffic (HCAL roadmap R2.1) to
|
||||
determine whether AddStreamedValue(HistorianEvent) rides the WCF MDAS path (capturable
|
||||
+ implementable as a pure-managed-WCF SDK op) or the storage-engine shared-memory pipe
|
||||
(like revision writes — which would block M2 as a WCF SDK).
|
||||
|
||||
.DESCRIPTION
|
||||
Drives the .NET-Framework NativeTraceHarness's `event-send` scenario against the live
|
||||
Historian with an IL-rewritten copy of aahClientManaged.dll whose
|
||||
ClientMessageEncoder.WriteMessage AND ReadMessage are instrumented to log every MDAS
|
||||
body (the same pipeline that produced every other proven request/response shape). The
|
||||
harness opens an Event connection (ReadOnly=false), builds a clearly-marked test
|
||||
HistorianEvent, calls AddStreamedValue(HistorianEvent), then CloseStorageConnection to
|
||||
flush the queued event onto the wire.
|
||||
|
||||
Decode with scripts/decode-event-send-capture.py: if a StartStorage/AddStreamValues/
|
||||
EnqueueEventDataPacket body appears on WCF.WriteMessage.Body, M2 is viable over WCF and
|
||||
the body carries the PackToVtq event value blob to decode (R2.2). If NOTHING event-shaped
|
||||
appears on the WCF path even though the native AddStreamedValue returned success, the
|
||||
delivery used the storage-engine pipe and M2 is architecturally blocked over WCF — the
|
||||
same conclusion as the revision-write path (docs/plans/revision-write-path.md).
|
||||
|
||||
.NOTES
|
||||
Writes a real (clearly-marked) test event into the historian's event history. Artifacts
|
||||
are diagnostic and gitignored. Sanitize before copying anything into docs/ — never commit
|
||||
raw capture NDJSON, credentials, hostnames, or customer tag names.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ServerName = "localhost",
|
||||
[int]$TcpPort = 32568,
|
||||
[string]$EventType = "User.Write",
|
||||
[int]$FlushSeconds = 6,
|
||||
[string]$Configuration = "Debug"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
Set-Location $repoRoot
|
||||
|
||||
$reProj = Join-Path $repoRoot "tools\AVEVA.Historian.ReverseEngineering\AVEVA.Historian.ReverseEngineering.csproj"
|
||||
$harnessProj = Join-Path $repoRoot "tools\AVEVA.Historian.NativeTraceHarness\AVEVA.Historian.NativeTraceHarness.csproj"
|
||||
$instrProj = Join-Path $repoRoot "tools\AVEVA.Historian.ReverseInstrumentation\AVEVA.Historian.ReverseInstrumentation.csproj"
|
||||
|
||||
$captureDir = Join-Path $repoRoot "artifacts\reverse-engineering\instrumented-wcf-event-send"
|
||||
$currentCopy = Join-Path $captureDir "current-copy"
|
||||
$instrDll = Join-Path $captureDir "aahClientManaged.dll"
|
||||
$capturePath = Join-Path $captureDir "event-send-capture-latest.ndjson"
|
||||
|
||||
Write-Host "== Building tooling ($Configuration) ==" -ForegroundColor Cyan
|
||||
dotnet build $reProj -c $Configuration --nologo -v q | Out-Null
|
||||
dotnet build $instrProj -c $Configuration --nologo -v q | Out-Null
|
||||
dotnet build $harnessProj -c $Configuration --nologo -v q | Out-Null
|
||||
|
||||
$instrSourceDll = Get-ChildItem -Recurse (Join-Path $repoRoot "tools\AVEVA.Historian.ReverseInstrumentation\bin\$Configuration") `
|
||||
-Filter "AVEVA.Historian.ReverseInstrumentation.dll" | Select-Object -First 1 -ExpandProperty FullName
|
||||
if (-not $instrSourceDll) { throw "ReverseInstrumentation.dll not found under bin\$Configuration." }
|
||||
|
||||
Write-Host "== Instrumenting WriteMessage + ReadMessage ==" -ForegroundColor Cyan
|
||||
New-Item -ItemType Directory -Force -Path $captureDir | Out-Null
|
||||
# Chain via a distinct intermediate file (reading+writing the same path drops the second
|
||||
# hook on the mixed-mode native image). Final dll carries both hooks with distinct Phase
|
||||
# strings: WCF.WriteMessage.Body and WCF.ReadMessage.Body.
|
||||
$writeOnly = Join-Path $captureDir "aahClientManaged.write.dll"
|
||||
dotnet run --no-build -c $Configuration --project $reProj -- `
|
||||
instrument-wcf-writemessage (Join-Path $repoRoot "current\aahClientManaged.dll") $writeOnly | Out-Null
|
||||
dotnet run --no-build -c $Configuration --project $reProj -- `
|
||||
instrument-wcf-readmessage $writeOnly $instrDll | Out-Null
|
||||
|
||||
Write-Host "== Staging current-copy ==" -ForegroundColor Cyan
|
||||
robocopy (Join-Path $repoRoot "current") $currentCopy /MIR /NJH /NJS /NDL /NP /NC /NS | Out-Null
|
||||
Copy-Item -Force $instrDll (Join-Path $currentCopy "aahClientManaged.dll")
|
||||
Copy-Item -Force $instrSourceDll (Join-Path $currentCopy "AVEVA.Historian.ReverseInstrumentation.dll")
|
||||
|
||||
$harnessDll = Join-Path $currentCopy "aahClientManaged.dll"
|
||||
if (Test-Path $capturePath) { Remove-Item -Force $capturePath }
|
||||
$env:AVEVA_HISTORIAN_RE_CAPTURE = $capturePath
|
||||
|
||||
Write-Host "== Capturing event-send ==" -ForegroundColor Green
|
||||
$harnessArgs = @(
|
||||
"--scenario", "event-send",
|
||||
"--event-send-confirm",
|
||||
"--server-name", $ServerName,
|
||||
"--tcp-port", "$TcpPort",
|
||||
"--event-type", $EventType,
|
||||
"--event-send-flush-seconds", "$FlushSeconds",
|
||||
"--current-dir", $currentCopy,
|
||||
"--managed-dll-path", $harnessDll
|
||||
)
|
||||
|
||||
$harnessJson = $null
|
||||
try {
|
||||
$prevEap = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
$harnessJson = & dotnet run --no-build -c $Configuration --project $harnessProj -- @harnessArgs 2>&1
|
||||
} catch {
|
||||
Write-Host " (event-send raised: $($_.Exception.Message))" -ForegroundColor Yellow
|
||||
} finally {
|
||||
$ErrorActionPreference = $prevEap
|
||||
}
|
||||
|
||||
Remove-Item Env:\AVEVA_HISTORIAN_RE_CAPTURE -ErrorAction SilentlyContinue
|
||||
|
||||
$recCount = if (Test-Path $capturePath) { (Get-Content $capturePath | Where-Object { $_.Trim() }).Count } else { 0 }
|
||||
Write-Host "`n== Capture summary ==" -ForegroundColor Cyan
|
||||
Write-Host " -> $recCount records -> $capturePath"
|
||||
Write-Host "Harness output (look for AddStreamedEvent Success / ErrorCode):" -ForegroundColor Cyan
|
||||
$harnessJson | Select-Object -Last 60
|
||||
Write-Host "`nDecode with: python scripts\decode-event-send-capture.py" -ForegroundColor Cyan
|
||||
@@ -0,0 +1,91 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Captures the native AVEVA client's ExecuteSqlCommand wire traffic (HCAL roadmap R1.1) so the
|
||||
Retr.ExeC + Retr.GetR string-handle SQL surface (op names, handle format, command/option
|
||||
encoding, Retr priming, GetR result byte stream) can be decoded instead of guessed.
|
||||
|
||||
.DESCRIPTION
|
||||
Drives the .NET-Framework NativeTraceHarness `exec-sql` scenario against the live Historian
|
||||
with an IL-rewritten copy of aahClientManaged.dll whose ClientMessageEncoder.WriteMessage AND
|
||||
ReadMessage are instrumented to log every MDAS body. Read-only benign query.
|
||||
|
||||
.NOTES
|
||||
Artifacts are diagnostic and gitignored. Sanitize before copying into docs/ -- never commit raw
|
||||
capture NDJSON, credentials, hostnames, or customer tag names.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ServerName = "localhost",
|
||||
[int]$TcpPort = 32568,
|
||||
[string]$Sql = "SELECT 1 AS ProbeValue",
|
||||
[string]$SqlOption = "ExecuteRecord",
|
||||
[string]$Configuration = "Debug"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
Set-Location $repoRoot
|
||||
|
||||
$reProj = Join-Path $repoRoot "tools\AVEVA.Historian.ReverseEngineering\AVEVA.Historian.ReverseEngineering.csproj"
|
||||
$harnessProj = Join-Path $repoRoot "tools\AVEVA.Historian.NativeTraceHarness\AVEVA.Historian.NativeTraceHarness.csproj"
|
||||
$instrProj = Join-Path $repoRoot "tools\AVEVA.Historian.ReverseInstrumentation\AVEVA.Historian.ReverseInstrumentation.csproj"
|
||||
|
||||
$captureDir = Join-Path $repoRoot "artifacts\reverse-engineering\instrumented-wcf-exec-sql"
|
||||
$currentCopy = Join-Path $captureDir "current-copy"
|
||||
$instrDll = Join-Path $captureDir "aahClientManaged.dll"
|
||||
$capturePath = Join-Path $captureDir "exec-sql-capture-latest.ndjson"
|
||||
|
||||
Write-Host "== Building tooling ($Configuration) ==" -ForegroundColor Cyan
|
||||
dotnet build $reProj -c $Configuration --nologo -v q | Out-Null
|
||||
dotnet build $instrProj -c $Configuration --nologo -v q | Out-Null
|
||||
dotnet build $harnessProj -c $Configuration --nologo -v q | Out-Null
|
||||
|
||||
$instrSourceDll = Get-ChildItem -Recurse (Join-Path $repoRoot "tools\AVEVA.Historian.ReverseInstrumentation\bin\$Configuration") `
|
||||
-Filter "AVEVA.Historian.ReverseInstrumentation.dll" | Select-Object -First 1 -ExpandProperty FullName
|
||||
if (-not $instrSourceDll) { throw "ReverseInstrumentation.dll not found under bin\$Configuration." }
|
||||
|
||||
Write-Host "== Instrumenting WriteMessage + ReadMessage ==" -ForegroundColor Cyan
|
||||
New-Item -ItemType Directory -Force -Path $captureDir | Out-Null
|
||||
$writeOnly = Join-Path $captureDir "aahClientManaged.write.dll"
|
||||
dotnet run --no-build -c $Configuration --project $reProj -- `
|
||||
instrument-wcf-writemessage (Join-Path $repoRoot "current\aahClientManaged.dll") $writeOnly | Out-Null
|
||||
dotnet run --no-build -c $Configuration --project $reProj -- `
|
||||
instrument-wcf-readmessage $writeOnly $instrDll | Out-Null
|
||||
|
||||
Write-Host "== Staging current-copy ==" -ForegroundColor Cyan
|
||||
robocopy (Join-Path $repoRoot "current") $currentCopy /MIR /NJH /NJS /NDL /NP /NC /NS | Out-Null
|
||||
Copy-Item -Force $instrDll (Join-Path $currentCopy "aahClientManaged.dll")
|
||||
Copy-Item -Force $instrSourceDll (Join-Path $currentCopy "AVEVA.Historian.ReverseInstrumentation.dll")
|
||||
|
||||
$harnessDll = Join-Path $currentCopy "aahClientManaged.dll"
|
||||
if (Test-Path $capturePath) { Remove-Item -Force $capturePath }
|
||||
$env:AVEVA_HISTORIAN_RE_CAPTURE = $capturePath
|
||||
|
||||
Write-Host "== Capturing exec-sql ==" -ForegroundColor Green
|
||||
$harnessArgs = @(
|
||||
"--scenario", "exec-sql",
|
||||
"--server-name", $ServerName,
|
||||
"--tcp-port", "$TcpPort",
|
||||
"--sql", $Sql,
|
||||
"--sql-option", $SqlOption,
|
||||
"--current-dir", $currentCopy,
|
||||
"--managed-dll-path", $harnessDll
|
||||
)
|
||||
|
||||
$harnessJson = $null
|
||||
try {
|
||||
$prevEap = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
$harnessJson = & dotnet run --no-build -c $Configuration --project $harnessProj -- @harnessArgs 2>&1
|
||||
} catch {
|
||||
Write-Host " (exec-sql raised: $($_.Exception.Message))" -ForegroundColor Yellow
|
||||
} finally {
|
||||
$ErrorActionPreference = $prevEap
|
||||
}
|
||||
|
||||
Remove-Item Env:\AVEVA_HISTORIAN_RE_CAPTURE -ErrorAction SilentlyContinue
|
||||
|
||||
$recCount = if (Test-Path $capturePath) { (Get-Content $capturePath | Where-Object { $_.Trim() }).Count } else { 0 }
|
||||
Write-Host "`n== Capture summary ==" -ForegroundColor Cyan
|
||||
Write-Host " -> $recCount records -> $capturePath"
|
||||
$harnessJson | Select-Object -Last 6
|
||||
@@ -0,0 +1,102 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Captures the native AVEVA client's GetHistorianInfo wire traffic (HCAL roadmap R1.4)
|
||||
so the WCF GETHI request that returns the FULL HISTORIAN_INFO struct can be decoded
|
||||
instead of guessed.
|
||||
|
||||
.DESCRIPTION
|
||||
Drives the .NET-Framework NativeTraceHarness's `historian-info` scenario against the live
|
||||
Historian with an IL-rewritten copy of aahClientManaged.dll whose
|
||||
ClientMessageEncoder.WriteMessage AND ReadMessage are instrumented to log every MDAS body
|
||||
(the same pipeline that produced every other proven request/response shape). The harness
|
||||
opens a normal authenticated process connection and calls
|
||||
HistorianAccess.GetHistorianInfo(out HistorianInfo, out err).
|
||||
|
||||
Decode with scripts/decode-historian-info-capture.py: locate the WCF.WriteMessage.Body
|
||||
whose op is GETHI -> that is the GetHistorianInfo request; read off the leading string
|
||||
handle and the pRequestBuff layout (distinct from the named-value "HistorianVersion"
|
||||
request). The paired WCF.ReadMessage.Body is the pResponseBuff = the 518-byte
|
||||
HISTORIAN_INFO struct (version string @0 UTF-16 null-terminated, EventStorageMode int32 @514).
|
||||
|
||||
.NOTES
|
||||
Read-only status call; no data is written. Artifacts are diagnostic and gitignored.
|
||||
Sanitize before copying anything into docs/ -- never commit raw capture NDJSON,
|
||||
credentials, hostnames, or customer tag names.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ServerName = "localhost",
|
||||
[int]$TcpPort = 32568,
|
||||
[string]$Configuration = "Debug"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
Set-Location $repoRoot
|
||||
|
||||
$reProj = Join-Path $repoRoot "tools\AVEVA.Historian.ReverseEngineering\AVEVA.Historian.ReverseEngineering.csproj"
|
||||
$harnessProj = Join-Path $repoRoot "tools\AVEVA.Historian.NativeTraceHarness\AVEVA.Historian.NativeTraceHarness.csproj"
|
||||
$instrProj = Join-Path $repoRoot "tools\AVEVA.Historian.ReverseInstrumentation\AVEVA.Historian.ReverseInstrumentation.csproj"
|
||||
|
||||
$captureDir = Join-Path $repoRoot "artifacts\reverse-engineering\instrumented-wcf-historian-info"
|
||||
$currentCopy = Join-Path $captureDir "current-copy"
|
||||
$instrDll = Join-Path $captureDir "aahClientManaged.dll"
|
||||
$capturePath = Join-Path $captureDir "historian-info-capture-latest.ndjson"
|
||||
|
||||
Write-Host "== Building tooling ($Configuration) ==" -ForegroundColor Cyan
|
||||
dotnet build $reProj -c $Configuration --nologo -v q | Out-Null
|
||||
dotnet build $instrProj -c $Configuration --nologo -v q | Out-Null
|
||||
dotnet build $harnessProj -c $Configuration --nologo -v q | Out-Null
|
||||
|
||||
$instrSourceDll = Get-ChildItem -Recurse (Join-Path $repoRoot "tools\AVEVA.Historian.ReverseInstrumentation\bin\$Configuration") `
|
||||
-Filter "AVEVA.Historian.ReverseInstrumentation.dll" | Select-Object -First 1 -ExpandProperty FullName
|
||||
if (-not $instrSourceDll) { throw "ReverseInstrumentation.dll not found under bin\$Configuration." }
|
||||
|
||||
Write-Host "== Instrumenting WriteMessage + ReadMessage ==" -ForegroundColor Cyan
|
||||
New-Item -ItemType Directory -Force -Path $captureDir | Out-Null
|
||||
# Chain via a distinct intermediate file (reading+writing the same path drops the second
|
||||
# hook on the mixed-mode native image). Final dll carries both hooks with distinct Phase
|
||||
# strings: WCF.WriteMessage.Body and WCF.ReadMessage.Body.
|
||||
$writeOnly = Join-Path $captureDir "aahClientManaged.write.dll"
|
||||
dotnet run --no-build -c $Configuration --project $reProj -- `
|
||||
instrument-wcf-writemessage (Join-Path $repoRoot "current\aahClientManaged.dll") $writeOnly | Out-Null
|
||||
dotnet run --no-build -c $Configuration --project $reProj -- `
|
||||
instrument-wcf-readmessage $writeOnly $instrDll | Out-Null
|
||||
|
||||
Write-Host "== Staging current-copy ==" -ForegroundColor Cyan
|
||||
robocopy (Join-Path $repoRoot "current") $currentCopy /MIR /NJH /NJS /NDL /NP /NC /NS | Out-Null
|
||||
Copy-Item -Force $instrDll (Join-Path $currentCopy "aahClientManaged.dll")
|
||||
Copy-Item -Force $instrSourceDll (Join-Path $currentCopy "AVEVA.Historian.ReverseInstrumentation.dll")
|
||||
|
||||
$harnessDll = Join-Path $currentCopy "aahClientManaged.dll"
|
||||
if (Test-Path $capturePath) { Remove-Item -Force $capturePath }
|
||||
$env:AVEVA_HISTORIAN_RE_CAPTURE = $capturePath
|
||||
|
||||
Write-Host "== Capturing historian-info ==" -ForegroundColor Green
|
||||
$harnessArgs = @(
|
||||
"--scenario", "historian-info",
|
||||
"--server-name", $ServerName,
|
||||
"--tcp-port", "$TcpPort",
|
||||
"--current-dir", $currentCopy,
|
||||
"--managed-dll-path", $harnessDll
|
||||
)
|
||||
|
||||
$harnessJson = $null
|
||||
try {
|
||||
$prevEap = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
$harnessJson = & dotnet run --no-build -c $Configuration --project $harnessProj -- @harnessArgs 2>&1
|
||||
} catch {
|
||||
Write-Host " (historian-info raised: $($_.Exception.Message))" -ForegroundColor Yellow
|
||||
} finally {
|
||||
$ErrorActionPreference = $prevEap
|
||||
}
|
||||
|
||||
Remove-Item Env:\AVEVA_HISTORIAN_RE_CAPTURE -ErrorAction SilentlyContinue
|
||||
|
||||
$recCount = if (Test-Path $capturePath) { (Get-Content $capturePath | Where-Object { $_.Trim() }).Count } else { 0 }
|
||||
Write-Host "`n== Capture summary ==" -ForegroundColor Cyan
|
||||
Write-Host " -> $recCount records -> $capturePath"
|
||||
Write-Host "Harness output (GetHistorianInfoReturned / HistorianInfo):" -ForegroundColor Cyan
|
||||
$harnessJson | Select-Object -Last 24
|
||||
Write-Host "`nDecode with: python scripts\decode-historian-info-capture.py" -ForegroundColor Cyan
|
||||
@@ -0,0 +1,133 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Captures the native AVEVA client's RenameTags wire traffic (HCAL roadmap R1.10) so the
|
||||
StJb (StartJob) rename jobBuffer + GtJb (GetJobStatus) response can be decoded instead of guessed.
|
||||
|
||||
.DESCRIPTION
|
||||
Drives the .NET-Framework NativeTraceHarness's `rename` scenario against the live Historian
|
||||
with an IL-rewritten copy of aahClientManaged.dll whose ClientMessageEncoder.WriteMessage AND
|
||||
ReadMessage are instrumented to log every MDAS body. The harness opens a WRITE-enabled
|
||||
connection, creates a sandbox source tag (RetestSdkWrite...), calls
|
||||
HistorianAccess.RenameTags([(from,to)], ref status, out err), and polls GetTagRenameStatus.
|
||||
|
||||
Rename maps to the generic job framework: StJb(handle, jobBuffer) -> jobId, then
|
||||
GtJb(handle, jobId) -> jobStatus. Decode with scripts/decode-rename-capture.py: find the
|
||||
WCF.WriteMessage.Body whose op is StJb -> its jobBuffer carries the (old,new) name pairs; the
|
||||
paired ReadMessage carries the jobId; the GtJb request/response carry the status.
|
||||
|
||||
SAFETY: sandbox-guarded — both names MUST start with 'RetestSdkWrite'. The default run renames
|
||||
RetestSdkWriteRenameSrc -> RetestSdkWriteRenameDst and (unless -SkipCleanup) deletes the
|
||||
destination tag afterward via a second harness pass.
|
||||
|
||||
.NOTES
|
||||
Artifacts are diagnostic and gitignored. Sanitize before copying anything into docs/ --
|
||||
never commit raw capture NDJSON, credentials, hostnames, or customer tag names.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ServerName = "localhost",
|
||||
[int]$TcpPort = 32568,
|
||||
[string]$RenameFrom = "RetestSdkWriteRenameSrc",
|
||||
[string]$RenameTo = "RetestSdkWriteRenameDst",
|
||||
[switch]$SkipCleanup,
|
||||
[string]$Configuration = "Debug"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
Set-Location $repoRoot
|
||||
|
||||
if (-not $RenameFrom.StartsWith("RetestSdkWrite") -or -not $RenameTo.StartsWith("RetestSdkWrite")) {
|
||||
throw "Both -RenameFrom and -RenameTo must start with 'RetestSdkWrite' (sandbox guard)."
|
||||
}
|
||||
|
||||
$reProj = Join-Path $repoRoot "tools\AVEVA.Historian.ReverseEngineering\AVEVA.Historian.ReverseEngineering.csproj"
|
||||
$harnessProj = Join-Path $repoRoot "tools\AVEVA.Historian.NativeTraceHarness\AVEVA.Historian.NativeTraceHarness.csproj"
|
||||
$instrProj = Join-Path $repoRoot "tools\AVEVA.Historian.ReverseInstrumentation\AVEVA.Historian.ReverseInstrumentation.csproj"
|
||||
|
||||
$captureDir = Join-Path $repoRoot "artifacts\reverse-engineering\instrumented-wcf-rename"
|
||||
$currentCopy = Join-Path $captureDir "current-copy"
|
||||
$instrDll = Join-Path $captureDir "aahClientManaged.dll"
|
||||
$capturePath = Join-Path $captureDir "rename-capture-latest.ndjson"
|
||||
|
||||
Write-Host "== Building tooling ($Configuration) ==" -ForegroundColor Cyan
|
||||
dotnet build $reProj -c $Configuration --nologo -v q | Out-Null
|
||||
dotnet build $instrProj -c $Configuration --nologo -v q | Out-Null
|
||||
dotnet build $harnessProj -c $Configuration --nologo -v q | Out-Null
|
||||
|
||||
$instrSourceDll = Get-ChildItem -Recurse (Join-Path $repoRoot "tools\AVEVA.Historian.ReverseInstrumentation\bin\$Configuration") `
|
||||
-Filter "AVEVA.Historian.ReverseInstrumentation.dll" | Select-Object -First 1 -ExpandProperty FullName
|
||||
if (-not $instrSourceDll) { throw "ReverseInstrumentation.dll not found under bin\$Configuration." }
|
||||
|
||||
Write-Host "== Instrumenting WriteMessage + ReadMessage ==" -ForegroundColor Cyan
|
||||
New-Item -ItemType Directory -Force -Path $captureDir | Out-Null
|
||||
$writeOnly = Join-Path $captureDir "aahClientManaged.write.dll"
|
||||
dotnet run --no-build -c $Configuration --project $reProj -- `
|
||||
instrument-wcf-writemessage (Join-Path $repoRoot "current\aahClientManaged.dll") $writeOnly | Out-Null
|
||||
dotnet run --no-build -c $Configuration --project $reProj -- `
|
||||
instrument-wcf-readmessage $writeOnly $instrDll | Out-Null
|
||||
|
||||
Write-Host "== Staging current-copy ==" -ForegroundColor Cyan
|
||||
robocopy (Join-Path $repoRoot "current") $currentCopy /MIR /NJH /NJS /NDL /NP /NC /NS | Out-Null
|
||||
Copy-Item -Force $instrDll (Join-Path $currentCopy "aahClientManaged.dll")
|
||||
Copy-Item -Force $instrSourceDll (Join-Path $currentCopy "AVEVA.Historian.ReverseInstrumentation.dll")
|
||||
|
||||
$harnessDll = Join-Path $currentCopy "aahClientManaged.dll"
|
||||
if (Test-Path $capturePath) { Remove-Item -Force $capturePath }
|
||||
$env:AVEVA_HISTORIAN_RE_CAPTURE = $capturePath
|
||||
|
||||
Write-Host "== Capturing rename ($RenameFrom -> $RenameTo) ==" -ForegroundColor Green
|
||||
$harnessArgs = @(
|
||||
"--scenario", "rename",
|
||||
"--server-name", $ServerName,
|
||||
"--tcp-port", "$TcpPort",
|
||||
"--rename-from", $RenameFrom,
|
||||
"--rename-to", $RenameTo,
|
||||
"--current-dir", $currentCopy,
|
||||
"--managed-dll-path", $harnessDll
|
||||
)
|
||||
|
||||
$harnessJson = $null
|
||||
try {
|
||||
$prevEap = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
$harnessJson = & dotnet run --no-build -c $Configuration --project $harnessProj -- @harnessArgs 2>&1
|
||||
} catch {
|
||||
Write-Host " (rename raised: $($_.Exception.Message))" -ForegroundColor Yellow
|
||||
} finally {
|
||||
$ErrorActionPreference = $prevEap
|
||||
}
|
||||
|
||||
Remove-Item Env:\AVEVA_HISTORIAN_RE_CAPTURE -ErrorAction SilentlyContinue
|
||||
|
||||
$recCount = if (Test-Path $capturePath) { (Get-Content $capturePath | Where-Object { $_.Trim() }).Count } else { 0 }
|
||||
Write-Host "`n== Capture summary ==" -ForegroundColor Cyan
|
||||
Write-Host " -> $recCount records -> $capturePath"
|
||||
Write-Host "Harness output (RenameTagsReturned / Rows):" -ForegroundColor Cyan
|
||||
$harnessJson | Select-Object -Last 30
|
||||
|
||||
# Best-effort cleanup: delete the destination sandbox tag so reruns start clean.
|
||||
if (-not $SkipCleanup) {
|
||||
Write-Host "`n== Cleanup: deleting $RenameTo ==" -ForegroundColor Cyan
|
||||
$cleanupArgs = @(
|
||||
"--scenario", "write",
|
||||
"--server-name", $ServerName,
|
||||
"--tcp-port", "$TcpPort",
|
||||
"--write-sandbox-tag", $RenameTo,
|
||||
"--write-skip-add-tag",
|
||||
"--write-skip-add-value",
|
||||
"--write-delete-after",
|
||||
"--current-dir", $currentCopy,
|
||||
"--managed-dll-path", $harnessDll
|
||||
)
|
||||
try {
|
||||
$ErrorActionPreference = "Continue"
|
||||
& dotnet run --no-build -c $Configuration --project $harnessProj -- @cleanupArgs 2>&1 | Select-Object -Last 4
|
||||
} catch {
|
||||
Write-Host " (cleanup raised: $($_.Exception.Message))" -ForegroundColor Yellow
|
||||
} finally {
|
||||
$ErrorActionPreference = "Stop"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "`nDecode with: python scripts\decode-rename-capture.py" -ForegroundColor Cyan
|
||||
@@ -0,0 +1,105 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Captures the native AVEVA client's GetRuntimeParameter wire traffic (HCAL roadmap R1.2)
|
||||
so the WCF op name, handle type (uint vs the string-handle wall), and the
|
||||
btRequest/btResponse buffer format can be decoded instead of guessed.
|
||||
|
||||
.DESCRIPTION
|
||||
Drives the .NET-Framework NativeTraceHarness's `runtime-param` scenario against the live
|
||||
Historian with an IL-rewritten copy of aahClientManaged.dll whose
|
||||
ClientMessageEncoder.WriteMessage AND ReadMessage are instrumented to log every MDAS body
|
||||
(the same pipeline that produced every other proven request/response shape). The harness
|
||||
opens a normal authenticated process connection and calls
|
||||
HistorianAccess.GetRuntimeParameter(List<string> names, out List<object> results, out err).
|
||||
|
||||
Decode with scripts/decode-runtime-param-capture.py: locate the WCF.WriteMessage.Body
|
||||
whose op carries the parameter name(s) -> that is the GetRuntimeParameter request; read
|
||||
off the SOAP action / op name, the leading handle param, and the btRequest layout. The
|
||||
paired WCF.ReadMessage.Body is the btResponse (the CRetVariant value list).
|
||||
|
||||
.NOTES
|
||||
Read-only status call; no data is written. Artifacts are diagnostic and gitignored.
|
||||
Sanitize before copying anything into docs/ -- never commit raw capture NDJSON,
|
||||
credentials, hostnames, or customer tag names.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ServerName = "localhost",
|
||||
[int]$TcpPort = 32568,
|
||||
# Semicolon-separated runtime parameter names. HistorianVersion is a known-good name
|
||||
# (returns the server version string) so the response decode has a real value.
|
||||
[string]$Names = "HistorianVersion",
|
||||
[string]$Configuration = "Debug"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
Set-Location $repoRoot
|
||||
|
||||
$reProj = Join-Path $repoRoot "tools\AVEVA.Historian.ReverseEngineering\AVEVA.Historian.ReverseEngineering.csproj"
|
||||
$harnessProj = Join-Path $repoRoot "tools\AVEVA.Historian.NativeTraceHarness\AVEVA.Historian.NativeTraceHarness.csproj"
|
||||
$instrProj = Join-Path $repoRoot "tools\AVEVA.Historian.ReverseInstrumentation\AVEVA.Historian.ReverseInstrumentation.csproj"
|
||||
|
||||
$captureDir = Join-Path $repoRoot "artifacts\reverse-engineering\instrumented-wcf-runtime-param"
|
||||
$currentCopy = Join-Path $captureDir "current-copy"
|
||||
$instrDll = Join-Path $captureDir "aahClientManaged.dll"
|
||||
$capturePath = Join-Path $captureDir "runtime-param-capture-latest.ndjson"
|
||||
|
||||
Write-Host "== Building tooling ($Configuration) ==" -ForegroundColor Cyan
|
||||
dotnet build $reProj -c $Configuration --nologo -v q | Out-Null
|
||||
dotnet build $instrProj -c $Configuration --nologo -v q | Out-Null
|
||||
dotnet build $harnessProj -c $Configuration --nologo -v q | Out-Null
|
||||
|
||||
$instrSourceDll = Get-ChildItem -Recurse (Join-Path $repoRoot "tools\AVEVA.Historian.ReverseInstrumentation\bin\$Configuration") `
|
||||
-Filter "AVEVA.Historian.ReverseInstrumentation.dll" | Select-Object -First 1 -ExpandProperty FullName
|
||||
if (-not $instrSourceDll) { throw "ReverseInstrumentation.dll not found under bin\$Configuration." }
|
||||
|
||||
Write-Host "== Instrumenting WriteMessage + ReadMessage ==" -ForegroundColor Cyan
|
||||
New-Item -ItemType Directory -Force -Path $captureDir | Out-Null
|
||||
# Chain via a distinct intermediate file (reading+writing the same path drops the second
|
||||
# hook on the mixed-mode native image). Final dll carries both hooks with distinct Phase
|
||||
# strings: WCF.WriteMessage.Body and WCF.ReadMessage.Body.
|
||||
$writeOnly = Join-Path $captureDir "aahClientManaged.write.dll"
|
||||
dotnet run --no-build -c $Configuration --project $reProj -- `
|
||||
instrument-wcf-writemessage (Join-Path $repoRoot "current\aahClientManaged.dll") $writeOnly | Out-Null
|
||||
dotnet run --no-build -c $Configuration --project $reProj -- `
|
||||
instrument-wcf-readmessage $writeOnly $instrDll | Out-Null
|
||||
|
||||
Write-Host "== Staging current-copy ==" -ForegroundColor Cyan
|
||||
robocopy (Join-Path $repoRoot "current") $currentCopy /MIR /NJH /NJS /NDL /NP /NC /NS | Out-Null
|
||||
Copy-Item -Force $instrDll (Join-Path $currentCopy "aahClientManaged.dll")
|
||||
Copy-Item -Force $instrSourceDll (Join-Path $currentCopy "AVEVA.Historian.ReverseInstrumentation.dll")
|
||||
|
||||
$harnessDll = Join-Path $currentCopy "aahClientManaged.dll"
|
||||
if (Test-Path $capturePath) { Remove-Item -Force $capturePath }
|
||||
$env:AVEVA_HISTORIAN_RE_CAPTURE = $capturePath
|
||||
|
||||
Write-Host "== Capturing runtime-param ==" -ForegroundColor Green
|
||||
$harnessArgs = @(
|
||||
"--scenario", "runtime-param",
|
||||
"--server-name", $ServerName,
|
||||
"--tcp-port", "$TcpPort",
|
||||
"--runtime-param-names", $Names,
|
||||
"--current-dir", $currentCopy,
|
||||
"--managed-dll-path", $harnessDll
|
||||
)
|
||||
|
||||
$harnessJson = $null
|
||||
try {
|
||||
$prevEap = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
$harnessJson = & dotnet run --no-build -c $Configuration --project $harnessProj -- @harnessArgs 2>&1
|
||||
} catch {
|
||||
Write-Host " (runtime-param raised: $($_.Exception.Message))" -ForegroundColor Yellow
|
||||
} finally {
|
||||
$ErrorActionPreference = $prevEap
|
||||
}
|
||||
|
||||
Remove-Item Env:\AVEVA_HISTORIAN_RE_CAPTURE -ErrorAction SilentlyContinue
|
||||
|
||||
$recCount = if (Test-Path $capturePath) { (Get-Content $capturePath | Where-Object { $_.Trim() }).Count } else { 0 }
|
||||
Write-Host "`n== Capture summary ==" -ForegroundColor Cyan
|
||||
Write-Host " -> $recCount records -> $capturePath"
|
||||
Write-Host "Harness output (GetRuntimeParameterReturned / Results):" -ForegroundColor Cyan
|
||||
$harnessJson | Select-Object -Last 20
|
||||
Write-Host "`nDecode with: python scripts\decode-runtime-param-capture.py" -ForegroundColor Cyan
|
||||
@@ -0,0 +1,150 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Captures the native AVEVA client's StartQuery2 request bytes for analog/state
|
||||
summary queries (HCAL roadmap R1.8/R1.9) so the managed SDK's summary request
|
||||
shape can be decoded against ground truth instead of guessed.
|
||||
|
||||
.DESCRIPTION
|
||||
Drives the .NET-Framework NativeTraceHarness against the live Historian with an
|
||||
IL-rewritten copy of aahClientManaged.dll whose ClientMessageEncoder.WriteMessage
|
||||
is instrumented to log every outgoing MDAS body (the same pipeline that produced
|
||||
every other proven request shape). For each candidate HistoryQueryArgs config it
|
||||
writes a per-config NDJSON capture under
|
||||
artifacts/reverse-engineering/instrumented-wcf-writemessage-summary/ (gitignored).
|
||||
|
||||
The default matrix is:
|
||||
- baseline-full : RetrievalMode=Full (the known-good non-summary request)
|
||||
- analog-avg : RetrievalMode=Cyclic + ValueSelector=Average + Resolution
|
||||
- analog-min : RetrievalMode=Cyclic + ValueSelector=Minimum + Resolution
|
||||
- analog-agg-avg : RetrievalMode=Cyclic + AggregationType=Average + Resolution
|
||||
- state-summary : RetrievalMode=Cyclic + MaxStates>0 + Resolution
|
||||
|
||||
Diff any candidate against baseline-full (scripts/decode-summary-capture.py) to read
|
||||
off the exact QueryType / SummaryType / AutoSummaryParameters bytes the native client
|
||||
sets for a summary, then implement the managed request against that.
|
||||
|
||||
.NOTES
|
||||
Artifacts are diagnostic. Sanitize before copying anything into docs/ — never commit
|
||||
raw capture NDJSON, credentials, hostnames, or customer tag names.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ServerName = "localhost",
|
||||
[int]$TcpPort = 32568,
|
||||
# SysTimeSec is the local data-bearing system tag (OtOpcUaParityTest_001.Counter is stale/empty).
|
||||
[string]$TagName = "SysTimeSec",
|
||||
[int]$LookbackMinutes = 240,
|
||||
[int]$MaxRows = 4,
|
||||
# 1-hour summary cycle in 100ns ticks (1h = 36,000,000,000 ticks).
|
||||
[uint64]$ResolutionTicks = 36000000000,
|
||||
[string]$Configuration = "Debug",
|
||||
# Restrict the run to a single named config from the matrix (default: run all).
|
||||
[string]$OnlyConfig = "",
|
||||
# Also instrument ReadMessage so each capture includes the incoming WCF response bodies
|
||||
# (the GetNextQueryResultBuffer2 pResultBuff summary rows). Decoded by decode-summary-response.py.
|
||||
[switch]$WithResponse
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
Set-Location $repoRoot
|
||||
|
||||
$reProj = Join-Path $repoRoot "tools\AVEVA.Historian.ReverseEngineering\AVEVA.Historian.ReverseEngineering.csproj"
|
||||
$harnessProj = Join-Path $repoRoot "tools\AVEVA.Historian.NativeTraceHarness\AVEVA.Historian.NativeTraceHarness.csproj"
|
||||
$instrProj = Join-Path $repoRoot "tools\AVEVA.Historian.ReverseInstrumentation\AVEVA.Historian.ReverseInstrumentation.csproj"
|
||||
|
||||
$captureDir = Join-Path $repoRoot "artifacts\reverse-engineering\instrumented-wcf-writemessage-summary"
|
||||
$currentCopy = Join-Path $captureDir "current-copy"
|
||||
$instrDll = Join-Path $captureDir "aahClientManaged.dll"
|
||||
|
||||
Write-Host "== Building tooling ($Configuration) ==" -ForegroundColor Cyan
|
||||
dotnet build $reProj -c $Configuration --nologo -v q | Out-Null
|
||||
dotnet build $instrProj -c $Configuration --nologo -v q | Out-Null
|
||||
dotnet build $harnessProj -c $Configuration --nologo -v q | Out-Null
|
||||
|
||||
$instrSourceDll = Get-ChildItem -Recurse (Join-Path $repoRoot "tools\AVEVA.Historian.ReverseInstrumentation\bin\$Configuration") `
|
||||
-Filter "AVEVA.Historian.ReverseInstrumentation.dll" | Select-Object -First 1 -ExpandProperty FullName
|
||||
if (-not $instrSourceDll) { throw "ReverseInstrumentation.dll not found under bin\$Configuration." }
|
||||
|
||||
Write-Host "== Instrumenting WriteMessage$(if ($WithResponse) { ' + ReadMessage' }) ==" -ForegroundColor Cyan
|
||||
New-Item -ItemType Directory -Force -Path $captureDir | Out-Null
|
||||
if ($WithResponse) {
|
||||
# Chain via a distinct intermediate file (reading+writing the same path drops the second
|
||||
# hook on the mixed-mode native image). Final dll carries both hooks with distinct Phase
|
||||
# strings: WCF.WriteMessage.Body and WCF.ReadMessage.Body.
|
||||
$writeOnly = Join-Path $captureDir "aahClientManaged.write.dll"
|
||||
dotnet run --no-build -c $Configuration --project $reProj -- `
|
||||
instrument-wcf-writemessage (Join-Path $repoRoot "current\aahClientManaged.dll") $writeOnly | Out-Null
|
||||
dotnet run --no-build -c $Configuration --project $reProj -- `
|
||||
instrument-wcf-readmessage $writeOnly $instrDll | Out-Null
|
||||
} else {
|
||||
dotnet run --no-build -c $Configuration --project $reProj -- `
|
||||
instrument-wcf-writemessage (Join-Path $repoRoot "current\aahClientManaged.dll") $instrDll | Out-Null
|
||||
}
|
||||
|
||||
Write-Host "== Staging current-copy ==" -ForegroundColor Cyan
|
||||
# Mirror current/ into current-copy, then overwrite the managed dll with the instrumented
|
||||
# build and drop the strong-named logger assembly alongside it so the injected call binds.
|
||||
robocopy (Join-Path $repoRoot "current") $currentCopy /MIR /NJH /NJS /NDL /NP /NC /NS | Out-Null
|
||||
Copy-Item -Force $instrDll (Join-Path $currentCopy "aahClientManaged.dll")
|
||||
Copy-Item -Force $instrSourceDll (Join-Path $currentCopy "AVEVA.Historian.ReverseInstrumentation.dll")
|
||||
|
||||
# Candidate matrix: name + harness arg list. Summary configs all use Cyclic + a resolution;
|
||||
# the differentiator is which summary knob is set.
|
||||
$matrix = @(
|
||||
@{ Name = "baseline-full"; Args = @("--retrieval-mode", "Full") },
|
||||
@{ Name = "analog-avg"; Args = @("--retrieval-mode", "Cyclic", "--value-selector", "Average", "--resolution-ticks", "$ResolutionTicks") },
|
||||
@{ Name = "analog-min"; Args = @("--retrieval-mode", "Cyclic", "--value-selector", "Minimum", "--resolution-ticks", "$ResolutionTicks") },
|
||||
@{ Name = "analog-max"; Args = @("--retrieval-mode", "Cyclic", "--value-selector", "Maximum", "--resolution-ticks", "$ResolutionTicks") },
|
||||
@{ Name = "analog-integral"; Args = @("--retrieval-mode", "Cyclic", "--value-selector", "Integral", "--resolution-ticks", "$ResolutionTicks") },
|
||||
@{ Name = "mode-integral"; Args = @("--retrieval-mode", "Integral", "--resolution-ticks", "$ResolutionTicks") },
|
||||
@{ Name = "mode-twavg"; Args = @("--retrieval-mode", "TimeWeightedAverage", "--resolution-ticks", "$ResolutionTicks") },
|
||||
@{ Name = "analog-agg-avg"; Args = @("--retrieval-mode", "Cyclic", "--aggregation-type", "Average", "--resolution-ticks", "$ResolutionTicks") },
|
||||
@{ Name = "state-summary"; Args = @("--retrieval-mode", "Cyclic", "--max-states", "10", "--resolution-ticks", "$ResolutionTicks") }
|
||||
)
|
||||
|
||||
if ($OnlyConfig) { $matrix = $matrix | Where-Object { $_.Name -eq $OnlyConfig } }
|
||||
if (-not $matrix) { throw "No matrix entry named '$OnlyConfig'." }
|
||||
|
||||
$harnessDll = Join-Path $currentCopy "aahClientManaged.dll"
|
||||
$summary = @()
|
||||
|
||||
foreach ($cfg in $matrix) {
|
||||
$name = $cfg.Name
|
||||
$capturePath = Join-Path $captureDir "summary-capture-$name-latest.ndjson"
|
||||
if (Test-Path $capturePath) { Remove-Item -Force $capturePath }
|
||||
$env:AVEVA_HISTORIAN_RE_CAPTURE = $capturePath
|
||||
|
||||
Write-Host "== Capturing: $name ==" -ForegroundColor Green
|
||||
$harnessArgs = @(
|
||||
"--scenario", "history",
|
||||
"--server-name", $ServerName,
|
||||
"--tcp-port", "$TcpPort",
|
||||
"--tag", $TagName,
|
||||
"--lookback-minutes", "$LookbackMinutes",
|
||||
"--max-rows", "$MaxRows",
|
||||
"--current-dir", $currentCopy,
|
||||
"--managed-dll-path", $harnessDll
|
||||
) + $cfg.Args
|
||||
|
||||
# Don't let a single config that errors (e.g. state summary on an analog tag) abort the
|
||||
# whole matrix, and don't treat dotnet's stderr noise as a terminating error.
|
||||
try {
|
||||
$prevEap = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
& dotnet run --no-build -c $Configuration --project $harnessProj -- @harnessArgs 2>&1 | Out-Null
|
||||
} catch {
|
||||
Write-Host " (config '$name' raised: $($_.Exception.Message))" -ForegroundColor Yellow
|
||||
} finally {
|
||||
$ErrorActionPreference = $prevEap
|
||||
}
|
||||
$recCount = if (Test-Path $capturePath) { (Get-Content $capturePath | Where-Object { $_.Trim() }).Count } else { 0 }
|
||||
Write-Host " -> $recCount records -> $capturePath"
|
||||
$summary += [pscustomobject]@{ Config = $name; Records = $recCount; Capture = $capturePath }
|
||||
}
|
||||
|
||||
Remove-Item Env:\AVEVA_HISTORIAN_RE_CAPTURE -ErrorAction SilentlyContinue
|
||||
|
||||
Write-Host "`n== Capture summary ==" -ForegroundColor Cyan
|
||||
$summary | Format-Table -AutoSize
|
||||
Write-Host "Decode with: python scripts\decode-summary-capture.py" -ForegroundColor Cyan
|
||||
@@ -0,0 +1,104 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Captures the native AVEVA client's GetTagExtendedPropertiesFromName (GetTepByNm) wire traffic
|
||||
(HCAL roadmap R1.5) so the WCF op name, the string-handle format, the tagNames request buffer,
|
||||
and the extended-property response buffer can be decoded instead of guessed.
|
||||
|
||||
.DESCRIPTION
|
||||
Drives the .NET-Framework NativeTraceHarness's tag scenario with --retrieve-extended-properties,
|
||||
which flips TagQueryArgs.RetrieveTagExtendedPropertyInfo and then calls
|
||||
TagQuery.GetTagExtendedPropertyInfo(start, count, out TagExtendedPropertyGroupList, out err) —
|
||||
the managed method that issues the GetTepByNm op. An IL-rewritten copy of aahClientManaged.dll
|
||||
logs every MDAS body (ClientMessageEncoder.WriteMessage + ReadMessage), the same pipeline that
|
||||
produced every other proven request/response shape.
|
||||
|
||||
Decode with scripts/decode-tag-properties-capture.py: locate the WCF.WriteMessage.Body whose op
|
||||
is aa/Retr/GetTepByNm -> that is the request (string handle + tagNames buffer + sequence). The
|
||||
paired WCF.ReadMessage.Body is the extended-property response buffer.
|
||||
|
||||
.NOTES
|
||||
Read-only metadata call; no data is written. Artifacts are diagnostic and gitignored.
|
||||
Sanitize before copying anything into docs/ -- never commit raw capture NDJSON, credentials,
|
||||
hostnames, or customer tag names. SysTimeSec is a built-in system tag (safe to name).
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ServerName = "localhost",
|
||||
[int]$TcpPort = 32568,
|
||||
# A tag (or wildcard) to query extended properties for. SysTimeSec is a built-in system tag
|
||||
# present on every Historian; override with a real tag that carries extended properties for a
|
||||
# richer response decode.
|
||||
[string]$Tag = "SysTimeSec",
|
||||
[string]$Configuration = "Debug"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
Set-Location $repoRoot
|
||||
|
||||
$reProj = Join-Path $repoRoot "tools\AVEVA.Historian.ReverseEngineering\AVEVA.Historian.ReverseEngineering.csproj"
|
||||
$harnessProj = Join-Path $repoRoot "tools\AVEVA.Historian.NativeTraceHarness\AVEVA.Historian.NativeTraceHarness.csproj"
|
||||
$instrProj = Join-Path $repoRoot "tools\AVEVA.Historian.ReverseInstrumentation\AVEVA.Historian.ReverseInstrumentation.csproj"
|
||||
|
||||
$captureDir = Join-Path $repoRoot "artifacts\reverse-engineering\instrumented-wcf-tag-extended-properties"
|
||||
$currentCopy = Join-Path $captureDir "current-copy"
|
||||
$instrDll = Join-Path $captureDir "aahClientManaged.dll"
|
||||
$capturePath = Join-Path $captureDir "tag-extended-properties-capture-latest.ndjson"
|
||||
|
||||
Write-Host "== Building tooling ($Configuration) ==" -ForegroundColor Cyan
|
||||
dotnet build $reProj -c $Configuration --nologo -v q | Out-Null
|
||||
dotnet build $instrProj -c $Configuration --nologo -v q | Out-Null
|
||||
dotnet build $harnessProj -c $Configuration --nologo -v q | Out-Null
|
||||
|
||||
$instrSourceDll = Get-ChildItem -Recurse (Join-Path $repoRoot "tools\AVEVA.Historian.ReverseInstrumentation\bin\$Configuration") `
|
||||
-Filter "AVEVA.Historian.ReverseInstrumentation.dll" | Select-Object -First 1 -ExpandProperty FullName
|
||||
if (-not $instrSourceDll) { throw "ReverseInstrumentation.dll not found under bin\$Configuration." }
|
||||
|
||||
Write-Host "== Instrumenting WriteMessage + ReadMessage ==" -ForegroundColor Cyan
|
||||
New-Item -ItemType Directory -Force -Path $captureDir | Out-Null
|
||||
# Chain via a distinct intermediate file (reading+writing the same path drops the second hook on
|
||||
# the mixed-mode native image). Final dll carries both hooks: WCF.WriteMessage.Body + WCF.ReadMessage.Body.
|
||||
$writeOnly = Join-Path $captureDir "aahClientManaged.write.dll"
|
||||
dotnet run --no-build -c $Configuration --project $reProj -- `
|
||||
instrument-wcf-writemessage (Join-Path $repoRoot "current\aahClientManaged.dll") $writeOnly | Out-Null
|
||||
dotnet run --no-build -c $Configuration --project $reProj -- `
|
||||
instrument-wcf-readmessage $writeOnly $instrDll | Out-Null
|
||||
|
||||
Write-Host "== Staging current-copy ==" -ForegroundColor Cyan
|
||||
robocopy (Join-Path $repoRoot "current") $currentCopy /MIR /NJH /NJS /NDL /NP /NC /NS | Out-Null
|
||||
Copy-Item -Force $instrDll (Join-Path $currentCopy "aahClientManaged.dll")
|
||||
Copy-Item -Force $instrSourceDll (Join-Path $currentCopy "AVEVA.Historian.ReverseInstrumentation.dll")
|
||||
|
||||
$harnessDll = Join-Path $currentCopy "aahClientManaged.dll"
|
||||
if (Test-Path $capturePath) { Remove-Item -Force $capturePath }
|
||||
$env:AVEVA_HISTORIAN_RE_CAPTURE = $capturePath
|
||||
|
||||
Write-Host "== Capturing tag-extended-properties ==" -ForegroundColor Green
|
||||
$harnessArgs = @(
|
||||
"--scenario", "tag-extended-properties",
|
||||
"--server-name", $ServerName,
|
||||
"--tcp-port", "$TcpPort",
|
||||
"--tag", $Tag,
|
||||
"--current-dir", $currentCopy,
|
||||
"--managed-dll-path", $harnessDll
|
||||
)
|
||||
|
||||
$harnessJson = $null
|
||||
try {
|
||||
$prevEap = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
$harnessJson = & dotnet run --no-build -c $Configuration --project $harnessProj -- @harnessArgs 2>&1
|
||||
} catch {
|
||||
Write-Host " (tag-extended-properties raised: $($_.Exception.Message))" -ForegroundColor Yellow
|
||||
} finally {
|
||||
$ErrorActionPreference = $prevEap
|
||||
}
|
||||
|
||||
Remove-Item Env:\AVEVA_HISTORIAN_RE_CAPTURE -ErrorAction SilentlyContinue
|
||||
|
||||
$recCount = if (Test-Path $capturePath) { (Get-Content $capturePath | Where-Object { $_.Trim() }).Count } else { 0 }
|
||||
Write-Host "`n== Capture summary ==" -ForegroundColor Cyan
|
||||
Write-Host " -> $recCount records -> $capturePath"
|
||||
Write-Host "Harness output (TagExtendedProperties Success / Groups):" -ForegroundColor Cyan
|
||||
$harnessJson | Select-Object -Last 24
|
||||
Write-Host "`nDecode with: python scripts\decode-tag-properties-capture.py" -ForegroundColor Cyan
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Decode the AddTagExtendedProperties / DeleteTagExtendedProperties WCF inBuff (HCAL R1.11).
|
||||
|
||||
Reads the capture produced by scripts/Capture-AddTagExtendedProperties.ps1 and locates the AddTEx /
|
||||
DelTep WriteMessage bodies by the sandbox tag + property name/value, then dumps the inBuff bytes so
|
||||
the framing (tag name, property count, per-property name + value markers) can be read off. Compare to
|
||||
the R1.5 read-response encoding in HistorianTagExtendedPropertyProtocol.
|
||||
|
||||
Output is diagnostic. Sanitize before copying into docs/.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
CAPDIR = REPO_ROOT / "artifacts" / "reverse-engineering" / "instrumented-wcf-add-tep"
|
||||
CAP = CAPDIR / "add-tep-capture-latest.ndjson"
|
||||
|
||||
TAG = "RetestSdkWriteTepTag"
|
||||
PROP = "SdkTestProp"
|
||||
VALUE = "SdkTestValue"
|
||||
OP_ADD = b"AddTEx"
|
||||
OP_DEL = b"DelTep"
|
||||
|
||||
|
||||
def hexdump(label, buf, base=0):
|
||||
print(f"=== {label}: {len(buf)} bytes ===")
|
||||
for off in range(0, len(buf), 16):
|
||||
c = buf[off:off + 16]
|
||||
hp = " ".join(f"{x:02X}" for x in c)
|
||||
ap = "".join(chr(x) if 32 <= x < 127 else "." for x in c)
|
||||
print(f" {base + off:04X} {hp:<48} |{ap}|")
|
||||
print()
|
||||
|
||||
|
||||
def ascii_strings(buf, minlen=3):
|
||||
out, cur, start = [], [], 0
|
||||
for i, x in enumerate(buf):
|
||||
if 32 <= x < 127:
|
||||
if not cur:
|
||||
start = i
|
||||
cur.append(chr(x))
|
||||
else:
|
||||
if len(cur) >= minlen:
|
||||
out.append((start, "".join(cur)))
|
||||
cur = []
|
||||
if len(cur) >= minlen:
|
||||
out.append((start, "".join(cur)))
|
||||
return out
|
||||
|
||||
|
||||
def u16_strings(buf, minlen=3):
|
||||
out, i = [], 0
|
||||
while i < len(buf) - 1:
|
||||
j, chars = i, []
|
||||
while j < len(buf) - 1 and 32 <= buf[j] < 127 and buf[j + 1] == 0:
|
||||
chars.append(chr(buf[j]))
|
||||
j += 2
|
||||
if len(chars) >= minlen:
|
||||
out.append((i, "".join(chars)))
|
||||
i = j
|
||||
else:
|
||||
i += 1
|
||||
return out
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not CAP.exists():
|
||||
print(f"Missing capture: {CAP}\nRun scripts/Capture-AddTagExtendedProperties.ps1 first.")
|
||||
return 1
|
||||
|
||||
records = []
|
||||
for line in CAP.open(encoding="utf-8-sig"):
|
||||
if line.strip():
|
||||
records.append(json.loads(line))
|
||||
|
||||
tag_a, prop_a, val_a = TAG.encode("ascii"), PROP.encode("ascii"), VALUE.encode("ascii")
|
||||
tag_u, prop_u, val_u = TAG.encode("utf-16-le"), PROP.encode("utf-16-le"), VALUE.encode("utf-16-le")
|
||||
|
||||
print(f"== {len(records)} MDAS bodies captured ==")
|
||||
for idx, rec in enumerate(records):
|
||||
body = base64.b64decode(rec["Base64"])
|
||||
flags = []
|
||||
if OP_ADD in body:
|
||||
flags.append("AddTEx")
|
||||
if OP_DEL in body:
|
||||
flags.append("DelTep")
|
||||
if prop_a in body or prop_u in body:
|
||||
flags.append("PROP")
|
||||
if val_a in body or val_u in body:
|
||||
flags.append("VALUE")
|
||||
print(f" [{idx:02d}] {rec.get('Phase'):26s} len={len(body):5d} {','.join(flags)}")
|
||||
|
||||
def dump(op):
|
||||
for idx, rec in enumerate(records):
|
||||
body = base64.b64decode(rec["Base64"])
|
||||
if rec.get("Phase") == "WCF.WriteMessage.Body" and op in body:
|
||||
hexdump(f"[{idx}] {op.decode()} WriteMessage", body)
|
||||
print(" UTF-16 strings:")
|
||||
for off, s in u16_strings(body):
|
||||
print(f" 0x{off:04X} {s!r}")
|
||||
print(" ASCII strings:")
|
||||
for off, s in ascii_strings(body):
|
||||
print(f" 0x{off:04X} {s!r}")
|
||||
print()
|
||||
|
||||
print("\n== AddTEx request(s) ==")
|
||||
dump(OP_ADD)
|
||||
print("\n== DelTep request(s) ==")
|
||||
dump(OP_DEL)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Decode the DeleteTagExtendedProperties (DelTep) WCF inBuff (HCAL R1.11 delete half).
|
||||
|
||||
Reads the Run-B capture produced by scripts/Capture-DeleteTagExtendedProperties.ps1 and dumps the
|
||||
DelTep WriteMessage body so the delete-request framing (tag name + property names + the
|
||||
delete-from-server flag) can be read off and compared to the AddTEx serializer.
|
||||
|
||||
Output is diagnostic. Sanitize before copying into docs/.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
CAPDIR = REPO_ROOT / "artifacts" / "reverse-engineering" / "instrumented-wcf-del-tep"
|
||||
CAP = CAPDIR / "del-tep-capture-latest.ndjson"
|
||||
|
||||
TAG = "RetestSdkWriteDelTepSdk"
|
||||
PROP = "SdkDelProp"
|
||||
OP_DEL = b"DelTep"
|
||||
OP_GET = b"GetTepByNm"
|
||||
|
||||
|
||||
def hexdump(label, buf, base=0):
|
||||
print(f"=== {label}: {len(buf)} bytes ===")
|
||||
for off in range(0, len(buf), 16):
|
||||
c = buf[off:off + 16]
|
||||
hp = " ".join(f"{x:02X}" for x in c)
|
||||
ap = "".join(chr(x) if 32 <= x < 127 else "." for x in c)
|
||||
print(f" {base + off:04X} {hp:<48} |{ap}|")
|
||||
print()
|
||||
|
||||
|
||||
def ascii_strings(buf, minlen=3):
|
||||
out, cur, start = [], [], 0
|
||||
for i, x in enumerate(buf):
|
||||
if 32 <= x < 127:
|
||||
if not cur:
|
||||
start = i
|
||||
cur.append(chr(x))
|
||||
else:
|
||||
if len(cur) >= minlen:
|
||||
out.append((start, "".join(cur)))
|
||||
cur = []
|
||||
if len(cur) >= minlen:
|
||||
out.append((start, "".join(cur)))
|
||||
return out
|
||||
|
||||
|
||||
def u16_strings(buf, minlen=3):
|
||||
out, i = [], 0
|
||||
while i < len(buf) - 1:
|
||||
j, chars = i, []
|
||||
while j < len(buf) - 1 and 32 <= buf[j] < 127 and buf[j + 1] == 0:
|
||||
chars.append(chr(buf[j]))
|
||||
j += 2
|
||||
if len(chars) >= minlen:
|
||||
out.append((i, "".join(chars)))
|
||||
i = j
|
||||
else:
|
||||
i += 1
|
||||
return out
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not CAP.exists():
|
||||
print(f"Missing capture: {CAP}\nRun scripts/Capture-DeleteTagExtendedProperties.ps1 first.")
|
||||
return 1
|
||||
|
||||
records = []
|
||||
for line in CAP.open(encoding="utf-8-sig"):
|
||||
if line.strip():
|
||||
records.append(json.loads(line))
|
||||
|
||||
print(f"== {len(records)} MDAS bodies captured (Run B) ==")
|
||||
for idx, rec in enumerate(records):
|
||||
body = base64.b64decode(rec["Base64"])
|
||||
flags = []
|
||||
if OP_DEL in body:
|
||||
flags.append("DelTep")
|
||||
if OP_GET in body:
|
||||
flags.append("GetTepByNm")
|
||||
if TAG.encode("ascii") in body or TAG.encode("utf-16-le") in body:
|
||||
flags.append("TAG")
|
||||
if PROP.encode("ascii") in body or PROP.encode("utf-16-le") in body:
|
||||
flags.append("PROP")
|
||||
print(f" [{idx:02d}] {rec.get('Phase'):26s} len={len(body):5d} {','.join(flags)}")
|
||||
|
||||
print("\n== DelTep request(s) ==")
|
||||
for idx, rec in enumerate(records):
|
||||
body = base64.b64decode(rec["Base64"])
|
||||
if rec.get("Phase") == "WCF.WriteMessage.Body" and OP_DEL in body:
|
||||
hexdump(f"[{idx}] DelTep WriteMessage", body)
|
||||
print(" UTF-16 strings:")
|
||||
for off, s in u16_strings(body):
|
||||
print(f" 0x{off:04X} {s!r}")
|
||||
print(" ASCII strings:")
|
||||
for off, s in ascii_strings(body):
|
||||
print(f" 0x{off:04X} {s!r}")
|
||||
print()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Decode the StartEventQuery filter-block encoding (HCAL R1.7).
|
||||
|
||||
Extracts the `pRequestBuff` from the StartEventQuery WriteMessage body in the baseline
|
||||
(no filter) and filtered captures produced by scripts/Capture-EventFilter.ps1, dumps both,
|
||||
and marks where they diverge so the filter predicate (property name / comparison op / value)
|
||||
can be read off the empty-filter baseline.
|
||||
|
||||
Output is diagnostic. Sanitize before copying into docs/.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
CAPDIR = REPO_ROOT / "artifacts" / "reverse-engineering" / "instrumented-wcf-event-filter"
|
||||
PARAM = b"pRequestBuff"
|
||||
OP = b"StartEventQuery"
|
||||
|
||||
|
||||
def extract_request(path):
|
||||
if not path.exists():
|
||||
return None
|
||||
for line in path.open(encoding="utf-8-sig"):
|
||||
if not line.strip():
|
||||
continue
|
||||
rec = json.loads(line)
|
||||
if rec.get("Phase") != "WCF.WriteMessage.Body":
|
||||
continue
|
||||
body = base64.b64decode(rec["Base64"])
|
||||
if OP not in body:
|
||||
continue
|
||||
i = body.find(PARAM)
|
||||
if i < 0:
|
||||
continue
|
||||
i += len(PARAM)
|
||||
for s in range(i, min(i + 16, len(body))):
|
||||
m = body[s]
|
||||
if m == 0x9E:
|
||||
return body[s + 2:s + 2 + body[s + 1]]
|
||||
if m == 0x9F:
|
||||
n = int.from_bytes(body[s + 1:s + 3], "little")
|
||||
return body[s + 3:s + 3 + n]
|
||||
if m == 0xA0:
|
||||
n = int.from_bytes(body[s + 1:s + 3], "little")
|
||||
return body[s + 3:s + 3 + n]
|
||||
return None
|
||||
|
||||
|
||||
def hexdump(label, buf):
|
||||
print(f"=== {label}: {len(buf)} bytes ===")
|
||||
for off in range(0, len(buf), 16):
|
||||
c = buf[off:off + 16]
|
||||
hp = " ".join(f"{x:02X}" for x in c)
|
||||
ap = "".join(chr(x) if 32 <= x < 127 else "." for x in c)
|
||||
print(f" {off:04X} {hp:<48} |{ap}|")
|
||||
print()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
base = extract_request(CAPDIR / "event-filter-capture-baseline-latest.ndjson")
|
||||
filt = extract_request(CAPDIR / "event-filter-capture-filtered-latest.ndjson")
|
||||
if base is None or filt is None:
|
||||
print("Missing capture(s). Run scripts/Capture-EventFilter.ps1 first.")
|
||||
print(f" baseline: {'ok' if base is not None else 'MISSING'}")
|
||||
print(f" filtered: {'ok' if filt is not None else 'MISSING'}")
|
||||
return 1
|
||||
|
||||
hexdump("baseline (no filter) pRequestBuff", base)
|
||||
hexdump("filtered pRequestBuff", filt)
|
||||
|
||||
# First divergence offset.
|
||||
n = min(len(base), len(filt))
|
||||
div = next((i for i in range(n) if base[i] != filt[i]), n)
|
||||
print(f"== First divergence at offset 0x{div:04X} (lenBase={len(base)} lenFilt={len(filt)}) ==")
|
||||
print(" Filtered bytes from divergence (the inserted filter block):")
|
||||
tail = filt[div:]
|
||||
for off in range(0, len(tail), 16):
|
||||
c = tail[off:off + 16]
|
||||
hp = " ".join(f"{x:02X}" for x in c)
|
||||
ap = "".join(chr(x) if 32 <= x < 127 else "." for x in c)
|
||||
print(f" {div + off:04X} {hp:<48} |{ap}|")
|
||||
|
||||
print("\n== Strings in filtered buffer ==")
|
||||
for enc, label in ((b"ascii", "ASCII"), (None, "UTF-16LE")):
|
||||
if enc == b"ascii":
|
||||
cur, start = [], 0
|
||||
for i, x in enumerate(filt):
|
||||
if 32 <= x < 127:
|
||||
if not cur:
|
||||
start = i
|
||||
cur.append(chr(x))
|
||||
else:
|
||||
if len(cur) >= 3:
|
||||
print(f" {label} 0x{start:04X} {''.join(cur)!r}")
|
||||
cur = []
|
||||
else:
|
||||
i = 0
|
||||
while i < len(filt) - 1:
|
||||
j, chars = i, []
|
||||
while j < len(filt) - 1 and 32 <= filt[j] < 127 and filt[j + 1] == 0:
|
||||
chars.append(chr(filt[j]))
|
||||
j += 2
|
||||
if len(chars) >= 3:
|
||||
print(f" {label} 0x{i:04X} {''.join(chars)!r}")
|
||||
i = j
|
||||
else:
|
||||
i += 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Decide whether native event-send (HCAL R2.1) rides WCF or the storage-engine pipe.
|
||||
|
||||
Reads the both-hooks capture produced by scripts/Capture-EventSend.ps1 and, for every
|
||||
outgoing WCF.WriteMessage.Body, tries to recognise the SOAP action / operation name. It
|
||||
then renders a verdict:
|
||||
|
||||
* If a storage/event delivery op (AddStreamValues / EnqueueEventDataPacket /
|
||||
OpenEventConnection / StartStorage / AddS2 / AddStreamValues2) appears on the WRITE path,
|
||||
event-send is a WCF op → M2 is implementable over WCF and that body carries the
|
||||
PackToVtq event value blob to decode (R2.2).
|
||||
* If NO such op appears on the WRITE path, the queued event was delivered via the
|
||||
storage-engine shared-memory pipe (not WCF) — M2 is architecturally blocked as a
|
||||
pure-managed-WCF SDK, the same conclusion as the revision-write path.
|
||||
|
||||
Output is diagnostic. Sanitize before copying into docs/.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
CAPTURE = (REPO_ROOT / "artifacts" / "reverse-engineering"
|
||||
/ "instrumented-wcf-event-send" / "event-send-capture-latest.ndjson")
|
||||
|
||||
# Operation-name markers we care about. The MDAS binary SOAP body carries the action /
|
||||
# operation name as readable text (ASCII and/or UTF-16LE). We scan for both encodings.
|
||||
EVENT_OR_STORAGE_OPS = [
|
||||
"AddStreamValues2", "AddStreamValues", "EnqueueEventDataPacket", "OpenEventConnection2",
|
||||
"OpenEventConnection", "StartStorage", "AddS2", "ForwardEventSnapshot", "AddStreamedValue",
|
||||
"AddNonStreamValues",
|
||||
]
|
||||
# Other ops we expect to see on a healthy event-send connection (auth/open/registration),
|
||||
# printed for context so a "no event op" result is clearly "delivery left WCF", not "nothing ran".
|
||||
KNOWN_OPS = [
|
||||
"GetV", "ValCl", "Open2", "OpenConnection", "GETHI", "GetSystemParameter",
|
||||
"UpdC3", "UpdateClientStatus3", "RTag2", "RegisterTags2", "EnsT2", "EnsureTags2",
|
||||
"IsOriginalAllowed", "StartQuery", "GetInterfaceVersion",
|
||||
]
|
||||
|
||||
|
||||
def find_ops(body, candidates):
|
||||
hits = []
|
||||
for op in candidates:
|
||||
a = op.encode("ascii")
|
||||
u = op.encode("utf-16-le")
|
||||
if a in body or u in body:
|
||||
hits.append(op)
|
||||
return hits
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not CAPTURE.exists():
|
||||
print(f"Capture not found: {CAPTURE}")
|
||||
print("Run: scripts/Capture-EventSend.ps1")
|
||||
return 1
|
||||
|
||||
with CAPTURE.open(encoding="utf-8-sig") as fh:
|
||||
records = [json.loads(line) for line in fh if line.strip()]
|
||||
|
||||
writes = [r for r in records if r.get("Phase") == "WCF.WriteMessage.Body"]
|
||||
reads = [r for r in records if r.get("Phase") == "WCF.ReadMessage.Body"]
|
||||
print(f"Records: {len(records)} (write={len(writes)} read={len(reads)})\n")
|
||||
|
||||
event_write_hits = []
|
||||
print("== Outgoing WCF.WriteMessage.Body ops ==")
|
||||
for i, r in enumerate(writes):
|
||||
body = base64.b64decode(r["Base64"])
|
||||
known = find_ops(body, KNOWN_OPS)
|
||||
event = find_ops(body, EVENT_OR_STORAGE_OPS)
|
||||
if event:
|
||||
event_write_hits.extend(event)
|
||||
label = ", ".join(event + known) or "<no recognized op>"
|
||||
flag = " <<< EVENT/STORAGE OP" if event else ""
|
||||
print(f" write[{i:02d}] {len(body):6d}B {label}{flag}")
|
||||
|
||||
print("\n== Verdict ==")
|
||||
if event_write_hits:
|
||||
uniq = sorted(set(event_write_hits))
|
||||
print(f" EVENT/STORAGE op(s) on the WCF WRITE path: {uniq}")
|
||||
print(" => event-send IS a WCF op. M2 viable over WCF; decode the PackToVtq value")
|
||||
print(" blob in that body for R2.2.")
|
||||
return 0
|
||||
|
||||
print(" NO event/storage delivery op on the WCF WRITE path.")
|
||||
print(" => the queued event did NOT leave via WCF. If the native AddStreamedValue")
|
||||
print(" returned success (see harness JSON), delivery used the storage-engine")
|
||||
print(" shared-memory pipe — M2 is blocked as a pure-managed-WCF SDK, same as the")
|
||||
print(" revision-write path (docs/plans/revision-write-path.md).")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Decode the AddS2 (AddStreamValues2) pBuf event-VTQ blob captured for event-send (R2.2).
|
||||
|
||||
Extracts the `pBuf` parameter from the AddS2 WriteMessage body in the event-send capture
|
||||
and hex-dumps it, annotating windows that match the known test event so the
|
||||
HistorianEvent.PackToVtq framing can be read off and inverted into a managed serializer.
|
||||
|
||||
Known test event (from scripts/Capture-EventSend.ps1 defaults):
|
||||
Type="User.Write", Namespace="RetestSdkEventSend",
|
||||
properties: Source="RetestSdkEventSend", TestMarker="histsdk-R2.1-capture"
|
||||
|
||||
Output is diagnostic. Sanitize before copying into docs/.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
CAPTURE = (REPO_ROOT / "artifacts" / "reverse-engineering"
|
||||
/ "instrumented-wcf-event-send" / "event-send-capture-latest.ndjson")
|
||||
|
||||
PARAM = b"pBuf"
|
||||
ADDS2 = b"AddS2"
|
||||
|
||||
|
||||
def extract_param(body, param):
|
||||
i = body.find(param)
|
||||
if i < 0:
|
||||
return None
|
||||
i += len(param)
|
||||
# Skip the closing of the element name / attributes until a binary length marker.
|
||||
# MDAS length markers: 0x9E (1-byte len), 0x9F (2-byte len), 0xA0 (2-byte len+1).
|
||||
for scan in range(i, min(i + 16, len(body))):
|
||||
marker = body[scan]
|
||||
if marker == 0x9E:
|
||||
length = body[scan + 1]
|
||||
return body[scan + 2:scan + 2 + length]
|
||||
if marker == 0x9F:
|
||||
length = int.from_bytes(body[scan + 1:scan + 3], "little")
|
||||
return body[scan + 3:scan + 3 + length]
|
||||
if marker == 0xA0:
|
||||
length = int.from_bytes(body[scan + 1:scan + 3], "little")
|
||||
return body[scan + 3:scan + 3 + length + 1]
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not CAPTURE.exists():
|
||||
print(f"Capture not found: {CAPTURE}")
|
||||
return 1
|
||||
|
||||
with CAPTURE.open(encoding="utf-8-sig") as fh:
|
||||
records = [json.loads(line) for line in fh if line.strip()]
|
||||
|
||||
body = None
|
||||
for r in records:
|
||||
if r.get("Phase") != "WCF.WriteMessage.Body":
|
||||
continue
|
||||
b = base64.b64decode(r["Base64"])
|
||||
if ADDS2 in b:
|
||||
body = b
|
||||
break
|
||||
|
||||
if body is None:
|
||||
print("No AddS2 WriteMessage body found.")
|
||||
return 2
|
||||
|
||||
pbuf = extract_param(body, PARAM)
|
||||
if pbuf is None:
|
||||
print("Found AddS2 body but could not extract pBuf. Full body hex dump:")
|
||||
pbuf = body
|
||||
|
||||
print(f"pBuf: {len(pbuf)} bytes\n")
|
||||
for off in range(0, len(pbuf), 16):
|
||||
chunk = pbuf[off:off + 16]
|
||||
hp = " ".join(f"{c:02X}" for c in chunk)
|
||||
ap = "".join(chr(c) if 32 <= c < 127 else "." for c in chunk)
|
||||
print(f" {off:04X} {hp:<48} |{ap}|")
|
||||
|
||||
print("\n== ASCII strings (len>=3) ==")
|
||||
cur = []
|
||||
start = 0
|
||||
for i, c in enumerate(pbuf):
|
||||
if 32 <= c < 127:
|
||||
if not cur:
|
||||
start = i
|
||||
cur.append(chr(c))
|
||||
else:
|
||||
if len(cur) >= 3:
|
||||
print(f" 0x{start:04X} {''.join(cur)!r}")
|
||||
cur = []
|
||||
if len(cur) >= 3:
|
||||
print(f" 0x{start:04X} {''.join(cur)!r}")
|
||||
|
||||
print("\n== UTF-16LE strings (len>=3) ==")
|
||||
i = 0
|
||||
while i < len(pbuf) - 1:
|
||||
j = i
|
||||
chars = []
|
||||
while j < len(pbuf) - 1 and 32 <= pbuf[j] < 127 and pbuf[j + 1] == 0:
|
||||
chars.append(chr(pbuf[j]))
|
||||
j += 2
|
||||
if len(chars) >= 3:
|
||||
print(f" 0x{i:04X} {''.join(chars)!r}")
|
||||
i = j
|
||||
else:
|
||||
i += 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Decode the GetHistorianInfo (GETHI) WCF request/response (HCAL R1.4).
|
||||
|
||||
Reads the chained WriteMessage+ReadMessage capture produced by
|
||||
scripts/Capture-HistorianInfo.ps1 and locates the GetHistorianInfo exchange. The goal is
|
||||
to learn (a) the pRequestBuff that returns the FULL HISTORIAN_INFO struct (distinct from the
|
||||
named-value "HistorianVersion" request) and (b) the response struct layout: the analysis
|
||||
folder says it's 518 bytes with the version string (UTF-16, null-terminated) at offset 0 and
|
||||
EventStorageMode (int32) at offset 514.
|
||||
|
||||
We flag candidate bodies by the GETHI op action, by the server version value, and by a
|
||||
response length near 518, then dump bytes + the int32 at offset 514 so the layout can be
|
||||
read off directly.
|
||||
|
||||
Output is diagnostic. Sanitize before copying into docs/.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
CAPDIR = REPO_ROOT / "artifacts" / "reverse-engineering" / "instrumented-wcf-historian-info"
|
||||
CAP = CAPDIR / "historian-info-capture-latest.ndjson"
|
||||
|
||||
# The GETHI op action (WS-Addressing) the native client sends. The server version value is
|
||||
# version-shaped, not secret; used only to locate the response.
|
||||
OP_ASCII = b"GetHistorianInfo"
|
||||
OP_GETHI = b"GETHI"
|
||||
VERSION = "20,0,000,000"
|
||||
VERSION_U16 = VERSION.encode("utf-16-le")
|
||||
VERSION_ASCII = VERSION.encode("ascii")
|
||||
|
||||
|
||||
def hexdump(label, buf, base=0):
|
||||
print(f"=== {label}: {len(buf)} bytes ===")
|
||||
for off in range(0, len(buf), 16):
|
||||
c = buf[off:off + 16]
|
||||
hp = " ".join(f"{x:02X}" for x in c)
|
||||
ap = "".join(chr(x) if 32 <= x < 127 else "." for x in c)
|
||||
print(f" {base + off:04X} {hp:<48} |{ap}|")
|
||||
print()
|
||||
|
||||
|
||||
def ascii_strings(buf, minlen=3):
|
||||
out, cur, start = [], [], 0
|
||||
for i, x in enumerate(buf):
|
||||
if 32 <= x < 127:
|
||||
if not cur:
|
||||
start = i
|
||||
cur.append(chr(x))
|
||||
else:
|
||||
if len(cur) >= minlen:
|
||||
out.append((start, "".join(cur)))
|
||||
cur = []
|
||||
if len(cur) >= minlen:
|
||||
out.append((start, "".join(cur)))
|
||||
return out
|
||||
|
||||
|
||||
def u16_strings(buf, minlen=3):
|
||||
out, i = [], 0
|
||||
while i < len(buf) - 1:
|
||||
j, chars = i, []
|
||||
while j < len(buf) - 1 and 32 <= buf[j] < 127 and buf[j + 1] == 0:
|
||||
chars.append(chr(buf[j]))
|
||||
j += 2
|
||||
if len(chars) >= minlen:
|
||||
out.append((i, "".join(chars)))
|
||||
i = j
|
||||
else:
|
||||
i += 1
|
||||
return out
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not CAP.exists():
|
||||
print(f"Missing capture: {CAP}\nRun scripts/Capture-HistorianInfo.ps1 first.")
|
||||
return 1
|
||||
|
||||
records = []
|
||||
for line in CAP.open(encoding="utf-8-sig"):
|
||||
if line.strip():
|
||||
records.append(json.loads(line))
|
||||
|
||||
print(f"== {len(records)} MDAS bodies captured ==")
|
||||
for idx, rec in enumerate(records):
|
||||
body = base64.b64decode(rec["Base64"])
|
||||
flags = []
|
||||
if OP_ASCII in body or OP_GETHI in body:
|
||||
flags.append("GETHI-OP")
|
||||
if VERSION_U16 in body or VERSION_ASCII in body:
|
||||
flags.append("VERSION")
|
||||
# A ~518-byte embedded struct is the tell for the full-info response.
|
||||
if 500 <= len(body) <= 4096:
|
||||
flags.append(f"len={len(body)}")
|
||||
print(f" [{idx:02d}] {rec.get('Phase'):26s} len={len(body):5d} {','.join(flags)}")
|
||||
|
||||
def find(predicate):
|
||||
hits = []
|
||||
for idx, rec in enumerate(records):
|
||||
body = base64.b64decode(rec["Base64"])
|
||||
if predicate(rec, body):
|
||||
hits.append((idx, rec, body))
|
||||
return hits
|
||||
|
||||
print("\n== Request candidate(s): WriteMessage bodies tagged GETHI-OP ==")
|
||||
for idx, rec, body in find(lambda r, b: r.get("Phase") == "WCF.WriteMessage.Body"
|
||||
and (OP_ASCII in b or OP_GETHI in b)):
|
||||
hexdump(f"[{idx}] WriteMessage", body)
|
||||
print(" UTF-16 strings:")
|
||||
for off, s in u16_strings(body):
|
||||
print(f" 0x{off:04X} {s!r}")
|
||||
print(" ASCII strings:")
|
||||
for off, s in ascii_strings(body):
|
||||
print(f" 0x{off:04X} {s!r}")
|
||||
print()
|
||||
|
||||
print("\n== Response candidate(s): ReadMessage bodies carrying VERSION ==")
|
||||
for idx, rec, body in find(lambda r, b: r.get("Phase") == "WCF.ReadMessage.Body"
|
||||
and (VERSION_U16 in b or VERSION_ASCII in b)):
|
||||
hexdump(f"[{idx}] ReadMessage", body)
|
||||
print(" UTF-16 strings:")
|
||||
for off, s in u16_strings(body):
|
||||
print(f" 0x{off:04X} {s!r}")
|
||||
# The analysis folder pins EventStorageMode @ offset 514 (int32) inside the
|
||||
# 518-byte struct. The struct is embedded in the MDAS body at some base; scan for
|
||||
# a plausible version@0 run and print the int32 514 bytes after each candidate base.
|
||||
print(" Candidate struct decodes (version@base, int32 @ base+514):")
|
||||
for base_off, s in u16_strings(body):
|
||||
if any(ch.isdigit() for ch in s) and "," in s:
|
||||
idx514 = base_off + 514
|
||||
if idx514 + 4 <= len(body):
|
||||
mode = struct.unpack_from("<i", body, idx514)[0]
|
||||
print(f" base=0x{base_off:04X} version={s!r} int32@+514={mode}")
|
||||
print()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Decode the RenameTags WCF request/response (HCAL R1.10).
|
||||
|
||||
Reads the chained WriteMessage+ReadMessage capture produced by scripts/Capture-RenameTags.ps1
|
||||
and locates the rename exchange. Rename maps to the generic job framework:
|
||||
|
||||
StJb (StartJob): WriteMessage carries op "StJb" + a string handle + the rename jobBuffer
|
||||
(the (old,new) name pairs). ReadMessage carries the returned jobId string.
|
||||
GtJb (GetJobStatus): WriteMessage carries op "GtJb" + handle + jobId. ReadMessage carries
|
||||
the job-status buffer.
|
||||
|
||||
We flag bodies by the StJb/GtJb op and by the sandbox names, then dump the buffers so the
|
||||
jobBuffer layout (batch count + old/new UTF-16 framing) can be read off directly.
|
||||
|
||||
Output is diagnostic. Sanitize before copying into docs/.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
CAPDIR = REPO_ROOT / "artifacts" / "reverse-engineering" / "instrumented-wcf-rename"
|
||||
CAP = CAPDIR / "rename-capture-latest.ndjson"
|
||||
|
||||
# Sandbox names used by the default capture run (not secret).
|
||||
FROM = "RetestSdkWriteRenameSrc"
|
||||
TO = "RetestSdkWriteRenameDst"
|
||||
OP_STJB = b"StJb"
|
||||
OP_GTJB = b"GtJb"
|
||||
|
||||
|
||||
def hexdump(label, buf, base=0):
|
||||
print(f"=== {label}: {len(buf)} bytes ===")
|
||||
for off in range(0, len(buf), 16):
|
||||
c = buf[off:off + 16]
|
||||
hp = " ".join(f"{x:02X}" for x in c)
|
||||
ap = "".join(chr(x) if 32 <= x < 127 else "." for x in c)
|
||||
print(f" {base + off:04X} {hp:<48} |{ap}|")
|
||||
print()
|
||||
|
||||
|
||||
def ascii_strings(buf, minlen=3):
|
||||
out, cur, start = [], [], 0
|
||||
for i, x in enumerate(buf):
|
||||
if 32 <= x < 127:
|
||||
if not cur:
|
||||
start = i
|
||||
cur.append(chr(x))
|
||||
else:
|
||||
if len(cur) >= minlen:
|
||||
out.append((start, "".join(cur)))
|
||||
cur = []
|
||||
if len(cur) >= minlen:
|
||||
out.append((start, "".join(cur)))
|
||||
return out
|
||||
|
||||
|
||||
def u16_strings(buf, minlen=3):
|
||||
out, i = [], 0
|
||||
while i < len(buf) - 1:
|
||||
j, chars = i, []
|
||||
while j < len(buf) - 1 and 32 <= buf[j] < 127 and buf[j + 1] == 0:
|
||||
chars.append(chr(buf[j]))
|
||||
j += 2
|
||||
if len(chars) >= minlen:
|
||||
out.append((i, "".join(chars)))
|
||||
i = j
|
||||
else:
|
||||
i += 1
|
||||
return out
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not CAP.exists():
|
||||
print(f"Missing capture: {CAP}\nRun scripts/Capture-RenameTags.ps1 first.")
|
||||
return 1
|
||||
|
||||
records = []
|
||||
for line in CAP.open(encoding="utf-8-sig"):
|
||||
if line.strip():
|
||||
records.append(json.loads(line))
|
||||
|
||||
from_u16, to_u16 = FROM.encode("utf-16-le"), TO.encode("utf-16-le")
|
||||
from_a, to_a = FROM.encode("ascii"), TO.encode("ascii")
|
||||
|
||||
print(f"== {len(records)} MDAS bodies captured ==")
|
||||
for idx, rec in enumerate(records):
|
||||
body = base64.b64decode(rec["Base64"])
|
||||
flags = []
|
||||
if OP_STJB in body:
|
||||
flags.append("StJb")
|
||||
if OP_GTJB in body:
|
||||
flags.append("GtJb")
|
||||
if from_u16 in body or from_a in body:
|
||||
flags.append("FROM")
|
||||
if to_u16 in body or to_a in body:
|
||||
flags.append("TO")
|
||||
print(f" [{idx:02d}] {rec.get('Phase'):26s} len={len(body):5d} {','.join(flags)}")
|
||||
|
||||
def find(predicate):
|
||||
hits = []
|
||||
for idx, rec in enumerate(records):
|
||||
body = base64.b64decode(rec["Base64"])
|
||||
if predicate(rec, body):
|
||||
hits.append((idx, rec, body))
|
||||
return hits
|
||||
|
||||
print("\n== StJb request(s): WriteMessage bodies tagged StJb ==")
|
||||
for idx, rec, body in find(lambda r, b: r.get("Phase") == "WCF.WriteMessage.Body" and OP_STJB in b):
|
||||
hexdump(f"[{idx}] StJb WriteMessage", body)
|
||||
print(" UTF-16 strings:")
|
||||
for off, s in u16_strings(body):
|
||||
print(f" 0x{off:04X} {s!r}")
|
||||
print(" ASCII strings:")
|
||||
for off, s in ascii_strings(body):
|
||||
print(f" 0x{off:04X} {s!r}")
|
||||
print()
|
||||
|
||||
print("\n== StJb / GtJb response(s) + GtJb request(s) ==")
|
||||
for idx, rec, body in find(lambda r, b: (OP_STJB in b or OP_GTJB in b) and r.get("Phase") == "WCF.ReadMessage.Body"):
|
||||
hexdump(f"[{idx}] {rec.get('Phase')}", body)
|
||||
print(" strings:", [s for _, s in ascii_strings(body)][:16])
|
||||
print()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Decode the GetRuntimeParameter WCF request/response (HCAL R1.2).
|
||||
|
||||
Reads the chained WriteMessage+ReadMessage capture produced by
|
||||
scripts/Capture-RuntimeParam.ps1 and locates the GetRuntimeParameter exchange by
|
||||
searching every MDAS body for the parameter name (UTF-16) on the request side and the
|
||||
returned value on the response side. Dumps the surrounding bytes so the op name, the
|
||||
leading handle parameter, and the btRequest/btResponse buffer layout can be read off.
|
||||
|
||||
Output is diagnostic. Sanitize before copying into docs/.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
CAPDIR = REPO_ROOT / "artifacts" / "reverse-engineering" / "instrumented-wcf-runtime-param"
|
||||
CAP = CAPDIR / "runtime-param-capture-latest.ndjson"
|
||||
|
||||
# Markers we expect on the wire for the default "HistorianVersion" capture.
|
||||
NAME = "HistorianVersion"
|
||||
NAME_U16 = NAME.encode("utf-16-le")
|
||||
NAME_ASCII = NAME.encode("ascii")
|
||||
VALUE = "20,0,000,000" # server runtime "HistorianVersion" value (version-shaped, not secret)
|
||||
VALUE_U16 = VALUE.encode("utf-16-le")
|
||||
VALUE_ASCII = VALUE.encode("ascii")
|
||||
|
||||
|
||||
def hexdump(label, buf, base=0):
|
||||
print(f"=== {label}: {len(buf)} bytes ===")
|
||||
for off in range(0, len(buf), 16):
|
||||
c = buf[off:off + 16]
|
||||
hp = " ".join(f"{x:02X}" for x in c)
|
||||
ap = "".join(chr(x) if 32 <= x < 127 else "." for x in c)
|
||||
print(f" {base + off:04X} {hp:<48} |{ap}|")
|
||||
print()
|
||||
|
||||
|
||||
def ascii_strings(buf, minlen=3):
|
||||
out, cur, start = [], [], 0
|
||||
for i, x in enumerate(buf):
|
||||
if 32 <= x < 127:
|
||||
if not cur:
|
||||
start = i
|
||||
cur.append(chr(x))
|
||||
else:
|
||||
if len(cur) >= minlen:
|
||||
out.append((start, "".join(cur)))
|
||||
cur = []
|
||||
if len(cur) >= minlen:
|
||||
out.append((start, "".join(cur)))
|
||||
return out
|
||||
|
||||
|
||||
def u16_strings(buf, minlen=3):
|
||||
out, i = [], 0
|
||||
while i < len(buf) - 1:
|
||||
j, chars = i, []
|
||||
while j < len(buf) - 1 and 32 <= buf[j] < 127 and buf[j + 1] == 0:
|
||||
chars.append(chr(buf[j]))
|
||||
j += 2
|
||||
if len(chars) >= minlen:
|
||||
out.append((i, "".join(chars)))
|
||||
i = j
|
||||
else:
|
||||
i += 1
|
||||
return out
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not CAP.exists():
|
||||
print(f"Missing capture: {CAP}\nRun scripts/Capture-RuntimeParam.ps1 first.")
|
||||
return 1
|
||||
|
||||
records = []
|
||||
for line in CAP.open(encoding="utf-8-sig"):
|
||||
if line.strip():
|
||||
records.append(json.loads(line))
|
||||
|
||||
print(f"== {len(records)} MDAS bodies captured ==")
|
||||
for idx, rec in enumerate(records):
|
||||
body = base64.b64decode(rec["Base64"])
|
||||
flags = []
|
||||
if NAME_U16 in body or NAME_ASCII in body:
|
||||
flags.append("NAME")
|
||||
if VALUE_U16 in body or VALUE_ASCII in body:
|
||||
flags.append("VALUE")
|
||||
# The WS-Addressing action is the most reliable op label; show any string that
|
||||
# looks like an op (contains a slash or is short and capitalized).
|
||||
print(f" [{idx:02d}] {rec.get('Phase'):26s} len={len(body):5d} {','.join(flags)}")
|
||||
|
||||
def find(predicate):
|
||||
hits = []
|
||||
for idx, rec in enumerate(records):
|
||||
body = base64.b64decode(rec["Base64"])
|
||||
if predicate(rec, body):
|
||||
hits.append((idx, rec, body))
|
||||
return hits
|
||||
|
||||
print("\n== Request candidate(s): WriteMessage bodies containing the NAME ==")
|
||||
for idx, rec, body in find(lambda r, b: r.get("Phase") == "WCF.WriteMessage.Body"
|
||||
and (NAME_U16 in b or NAME_ASCII in b)):
|
||||
hexdump(f"[{idx}] WriteMessage", body)
|
||||
print(" UTF-16 strings:")
|
||||
for off, s in u16_strings(body):
|
||||
print(f" 0x{off:04X} {s!r}")
|
||||
print(" ASCII strings:")
|
||||
for off, s in ascii_strings(body):
|
||||
print(f" 0x{off:04X} {s!r}")
|
||||
print()
|
||||
|
||||
print("\n== Response candidate(s): ReadMessage bodies containing the VALUE ==")
|
||||
for idx, rec, body in find(lambda r, b: r.get("Phase") == "WCF.ReadMessage.Body"
|
||||
and (VALUE_U16 in b or VALUE_ASCII in b)):
|
||||
hexdump(f"[{idx}] ReadMessage", body)
|
||||
print(" UTF-16 strings:")
|
||||
for off, s in u16_strings(body):
|
||||
print(f" 0x{off:04X} {s!r}")
|
||||
print(" ASCII strings:")
|
||||
for off, s in ascii_strings(body):
|
||||
print(f" 0x{off:04X} {s!r}")
|
||||
print()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Decoder for the analog/state summary request capture (HCAL roadmap R1.8/R1.9).
|
||||
|
||||
Reads the per-config NDJSON captures produced by scripts/Capture-SummaryRequest.ps1
|
||||
under artifacts/reverse-engineering/instrumented-wcf-writemessage-summary/, extracts
|
||||
the Retr/StartQuery2 `pRequestBuff` payload from each, hex-dumps it, and diffs every
|
||||
summary candidate against the baseline-full request so the differing bytes (the native
|
||||
QueryType / SummaryType / AutoSummaryParameters fields) stand out.
|
||||
|
||||
Output is diagnostic. The only printed strings are the SDK-chosen system tag name and
|
||||
protocol field markers — sanitize before copying any of it into docs/.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
CAPTURE_DIR = REPO_ROOT / "artifacts" / "reverse-engineering" / "instrumented-wcf-writemessage-summary"
|
||||
|
||||
ACTION = b"aa/Retr/StartQuery2"
|
||||
PARAM = b"pRequestBuff"
|
||||
|
||||
|
||||
def extract_request_buffer(records):
|
||||
"""Return the pRequestBuff bytes from the first StartQuery2 write record, or None."""
|
||||
for rec in records:
|
||||
if rec.get("Phase") != "WCF.WriteMessage.Body":
|
||||
continue
|
||||
body = base64.b64decode(rec["Base64"])
|
||||
if ACTION not in body:
|
||||
continue
|
||||
i = body.find(PARAM)
|
||||
if i < 0:
|
||||
continue
|
||||
i += len(PARAM)
|
||||
marker = body[i]
|
||||
# MDAS length markers (same scheme as the write decoder).
|
||||
if marker == 0x9E:
|
||||
length = body[i + 1]
|
||||
return body[i + 2:i + 2 + length]
|
||||
if marker == 0x9F:
|
||||
length = int.from_bytes(body[i + 1:i + 3], "little")
|
||||
return body[i + 3:i + 3 + length]
|
||||
if marker == 0xA0:
|
||||
length = int.from_bytes(body[i + 1:i + 3], "little")
|
||||
return body[i + 3:i + 3 + length + 1]
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def hexdump(payload, diff_against=None):
|
||||
for off in range(0, len(payload), 16):
|
||||
chunk = payload[off:off + 16]
|
||||
cells = []
|
||||
for j, c in enumerate(chunk):
|
||||
mark = ""
|
||||
if diff_against is not None:
|
||||
k = off + j
|
||||
if k >= len(diff_against) or diff_against[k] != c:
|
||||
mark = "*"
|
||||
cells.append(f"{c:02X}{mark}")
|
||||
hp = " ".join(cells)
|
||||
ap = "".join(chr(c) if 32 <= c < 127 else "." for c in chunk)
|
||||
print(f" {off:04X} {hp:<56} |{ap}|")
|
||||
|
||||
|
||||
def load(path):
|
||||
with path.open(encoding="utf-8-sig") as fh:
|
||||
return [json.loads(line) for line in fh if line.strip()]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not CAPTURE_DIR.exists():
|
||||
print(f"Capture dir not found: {CAPTURE_DIR}")
|
||||
print("Run scripts/Capture-SummaryRequest.ps1 first.")
|
||||
return 1
|
||||
|
||||
captures = sorted(CAPTURE_DIR.glob("summary-capture-*-latest.ndjson"))
|
||||
if not captures:
|
||||
print(f"No capture files in {CAPTURE_DIR}")
|
||||
return 1
|
||||
|
||||
buffers = {}
|
||||
for path in captures:
|
||||
name = path.stem.replace("summary-capture-", "").replace("-latest", "")
|
||||
records = load(path)
|
||||
buf = extract_request_buffer(records)
|
||||
buffers[name] = buf
|
||||
status = f"{len(buf)} bytes" if buf else "<no StartQuery2 request found>"
|
||||
print(f"{name:<18} records={len(records):>3} pRequestBuff={status}")
|
||||
|
||||
baseline = buffers.get("baseline-full")
|
||||
print()
|
||||
if not baseline:
|
||||
print("No baseline-full request buffer captured; cannot diff. Dumping each raw.")
|
||||
for name, buf in buffers.items():
|
||||
if buf:
|
||||
print(f"\n== {name} pRequestBuff ({len(buf)} bytes) ==")
|
||||
hexdump(buf)
|
||||
return 0
|
||||
|
||||
print(f"== baseline-full pRequestBuff ({len(baseline)} bytes) ==")
|
||||
hexdump(baseline)
|
||||
|
||||
for name, buf in buffers.items():
|
||||
if name == "baseline-full" or not buf:
|
||||
continue
|
||||
print(f"\n== {name} pRequestBuff ({len(buf)} bytes) — '*' marks bytes differing from baseline ==")
|
||||
hexdump(buf, diff_against=baseline)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Decode the GetNextQueryResultBuffer2 *response* for an analog summary (HCAL R1.8).
|
||||
|
||||
Reads the both-hooks capture produced by
|
||||
scripts/Capture-SummaryRequest.ps1 -OnlyConfig analog-avg -WithResponse
|
||||
finds the ReadMessage record carrying GetNextQueryResultBuffer2Response, extracts the
|
||||
`pResultBuff` payload, hex-dumps it, and annotates every 8-byte window that decodes to a
|
||||
known ground-truth value (the AnalogSummaryHistory row for SysTimeSec) so the field offsets
|
||||
of CAnalogSummaryValue can be read off directly.
|
||||
|
||||
Output is diagnostic; the only printed strings are the SDK-chosen system tag name and field
|
||||
markers. Sanitize before copying into docs/.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
# Config name (analog-avg / analog-min / analog-max / …) selectable via argv[1].
|
||||
CONFIG = sys.argv[1] if len(sys.argv) > 1 else "analog-avg"
|
||||
CAPTURE = (REPO_ROOT / "artifacts" / "reverse-engineering"
|
||||
/ "instrumented-wcf-writemessage-summary" / f"summary-capture-{CONFIG}-latest.ndjson")
|
||||
|
||||
RESP = b"GetNextQueryResultBuffer2Response"
|
||||
PARAM = b"pResultBuff"
|
||||
|
||||
# Ground-truth values from AnalogSummaryHistory(SysTimeSec, 1h cycle) — used to label offsets.
|
||||
KNOWN_DOUBLES = {
|
||||
31.0: "31.0 (First/Last/Average)",
|
||||
100.0: "100.0 (PercentGood)",
|
||||
0.031: "0.031 (Integral)",
|
||||
111600.0: "111600.0 (Integral, full-cycle)",
|
||||
1.0: "1.0 (ValueCount as double?)",
|
||||
}
|
||||
KNOWN_U32 = {
|
||||
1: "ValueCount=1",
|
||||
192: "OPCQuality=192",
|
||||
100: "PercentGood=100",
|
||||
9: "version=9",
|
||||
}
|
||||
|
||||
|
||||
def extract_param(body, param):
|
||||
i = body.find(param)
|
||||
if i < 0:
|
||||
return None
|
||||
i += len(param)
|
||||
marker = body[i]
|
||||
if marker == 0x9E:
|
||||
length = body[i + 1]
|
||||
return body[i + 2:i + 2 + length]
|
||||
if marker == 0x9F:
|
||||
length = int.from_bytes(body[i + 1:i + 3], "little")
|
||||
return body[i + 3:i + 3 + length]
|
||||
if marker == 0xA0:
|
||||
length = int.from_bytes(body[i + 1:i + 3], "little")
|
||||
return body[i + 3:i + 3 + length + 1]
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not CAPTURE.exists():
|
||||
print(f"Capture not found: {CAPTURE}")
|
||||
print("Run: scripts/Capture-SummaryRequest.ps1 -OnlyConfig analog-avg -WithResponse")
|
||||
return 1
|
||||
|
||||
with CAPTURE.open(encoding="utf-8-sig") as fh:
|
||||
records = [json.loads(line) for line in fh if line.strip()]
|
||||
|
||||
payload = None
|
||||
for rec in records:
|
||||
if rec.get("Phase") != "WCF.ReadMessage.Body":
|
||||
continue
|
||||
body = base64.b64decode(rec["Base64"])
|
||||
if RESP not in body:
|
||||
continue
|
||||
payload = extract_param(body, PARAM)
|
||||
break
|
||||
|
||||
if payload is None:
|
||||
print("No GetNextQueryResultBuffer2Response / pResultBuff found in capture.")
|
||||
return 2
|
||||
|
||||
print(f"pResultBuff: {len(payload)} bytes")
|
||||
if len(payload) >= 6:
|
||||
version = int.from_bytes(payload[0:2], "little")
|
||||
row_count = int.from_bytes(payload[2:6], "little")
|
||||
print(f" header: version={version} rowCount={row_count}")
|
||||
print()
|
||||
|
||||
# Annotated hex dump.
|
||||
for off in range(0, len(payload), 16):
|
||||
chunk = payload[off:off + 16]
|
||||
hp = " ".join(f"{c:02X}" for c in chunk)
|
||||
ap = "".join(chr(c) if 32 <= c < 127 else "." for c in chunk)
|
||||
print(f" {off:04X} {hp:<48} |{ap}|")
|
||||
|
||||
# Scan every 8-byte window for known doubles, and every 4-byte window for known u32s.
|
||||
print("\n== Known-value hits (offset -> field) ==")
|
||||
for off in range(0, len(payload) - 7):
|
||||
val = struct.unpack_from("<d", payload, off)[0]
|
||||
for known, label in KNOWN_DOUBLES.items():
|
||||
if val == known or (known != 0 and abs(val - known) < 1e-9 * max(1.0, abs(known))):
|
||||
print(f" 0x{off:04X} double {val!r:>14} -> {label}")
|
||||
for off in range(0, len(payload) - 3):
|
||||
val = int.from_bytes(payload[off:off + 4], "little")
|
||||
if val in KNOWN_U32:
|
||||
print(f" 0x{off:04X} uint32 {val:>14} -> {KNOWN_U32[val]}")
|
||||
|
||||
# FILETIME windows (plausible 2026 timestamps: 0x01DC.. high dword).
|
||||
print("\n== Plausible FILETIME windows (Int64, year ~2020-2030) ==")
|
||||
for off in range(0, len(payload) - 7):
|
||||
ft = int.from_bytes(payload[off:off + 8], "little")
|
||||
# FILETIME for 2020-01-01 ~= 0x01D5BF.. ; 2030 ~= 0x01E5.. — gate by high word.
|
||||
if 0x01D5_0000_0000_0000 <= ft <= 0x01E6_0000_0000_0000:
|
||||
print(f" 0x{off:04X} filetime 0x{ft:016X}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Decode the GetTagExtendedPropertiesFromName (GetTepByNm) WCF request/response (HCAL R1.5).
|
||||
|
||||
Reads the chained WriteMessage+ReadMessage capture produced by
|
||||
scripts/Capture-TagExtendedProperties.ps1, locates the aa/Retr/GetTepByNm exchange, and
|
||||
dumps the tagNames request buffer + tagExtendedProperties response buffer so the op name,
|
||||
the uppercase string handle, the tagNames layout, and the extended-property response layout
|
||||
can be read off.
|
||||
|
||||
Request tagNames buffer:
|
||||
uint32 count + per name: uint32 charCount + UTF-16LE chars.
|
||||
|
||||
Response tagExtendedProperties buffer:
|
||||
uint32 tagCount
|
||||
per tag: byte marker(0x01) + compact-ASCII tagName(0x09 + uint16 len + ascii)
|
||||
uint32 propCount
|
||||
per prop: byte marker(0x02) + compact-ASCII propName(0x09 + uint16 len + ascii)
|
||||
value: 0x43 (VT_BSTR) + uint16 payloadLen + uint16 charCount + UTF-16LE
|
||||
trailing byte(0x01)
|
||||
|
||||
Output is diagnostic. Sanitize before copying into docs/ (tag names / values are dev data).
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
CAPDIR = REPO_ROOT / "artifacts" / "reverse-engineering" / "instrumented-wcf-tag-extended-properties"
|
||||
DEFAULT_CAP = CAPDIR / "tep-localized-capture.ndjson"
|
||||
|
||||
ACTION = re.compile(rb"aa/[A-Za-z0-9]+/[A-Za-z0-9_]+")
|
||||
|
||||
|
||||
def hexdump(label, buf):
|
||||
print(f"=== {label}: {len(buf)} bytes ===")
|
||||
for off in range(0, len(buf), 16):
|
||||
c = buf[off:off + 16]
|
||||
hp = " ".join(f"{x:02X}" for x in c)
|
||||
ap = "".join(chr(x) if 32 <= x < 127 else "." for x in c)
|
||||
print(f" {off:04X} {hp:<48} |{ap}|")
|
||||
print()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
cap = Path(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_CAP
|
||||
if not cap.exists():
|
||||
print(f"Missing capture: {cap}\nRun scripts/Capture-TagExtendedProperties.ps1 -Localized first.")
|
||||
return 1
|
||||
|
||||
records = [json.loads(l) for l in cap.open(encoding="utf-8-sig") if l.strip()]
|
||||
print(f"== {len(records)} MDAS bodies captured ==")
|
||||
for idx, rec in enumerate(records):
|
||||
body = base64.b64decode(rec["Base64"])
|
||||
acts = sorted({m.decode() for m in ACTION.findall(body)})
|
||||
flag = " <== GetTepByNm" if any("Tep" in a for a in acts) else ""
|
||||
print(f" [{idx:02d}] {rec.get('Phase'):24s} len={len(body):5d} {acts}{flag}")
|
||||
|
||||
print("\n== GetTepByNm request(s) [WriteMessage] ==")
|
||||
for idx, rec in enumerate(records):
|
||||
body = base64.b64decode(rec["Base64"])
|
||||
if rec.get("Phase") == "WCF.WriteMessage.Body" and b"GetTepByNm" in body:
|
||||
hexdump(f"[{idx}] request", body)
|
||||
|
||||
print("\n== GetTepByNm response(s) [ReadMessage] ==")
|
||||
for idx, rec in enumerate(records):
|
||||
body = base64.b64decode(rec["Base64"])
|
||||
if rec.get("Phase") == "WCF.ReadMessage.Body" and b"GetTepByNmResponse" in body:
|
||||
hexdump(f"[{idx}] response", body)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,173 @@
|
||||
// Frida hook for the native ExchangeKey credential-token crypto (Windows CNG / bcrypt.dll).
|
||||
// Traces the ECDH secret agreement, the KDF (with its parameter list), symmetric-key import, and
|
||||
// encrypt/hash so the 26-byte v8 credential-token derivation can be reconstructed in managed code.
|
||||
// Reverse-engineering aid only — observes the native client; nothing is shipped from here.
|
||||
'use strict';
|
||||
|
||||
function resolve(modName, fnName) {
|
||||
let m = null;
|
||||
try { m = Process.getModuleByName(modName); } catch (e) {
|
||||
try { m = Module.load(modName); } catch (e2) { return null; }
|
||||
}
|
||||
try { return m.findExportByName(fnName); } catch (e) { return null; }
|
||||
}
|
||||
|
||||
function dump(label, ptr, len) {
|
||||
if (ptr.isNull() || len <= 0) { console.log(label + ' <empty>'); return; }
|
||||
const n = Math.min(len, 256);
|
||||
console.log(label + ' (' + len + ' bytes)\n' + hexdump(ptr, { length: n, header: false, ansi: false }));
|
||||
}
|
||||
|
||||
function hook(modName, fnName, onEnter, onLeave) {
|
||||
const addr = resolve(modName, fnName);
|
||||
if (!addr) { console.log('[skip] ' + modName + '!' + fnName + ' not found'); return; }
|
||||
Interceptor.attach(addr, { onEnter: onEnter, onLeave: onLeave });
|
||||
console.log('[hooked] ' + modName + '!' + fnName);
|
||||
}
|
||||
|
||||
// BCryptOpenAlgorithmProvider(phAlgorithm, pszAlgId, pszImplementation, dwFlags) — names every algo used.
|
||||
hook('bcrypt.dll', 'BCryptOpenAlgorithmProvider', function (a) {
|
||||
console.log('[OpenAlgorithmProvider] algId=' + (a[1].isNull() ? '?' : a[1].readUtf16String()));
|
||||
});
|
||||
|
||||
// BCryptSecretAgreement(hPrivKey, hPubKey, *phAgreedSecret, flags)
|
||||
hook('bcrypt.dll', 'BCryptSecretAgreement', function (a) {
|
||||
console.log('[SecretAgreement] hPriv=' + a[0] + ' hPub=' + a[1]);
|
||||
});
|
||||
|
||||
// Decode a BCryptBufferDesc parameter list (used by BCryptDeriveKey) into (type -> bytes).
|
||||
function dumpParamList(pParamList) {
|
||||
if (pParamList.isNull()) { console.log(' paramList <null>'); return; }
|
||||
const cBuffers = pParamList.add(4).readU32(); // ULONG ulVersion; ULONG cBuffers;
|
||||
const pBuffers = pParamList.add(8).readPointer(); // BCryptBuffer* pBuffers;
|
||||
const names = { 0: 'HASH_ALGORITHM', 1: 'SECRET_PREPEND', 2: 'SECRET_APPEND', 3: 'HMAC_KEY',
|
||||
4: 'TLS_PRF_LABEL', 5: 'TLS_PRF_SEED', 6: 'SECRET_HANDLE', 8: 'SP80056A_CONCAT',
|
||||
0xD: 'LABEL', 0xE: 'CONTEXT', 0xF: 'SALT', 0x10: 'ITERATION_COUNT' };
|
||||
console.log(' paramList cBuffers=' + cBuffers);
|
||||
for (let i = 0; i < cBuffers; i++) {
|
||||
const b = pBuffers.add(i * 16); // { ULONG cbBuffer; ULONG BufferType; PVOID pvBuffer; }
|
||||
const cb = b.readU32();
|
||||
const type = b.add(4).readU32();
|
||||
const pv = b.add(8).readPointer();
|
||||
const tn = names[type] || ('0x' + type.toString(16));
|
||||
if (type === 0 || type === 4 || type === 0xD) { // string-ish (hash alg name / label)
|
||||
console.log(' [' + tn + '] ' + (pv.isNull() ? '?' : pv.readUtf16String()));
|
||||
} else {
|
||||
dump(' [' + tn + ']', pv, cb);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BCryptDeriveKey(hSecret, pwszKDF, *pParamList, pbDerivedKey, cbDerivedKey, *pcbResult, flags)
|
||||
hook('bcrypt.dll', 'BCryptDeriveKey', function (a) {
|
||||
this.kdf = a[1].isNull() ? '?' : a[1].readUtf16String();
|
||||
this.outKey = a[3]; this.pcb = a[5];
|
||||
console.log('[DeriveKey] KDF=' + this.kdf + ' cbDerivedKey=' + a[4].toInt32());
|
||||
dumpParamList(a[2]);
|
||||
}, function () {
|
||||
const n = this.pcb.isNull() ? 0 : this.pcb.readU32();
|
||||
dump('[DeriveKey] derived', this.outKey, n);
|
||||
});
|
||||
|
||||
hook('bcrypt.dll', 'BCryptDeriveKeyPBKDF2', function (a) {
|
||||
console.log('[PBKDF2] cbPassword=' + a[2].toInt32() + ' cbSalt=' + a[4].toInt32() + ' iter=' + a[5]);
|
||||
dump(' password', a[1], a[2].toInt32());
|
||||
dump(' salt', a[3], a[4].toInt32());
|
||||
});
|
||||
|
||||
// BCryptGenerateSymmetricKey(hAlg, *phKey, pbKeyObject, cbKeyObject, pbSecret, cbSecret, flags) — the actual key bytes.
|
||||
hook('bcrypt.dll', 'BCryptGenerateSymmetricKey', function (a) {
|
||||
dump('[GenerateSymmetricKey] keyBytes', a[4], a[5].toInt32());
|
||||
});
|
||||
|
||||
// BCryptEncrypt(hKey, pbIn, cbIn, *pPad, pbIV, cbIV, pbOut, cbOut, *pcbResult, flags)
|
||||
hook('bcrypt.dll', 'BCryptEncrypt', function (a) {
|
||||
this.out = a[6]; this.pcb = a[8];
|
||||
dump('[Encrypt] plaintext', a[1], a[2].toInt32());
|
||||
dump('[Encrypt] IV', a[4], a[5].toInt32());
|
||||
}, function () {
|
||||
const n = this.pcb.isNull() ? 0 : this.pcb.readU32();
|
||||
dump('[Encrypt] ciphertext', this.out, n);
|
||||
});
|
||||
|
||||
// Hash path (in case the token is a keyed hash rather than a cipher).
|
||||
hook('bcrypt.dll', 'BCryptHashData', function (a) {
|
||||
dump('[HashData] input', a[1], a[2].toInt32());
|
||||
});
|
||||
hook('bcrypt.dll', 'BCryptFinishHash', function (a) {
|
||||
this.out = a[1]; this.cb = a[2].toInt32();
|
||||
}, function () {
|
||||
dump('[FinishHash] digest', this.out, this.cb);
|
||||
});
|
||||
|
||||
// ---- NCrypt (CNG key-storage layer) — the likely home of the ECDH ExchangeKey + token crypto ----
|
||||
|
||||
// NCryptSecretAgreement(hPrivKey, hPubKey, *phAgreedSecret, dwFlags)
|
||||
hook('ncrypt.dll', 'NCryptSecretAgreement', function (a) {
|
||||
console.log('[NCryptSecretAgreement] hPriv=' + a[0] + ' hPub=' + a[1]);
|
||||
console.log(' backtrace (addr -> module+offset):');
|
||||
Thread.backtrace(this.context, Backtracer.ACCURATE).slice(0, 14).forEach(function (addr) {
|
||||
const m = Process.findModuleByAddress(addr);
|
||||
if (m) {
|
||||
console.log(' ' + addr + ' ' + m.name + '+0x' + addr.sub(m.base).toString(16));
|
||||
} else {
|
||||
console.log(' ' + addr + ' <JIT/unknown>');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// NCryptDeriveKey(hSharedSecret, pwszKDF, *pParameterList, pbDerivedKey, cbDerivedKey, *pcbResult, dwFlags)
|
||||
hook('ncrypt.dll', 'NCryptDeriveKey', function (a) {
|
||||
this.kdf = a[1].isNull() ? '?' : a[1].readUtf16String();
|
||||
this.outKey = a[3]; this.pcb = a[5];
|
||||
console.log('[NCryptDeriveKey] KDF=' + this.kdf + ' cbDerivedKey=' + a[4].toInt32());
|
||||
dumpParamList(a[2]);
|
||||
}, function () {
|
||||
const n = this.pcb.isNull() ? 0 : this.pcb.readU32();
|
||||
dump('[NCryptDeriveKey] derived', this.outKey, n);
|
||||
});
|
||||
|
||||
// NCryptEncrypt(hKey, pbInput, cbInput, *pPaddingInfo, pbOutput, cbOutput, *pcbResult, dwFlags)
|
||||
hook('ncrypt.dll', 'NCryptEncrypt', function (a) {
|
||||
this.out = a[4]; this.pcb = a[6];
|
||||
dump('[NCryptEncrypt] plaintext', a[1], a[2].toInt32());
|
||||
}, function () {
|
||||
const n = this.pcb.isNull() ? 0 : this.pcb.readU32();
|
||||
dump('[NCryptEncrypt] ciphertext', this.out, n);
|
||||
});
|
||||
|
||||
// NCryptImportKey(hProvider, hImportKey, pszBlobType, *pParameterList, *phKey, pbData, cbData, dwFlags)
|
||||
hook('ncrypt.dll', 'NCryptImportKey', function (a) {
|
||||
console.log('[NCryptImportKey] blobType=' + (a[2].isNull() ? '?' : a[2].readUtf16String()));
|
||||
dump(' blob', a[5], a[6].toInt32());
|
||||
});
|
||||
|
||||
// NCryptExportKey(hKey, hExportKey, pszBlobType, *pParameterList, pbOutput, cbOutput, *pcbResult, dwFlags)
|
||||
hook('ncrypt.dll', 'NCryptExportKey', function (a) {
|
||||
this.blobType = a[2].isNull() ? '?' : a[2].readUtf16String();
|
||||
this.out = a[4]; this.pcb = a[6];
|
||||
}, function () {
|
||||
const n = this.pcb.isNull() ? 0 : this.pcb.readU32();
|
||||
console.log('[NCryptExportKey] blobType=' + this.blobType);
|
||||
dump(' blob', this.out, n);
|
||||
});
|
||||
|
||||
hook('ncrypt.dll', 'NCryptOpenStorageProvider', function (a) {
|
||||
console.log('[NCryptOpenStorageProvider] ' + (a[1].isNull() ? '?' : a[1].readUtf16String()));
|
||||
});
|
||||
|
||||
// BCrypt EC key operations (in case the ECDH is bcrypt but uses import/export rather than DeriveKey).
|
||||
hook('bcrypt.dll', 'BCryptImportKeyPair', function (a) {
|
||||
console.log('[BCryptImportKeyPair] blobType=' + (a[2].isNull() ? '?' : a[2].readUtf16String()) + ' cb=' + a[5].toInt32());
|
||||
dump(' blob', a[4], a[5].toInt32());
|
||||
});
|
||||
hook('bcrypt.dll', 'BCryptExportKey', function (a) {
|
||||
this.blobType = a[2].isNull() ? '?' : a[2].readUtf16String();
|
||||
this.out = a[3]; this.pcb = a[5];
|
||||
}, function () {
|
||||
const n = this.pcb.isNull() ? 0 : this.pcb.readU32();
|
||||
console.log('[BCryptExportKey] blobType=' + this.blobType);
|
||||
dump(' blob', this.out, n);
|
||||
});
|
||||
|
||||
console.log('=== CNG ExchangeKey crypto hooks installed ===');
|
||||
@@ -7,6 +7,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Formats.Nrbf" Version="10.0.7" />
|
||||
<PackageReference Include="System.Security.Cryptography.Xml" Version="10.0.7" />
|
||||
<PackageReference Include="System.ServiceModel.NetNamedPipe" Version="10.0.652802" />
|
||||
<PackageReference Include="System.ServiceModel.NetTcp" Version="10.0.652802" />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Net.Http;
|
||||
using System.Net.Security;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Grpc.Core;
|
||||
@@ -63,6 +64,50 @@ internal static class HistorianGrpcChannelFactory
|
||||
|
||||
GrpcChannel channel = GrpcChannel.ForAddress(address, channelOptions);
|
||||
|
||||
return new HistorianGrpcConnection(channel, BuildMetadata(options));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds an event-path channel that speaks <b>native HTTP/2 gRPC</b> (no <see cref="GrpcWebHandler"/>
|
||||
/// wrap) — the leading hypothesis for why gRPC-Web event reads return zero rows while the native
|
||||
/// <c>Grpc.Core</c> HTTP/2 client returns rows for a byte-identical request. The stock 2023 R2 client
|
||||
/// uses native <c>Grpc.Core</c> (HTTP/2); reads happen to work over gRPC-Web too, but the
|
||||
/// connection-scoped event query may require a true HTTP/2 connection. Over TLS this depends on the
|
||||
/// server negotiating the <c>h2</c> ALPN protocol; <see cref="SocketsHttpHandler"/> is pinned to
|
||||
/// HTTP/2 exact so the channel does not silently fall back to HTTP/1.1.
|
||||
/// </summary>
|
||||
public static HistorianGrpcConnection CreateHttp2(HistorianClientOptions options)
|
||||
{
|
||||
string address = ResolveAddress(options);
|
||||
|
||||
var socketsHandler = new SocketsHttpHandler
|
||||
{
|
||||
EnableMultipleHttp2Connections = true
|
||||
};
|
||||
|
||||
if (options.AllowUntrustedServerCertificate && options.GrpcUseTls)
|
||||
{
|
||||
socketsHandler.SslOptions = new SslClientAuthenticationOptions
|
||||
{
|
||||
RemoteCertificateValidationCallback = (_, _, _, _) => true
|
||||
};
|
||||
}
|
||||
|
||||
var channelOptions = new GrpcChannelOptions
|
||||
{
|
||||
HttpHandler = socketsHandler
|
||||
};
|
||||
|
||||
// GrpcChannel over a SocketsHttpHandler already issues requests as HTTP/2 with
|
||||
// RequestVersionExact (no GrpcWebHandler means no HTTP/1.1 fallback to mask a failed h2
|
||||
// negotiation — it surfaces instead).
|
||||
GrpcChannel channel = GrpcChannel.ForAddress(address, channelOptions);
|
||||
|
||||
return new HistorianGrpcConnection(channel, BuildMetadata(options));
|
||||
}
|
||||
|
||||
private static Metadata BuildMetadata(HistorianClientOptions options)
|
||||
{
|
||||
// The stock client always advertises gzip request encoding; honour the option so
|
||||
// bandwidth-limited links can disable it.
|
||||
var metadata = new Metadata();
|
||||
@@ -71,7 +116,7 @@ internal static class HistorianGrpcChannelFactory
|
||||
metadata.Add("grpc-internal-encoding-request", "gzip");
|
||||
}
|
||||
|
||||
return new HistorianGrpcConnection(channel, metadata);
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private static bool AcceptAnyCertificate(
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using Google.Protobuf;
|
||||
using Grpc.Core;
|
||||
using AVEVA.Historian.Client.Models;
|
||||
using AVEVA.Historian.Client.Wcf;
|
||||
using GrpcHistory = ArchestrA.Grpc.Contract.History;
|
||||
using GrpcRetrieval = ArchestrA.Grpc.Contract.Retrieval;
|
||||
using GrpcStatus = ArchestrA.Grpc.Contract.Status;
|
||||
using GrpcTransaction = ArchestrA.Grpc.Contract.Transaction;
|
||||
|
||||
namespace AVEVA.Historian.Client.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// 2023 R2 gRPC event-read orchestrator. Mirrors <see cref="HistorianWcfEventOrchestrator"/> over the
|
||||
/// gRPC transport: the same CM_EVENT registration sequence and the same event request/row buffers
|
||||
/// travel inside protobuf <c>bytes</c> fields, reusing the proven WCF serializers/parsers verbatim.
|
||||
///
|
||||
/// Operation mapping (2020 WCF → 2023 R2 gRPC):
|
||||
/// Hist.UpdateClientStatus3 → HistoryService.UpdateClientStatus
|
||||
/// Hist.RegisterTags2 → HistoryService.RegisterTags
|
||||
/// Hist.EnsureTags2 → HistoryService.EnsureTags
|
||||
/// Stat.GetHistorianInfo → StatusService.GetHistorianInfo
|
||||
/// Stat.GetSystemParameter → StatusService.GetSystemParameter
|
||||
/// Retr.StartEventQuery → RetrievalService.StartEventQuery
|
||||
/// Retr.GetNextEventQueryResultBuffer (loop) → RetrievalService.GetNextEventQueryResultBuffer
|
||||
/// Retr.EndEventQuery → RetrievalService.EndEventQuery
|
||||
///
|
||||
/// <para>
|
||||
/// The CM_EVENT registration replay (<see cref="RegisterCmEventTag"/>) is the hard part: without it
|
||||
/// the server returns native error type=4 code=85 from GetNextEventQueryResultBuffer. The captured
|
||||
/// registration buffers are shared with the WCF path via
|
||||
/// <see cref="HistorianEventRegistrationProtocol"/> so the two transports cannot drift. The gRPC
|
||||
/// RetrievalService event ops do NOT need the WCF <c>Retr.GetV</c>/<c>IsOriginalAllowed</c> prime
|
||||
/// (the read path proved the front-door session is sufficient over gRPC).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Live status — server-gated (settled 2026-06-25):</b> the chain runs end-to-end and
|
||||
/// <c>StartEventQuery</c> succeeds, but <c>GetNextEventQueryResultBuffer</c> <b>long-polls</b> to the
|
||||
/// no-data terminal (instead of the synchronous 5-byte code-85 terminal the 2020 WCF op returns); a
|
||||
/// poll-deadline expiry is treated as that terminal (see the loop). This is <b>not</b> an empty-box
|
||||
/// artifact: the live 2023 R2 server holds tens of thousands of events yet scopes <b>0 rows</b> to a
|
||||
/// managed connection. Every client-controllable layer was byte-matched to the stock client that returns
|
||||
/// rows (see <c>docs/reverse-engineering/grpc-event-query-capture.md</c>) — the gate is a server-internal
|
||||
/// per-connection retrieval working-set, <b>not client-fixable</b>. The legacy WCF transport is not a
|
||||
/// fallback on 2023 R2 (<c>docs/reverse-engineering/wcf-event-read-spike-results.md</c>). Tooled +
|
||||
/// completes cleanly, but proven NOT to return rows over a managed connection.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class HistorianGrpcEventOrchestrator
|
||||
{
|
||||
private readonly HistorianClientOptions _options;
|
||||
|
||||
public HistorianGrpcEventOrchestrator(HistorianClientOptions options)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
/// <summary>Diagnostic: length of the most recent event-row result buffer the server sent.</summary>
|
||||
public int LastResultBufferLength { get; private set; }
|
||||
|
||||
/// <summary>Diagnostic: type+code description of the most recent error/terminal buffer.</summary>
|
||||
public string LastErrorBufferDescription { get; private set; } = string.Empty;
|
||||
|
||||
/// <summary>Diagnostic: which transport the event channel used (<c>grpc-web</c> or <c>http2</c>).</summary>
|
||||
public string EventChannelMode { get; private set; } = string.Empty;
|
||||
|
||||
/// <summary>Diagnostic: hex of the most recent result buffer (first 48 bytes).</summary>
|
||||
public string LastResultBufferHex { get; private set; } = string.Empty;
|
||||
|
||||
/// <summary>Diagnostic: hex of the most recent GetNext error buffer.</summary>
|
||||
public string LastErrorBufferHex { get; private set; } = string.Empty;
|
||||
|
||||
public async IAsyncEnumerable<HistorianEvent> ReadEventsAsync(
|
||||
DateTime startUtc,
|
||||
DateTime endUtc,
|
||||
HistorianEventFilter? filter,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_options.IntegratedSecurity && string.IsNullOrEmpty(_options.UserName))
|
||||
{
|
||||
throw new ProtocolEvidenceMissingException(
|
||||
"Managed gRPC event flow currently requires IntegratedSecurity or an explicit UserName + Password.");
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Hard overall cap. The per-call gRPC-Web deadlines are NOT honored reliably over a tunnelled
|
||||
// link (observed live 2026-06-22: a chain with 4s per-call deadlines still ran >90s because the
|
||||
// server stalls several registration RPCs and long-polls GetNext). gRPC DOES honor token
|
||||
// cancellation, so a linked CTS firing at OverallBudget bounds the whole read deterministically.
|
||||
// A budget timeout on the unverified no-row path is surfaced as ProtocolEvidenceMissing, not as
|
||||
// a raw cancellation, so callers get the same honest "not row-verified over gRPC" signal.
|
||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
linked.CancelAfter(OverallBudget);
|
||||
|
||||
IReadOnlyList<HistorianEvent> events;
|
||||
try
|
||||
{
|
||||
events = await Task.Run(
|
||||
() => RunEventChain(startUtc, endUtc, filter, linked.Token),
|
||||
linked.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (IsBudgetCancellation(ex, linked, cancellationToken))
|
||||
{
|
||||
throw new ProtocolEvidenceMissingException(
|
||||
$"ReadEvents over gRPC did not return rows within {OverallBudget.TotalSeconds:0}s: StartEventQuery " +
|
||||
"succeeds but GetNextEventQueryResultBuffer long-polls to the no-data terminal. Event-row retrieval is " +
|
||||
"auth-solved but SERVER-GATED on 2023 R2 over both transports — the server scopes 0 rows to a managed " +
|
||||
"connection (gRPC: docs/reverse-engineering/grpc-event-query-capture.md). The WCF transport reaches the " +
|
||||
"2023 R2 historian (certificate transport + auth work, CM_EVENT registration succeeds on the 0x501 event " +
|
||||
"connection) but hits the SAME server-side row gate — 0-row buffer + long-poll (see " +
|
||||
"docs/reverse-engineering/wcf-event-read-spike-results.md). Not client-fixable on either transport.");
|
||||
}
|
||||
|
||||
foreach (HistorianEvent evt in events)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
yield return evt;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hard wall-clock budget for the entire gRPC event read. Bounds the chain deterministically since
|
||||
/// per-call gRPC-Web deadlines are unreliable over a tunnel. Scaled off the request timeout but
|
||||
/// capped so a long default timeout cannot make the (currently row-unverified) read stall for minutes.
|
||||
/// </summary>
|
||||
private TimeSpan OverallBudget
|
||||
{
|
||||
get
|
||||
{
|
||||
TimeSpan cap = TimeSpan.FromSeconds(30);
|
||||
return _options.RequestTimeout < cap ? _options.RequestTimeout : cap;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when an exception was caused by the overall-budget linked CTS firing (not by the caller's
|
||||
/// own cancellation). The budget surfaces either as an <see cref="OperationCanceledException"/>
|
||||
/// (Task.Run / token checks) or a gRPC <see cref="RpcException"/> with
|
||||
/// <see cref="StatusCode.Cancelled"/> from an in-flight RPC.
|
||||
/// </summary>
|
||||
private static bool IsBudgetCancellation(Exception ex, CancellationTokenSource linked, CancellationToken caller)
|
||||
{
|
||||
if (caller.IsCancellationRequested || !linked.IsCancellationRequested)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return ex is OperationCanceledException
|
||||
|| (ex is RpcException rpc && rpc.StatusCode is StatusCode.Cancelled or StatusCode.DeadlineExceeded);
|
||||
}
|
||||
|
||||
private List<HistorianEvent> RunEventChain(DateTime startUtc, DateTime endUtc, HistorianEventFilter? filter, CancellationToken cancellationToken)
|
||||
{
|
||||
// Hypothesis #1 (server-side/connection angle, grpc-event-query-capture.md): the native client
|
||||
// uses Grpc.Core native HTTP/2, while our default channel wraps gRPC-Web over HTTP/1.1. Reads
|
||||
// work over gRPC-Web, but the connection-scoped event query may require a true HTTP/2 connection.
|
||||
// Opt in via HISTORIAN_GRPC_EVENT_HTTP2=1 to use a plain HTTP/2 channel for the event path only.
|
||||
bool useHttp2 = string.Equals(
|
||||
Environment.GetEnvironmentVariable("HISTORIAN_GRPC_EVENT_HTTP2"), "1", StringComparison.Ordinal);
|
||||
EventChannelMode = useHttp2 ? "http2" : "grpc-web";
|
||||
using HistorianGrpcConnection connection = useHttp2
|
||||
? HistorianGrpcChannelFactory.CreateHttp2(_options)
|
||||
: HistorianGrpcChannelFactory.Create(_options);
|
||||
// Event reads need an Event-type (v8) connection. OpenSession(eventConnection: true) runs the
|
||||
// full v8 path: HistoryService.ExchangeKey (P-256 ECDH) -> client key = SHA256(secret) -> v8
|
||||
// OpenConnection with ConnectionType=Event and the credential token RC4(password, MD5(clientKey)).
|
||||
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, _options, cancellationToken, eventConnection: true);
|
||||
|
||||
RegisterCmEventTag(connection, session, cancellationToken);
|
||||
|
||||
List<HistorianEvent> events = RunEventQueryOnSession(connection, session, startUtc, endUtc, filter, cancellationToken);
|
||||
|
||||
// Honest no-data handling: when the query returns real rows, hand them back. When it instead
|
||||
// reaches the no-data terminal with ZERO rows (the gRPC server long-polls GetNext rather than
|
||||
// returning the WCF code-85 terminal), we cannot distinguish "genuinely no events in range"
|
||||
// from "the CM_EVENT registration replay didn't fully land over gRPC" — so we refuse to return
|
||||
// a possibly-false empty list and surface the gated state instead. Proven server-gated: the live
|
||||
// 2023 R2 server holds tens of thousands of events yet scopes 0 to a managed connection
|
||||
// (grpc-event-query-capture.md). WCF reaches the same historian (cert transport + auth work,
|
||||
// CM_EVENT registers on the 0x501 event connection) but hits the SAME row gate — not a fallback
|
||||
// (wcf-event-read-spike-results.md).
|
||||
if (events.Count == 0)
|
||||
{
|
||||
throw new ProtocolEvidenceMissingException(
|
||||
"ReadEvents over gRPC: the chain completes and StartEventQuery succeeds, but " +
|
||||
"GetNextEventQueryResultBuffer returns no rows (it long-polls to the no-data terminal " +
|
||||
$"after the CM_EVENT registration replay; last={LastErrorBufferDescription}). Event-row retrieval is " +
|
||||
"auth-solved but SERVER-GATED on 2023 R2 over both transports — the server scopes 0 rows to a managed " +
|
||||
"connection (gRPC: docs/reverse-engineering/grpc-event-query-capture.md; WCF reaches the historian and " +
|
||||
"registers on the 0x501 event connection yet hits the same row gate: " +
|
||||
"docs/reverse-engineering/wcf-event-read-spike-results.md). Not client-fixable on either transport.");
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
private DateTime Deadline() => DateTime.UtcNow.Add(_options.RequestTimeout);
|
||||
|
||||
/// <summary>
|
||||
/// Deadline for the GetNextEventQueryResultBuffer long-poll. Bounded to at most 10s (or the
|
||||
/// configured <see cref="HistorianClientOptions.RequestTimeout"/> if shorter) so the no-data
|
||||
/// terminal — a deadline expiry over gRPC — is reached promptly instead of stalling the read for
|
||||
/// the full request timeout. When rows are available the server returns them well before this.
|
||||
/// </summary>
|
||||
private DateTime EventPollDeadline()
|
||||
{
|
||||
TimeSpan cap = TimeSpan.FromSeconds(10);
|
||||
TimeSpan poll = _options.RequestTimeout < cap ? _options.RequestTimeout : cap;
|
||||
return DateTime.UtcNow.Add(poll);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deadline for the best-effort registration RPCs. Bounded to at most 5s: several of these
|
||||
/// (RegisterTags / EnsureTags / GetHistorianInfo) <b>stall server-side</b> on the remote 2023 R2
|
||||
/// box (observed live 2026-06-22) and only return at their deadline, so an unbounded
|
||||
/// <see cref="RequestTimeout"/> would make the registration phase dominate the read. They are
|
||||
/// swallowed via <see cref="TryRun"/> regardless of outcome.
|
||||
/// </summary>
|
||||
private DateTime RegistrationDeadline()
|
||||
{
|
||||
TimeSpan cap = TimeSpan.FromSeconds(5);
|
||||
TimeSpan d = _options.RequestTimeout < cap ? _options.RequestTimeout : cap;
|
||||
return DateTime.UtcNow.Add(d);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replays the native event-tag registration sequence (UpdC3 → 6 system params → RTag2 → 1 more
|
||||
/// system param → cross-service GetV probes → EnsT2) over the gRPC services. Best-effort: each
|
||||
/// call is wrapped so an individual rejection on this server does not abort the chain — the goal
|
||||
/// is to drive the server-side session into the state StartEventQuery / GetNextEventQueryResultBuffer
|
||||
/// expect. Buffers come from <see cref="HistorianEventRegistrationProtocol"/>.
|
||||
/// </summary>
|
||||
private void RegisterCmEventTag(HistorianGrpcConnection connection, HistorianGrpcHandshake.Session session, CancellationToken cancellationToken)
|
||||
{
|
||||
var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel);
|
||||
var statusClient = new GrpcStatus.StatusService.StatusServiceClient(connection.Channel);
|
||||
|
||||
// Native 2023 R2 gRPC event-connection registration sequence (captured order):
|
||||
// UpdateClientStatus -> RegisterTags(CM_EVENT) -> EnsureTags(CM_EVENT) -> GetHistorianInfo
|
||||
// -> GetSystemParameter x7. (StartEventQuery follows in RunEventQuery.) The 2020-WCF-era extra
|
||||
// probes (cross-service GetV, params-before-register) are NOT in the gRPC event flow.
|
||||
byte[] clientStatus = HistorianEventRegistrationProtocol.BuildUpdateClientStatusBlob();
|
||||
TryRun(() => historyClient.UpdateClientStatus(
|
||||
new GrpcHistory.UpdateClientStatusRequest { StrHandle = session.StringHandle, BtClientStatus = ByteString.CopyFrom(clientStatus) },
|
||||
connection.Metadata, RegistrationDeadline(), cancellationToken));
|
||||
|
||||
byte[] registerBuffer = HistorianEventRegistrationProtocol.BuildRegisterCmEventInputBuffer();
|
||||
try
|
||||
{
|
||||
GrpcHistory.RegisterTagsResponse rt = historyClient.RegisterTags(
|
||||
new GrpcHistory.RegisterTagsRequest { StrHandle = session.StringHandle, BtTagInfos = ByteString.CopyFrom(registerBuffer) },
|
||||
connection.Metadata, RegistrationDeadline(), cancellationToken);
|
||||
RegistrationDiag += $"RTag={rt.Status?.BSuccess} e={Convert.ToHexString(rt.Status?.BtError?.ToByteArray() ?? [])}; ";
|
||||
}
|
||||
catch (Exception ex) { RegistrationDiag += $"RTag=EX:{ex.GetType().Name}; "; }
|
||||
|
||||
// gRPC CM_EVENT EnsureTags uses the 86-byte native format (8-byte header + the …2f27 event-type
|
||||
// GUID), NOT the 2020 WCF CTagMetadata.
|
||||
byte[] payload = HistorianAddTagsProtocol.SerializeCmEventEnsureTagsGrpc(DateTime.UtcNow);
|
||||
try
|
||||
{
|
||||
GrpcHistory.EnsureTagsResponse et = historyClient.EnsureTags(
|
||||
new GrpcHistory.EnsureTagsRequest { StrHandle = session.StringHandle, BtTagInfos = ByteString.CopyFrom(payload), ElementCount = 1 },
|
||||
connection.Metadata, RegistrationDeadline(), cancellationToken);
|
||||
RegistrationDiag += $"EnsT={et.Status?.BSuccess} e={Convert.ToHexString(et.Status?.BtError?.ToByteArray() ?? [])} out={Convert.ToHexString(et.BtTagStatus?.ToByteArray() ?? [])}; ";
|
||||
}
|
||||
catch (Exception ex) { RegistrationDiag += $"EnsT=EX:{ex.GetType().Name}; "; }
|
||||
|
||||
byte[] historianVersionRequest = HistorianEventRegistrationProtocol.BuildGetHistorianInfoRequest("HistorianVersion");
|
||||
TryRun(() => statusClient.GetHistorianInfo(
|
||||
new GrpcStatus.GetHistorianInfoRequest { StrHandle = session.StringHandle, BtRequest = ByteString.CopyFrom(historianVersionRequest) },
|
||||
connection.Metadata, RegistrationDeadline(), cancellationToken));
|
||||
|
||||
string[] eventParams = ["AllowOriginals", "HistorianPartner", "HistorianVersion", "MaxCyclicStorageTimeout", "RealTimeWindow", "FutureTimeThreshold", "AllowRenameTags"];
|
||||
foreach (string parameterName in eventParams)
|
||||
{
|
||||
TryRun(() => statusClient.GetSystemParameter(
|
||||
new GrpcStatus.GetSystemParameterRequest { UiHandle = session.ClientHandle, StrParameterName = parameterName },
|
||||
connection.Metadata, RegistrationDeadline(), cancellationToken));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Diagnostic: outcomes of the key CM_EVENT registration RPCs.</summary>
|
||||
public string RegistrationDiag { get; private set; } = string.Empty;
|
||||
|
||||
// Spike seam (pending.md A1 broadening, Stage B0b): run ONLY the event query (StartEventQuery →
|
||||
// GetNext loop → EndEventQuery) against an EXTERNALLY-supplied, already-opened + CM_EVENT-registered
|
||||
// v8 Event connection + session — NO Create()/OpenSession/RegisterCmEventTag here. The per-call
|
||||
// RunEventChain delegates to this so the per-call read and the B0b reuse spike share one query
|
||||
// implementation (DRY). NOTE: event reads are otherwise GATED (C2) — the gRPC server long-polls
|
||||
// GetNext to the no-data terminal and row-level retrieval is not yet verified over gRPC (see class
|
||||
// remarks); the SEND seam is the spike's primary reuse signal. The split-channel opt-in
|
||||
// (HISTORIAN_GRPC_EVENT_SPLIT_CHANNEL) is preserved inside, unchanged.
|
||||
internal List<HistorianEvent> RunEventQueryOnSession(
|
||||
HistorianGrpcConnection connection,
|
||||
HistorianGrpcHandshake.Session session,
|
||||
DateTime startUtc,
|
||||
DateTime endUtc,
|
||||
HistorianEventFilter? filter,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// HTTP/2-frame capture (grpc-event-query-capture.md #3) showed the stock client runs the event
|
||||
// query on a DEDICATED RetrievalService TLS connection, separate from the HistoryService
|
||||
// connection that opened+registered the session (correlated only by the session handle); our SDK
|
||||
// collapses every service onto one connection. Opt in via HISTORIAN_GRPC_EVENT_SPLIT_CHANNEL=1 to
|
||||
// run StartEventQuery/GetNext/EndEventQuery on their own connection (mirrors native conn4: no
|
||||
// re-handshake, just the existing handle), to test whether topology is the row-scoping gate.
|
||||
bool splitChannel = string.Equals(
|
||||
Environment.GetEnvironmentVariable("HISTORIAN_GRPC_EVENT_SPLIT_CHANNEL"), "1", StringComparison.Ordinal);
|
||||
HistorianGrpcConnection rconn = splitChannel ? HistorianGrpcChannelFactory.Create(_options) : connection;
|
||||
try
|
||||
{
|
||||
|
||||
var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(rconn.Channel);
|
||||
GrpcRetrieval.GetRetrievalInterfaceVersionResponse retrievalVersion = retrievalClient.GetRetrievalInterfaceVersion(
|
||||
new GrpcRetrieval.GetRetrievalInterfaceVersionRequest(), rconn.Metadata, Deadline(), cancellationToken);
|
||||
HistorianServerVersionGate.Validate(HistorianServiceInterface.Retrieval, retrievalVersion.UiVersion, _options);
|
||||
|
||||
// Version 6 envelope: the stock 2023 R2 client sends v6 (the WCF path's v5 request is accepted
|
||||
// here but is the legacy format). NECESSARY but not alone sufficient — live validation 2026-06-22
|
||||
// showed rows still don't flow on v6 because the read also requires an EVENT-type connection
|
||||
// (the stock client opens ConnectionType=Event; our OpenSession opens a Process-style 0x402
|
||||
// session). See docs/reverse-engineering/grpc-event-query-capture.md "remaining gate".
|
||||
IReadOnlyList<HistorianEventQueryAttempt> attempts = HistorianEventQueryProtocol.CreateStartEventQueryAttempts(
|
||||
startUtc.ToUniversalTime(),
|
||||
endUtc.ToUniversalTime(),
|
||||
eventCount: 100,
|
||||
filter,
|
||||
version: 6);
|
||||
byte[] requestBuffer = attempts[0].RequestBuffer;
|
||||
|
||||
GrpcRetrieval.StartEventQueryResponse startResponse = retrievalClient.StartEventQuery(
|
||||
new GrpcRetrieval.StartEventQueryRequest
|
||||
{
|
||||
UiHandle = session.ClientHandle,
|
||||
UiQueryRequestType = HistorianEventQueryProtocol.QueryRequestTypeEvent,
|
||||
BtRequest = ByteString.CopyFrom(requestBuffer)
|
||||
},
|
||||
rconn.Metadata,
|
||||
Deadline(),
|
||||
cancellationToken);
|
||||
|
||||
byte[] startError = startResponse.Status?.BtError?.ToByteArray() ?? [];
|
||||
if (!(startResponse.Status?.BSuccess ?? false))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"gRPC StartEventQuery failed (errorLen={startError.Length}, error5={HistorianEventRegistrationProtocol.DescribeNativeError(startError)}).");
|
||||
}
|
||||
|
||||
uint queryHandle = startResponse.UiQueryHandle;
|
||||
RegistrationDiag += $"QH={queryHandle} clientH={session.ClientHandle} strH={session.StringHandle}; ";
|
||||
try
|
||||
{
|
||||
List<HistorianEvent> events = [];
|
||||
while (true)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
GrpcRetrieval.GetNextEventQueryResultBufferResponse nextResponse;
|
||||
try
|
||||
{
|
||||
nextResponse = retrievalClient.GetNextEventQueryResultBuffer(
|
||||
new GrpcRetrieval.GetNextEventQueryResultBufferRequest { UiHandle = session.ClientHandle, UiQueryHandle = queryHandle },
|
||||
rconn.Metadata,
|
||||
EventPollDeadline(),
|
||||
cancellationToken);
|
||||
}
|
||||
catch (RpcException ex) when (ex.StatusCode == StatusCode.DeadlineExceeded)
|
||||
{
|
||||
// No-data terminal. Over gRPC the 2023 R2 server LONG-POLLS GetNextEventQueryResultBuffer
|
||||
// when the query has no (more) rows to hand back, rather than returning the 5-byte
|
||||
// type=4 code=85 terminal the 2020 WCF op returns synchronously. A poll-deadline
|
||||
// expiry is therefore the gRPC equivalent of that soft terminal: stop reading and
|
||||
// return whatever rows were already collected. (Confirmed live 2026-06-22: the chain
|
||||
// runs and StartEventQuery succeeds, but GetNext blocks to the deadline on the idle
|
||||
// dev box, which holds no events.) See class remarks.
|
||||
LastErrorBufferDescription = "GetNext long-poll deadline (no-data terminal)";
|
||||
return events;
|
||||
}
|
||||
|
||||
byte[] resultBuffer = nextResponse.BtResult?.ToByteArray() ?? [];
|
||||
byte[] errorBuffer = nextResponse.Status?.BtError?.ToByteArray() ?? [];
|
||||
bool nextSuccess = nextResponse.Status?.BSuccess ?? false;
|
||||
|
||||
LastResultBufferLength = resultBuffer.Length;
|
||||
LastErrorBufferDescription = HistorianEventRegistrationProtocol.DescribeNativeError(errorBuffer);
|
||||
LastResultBufferHex = Convert.ToHexString(resultBuffer.Length <= 48 ? resultBuffer : resultBuffer[..48]);
|
||||
LastErrorBufferHex = Convert.ToHexString(errorBuffer);
|
||||
|
||||
// Any 5-byte type=4 error is a soft terminal (code 30 NoMoreData is canonical; code
|
||||
// 85 / 0x55 is the missing-registration signal seen on early runs). Mirror the WCF
|
||||
// orchestrator: stop reading and surface the diagnostic rather than throw.
|
||||
if (errorBuffer.Length == 5 && errorBuffer[0] == 4)
|
||||
{
|
||||
return events;
|
||||
}
|
||||
|
||||
if (!nextSuccess)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"gRPC GetNextEventQueryResultBuffer failed (errorLen={errorBuffer.Length}, error5={HistorianEventRegistrationProtocol.DescribeNativeError(errorBuffer)}).");
|
||||
}
|
||||
|
||||
if (resultBuffer.Length > 0)
|
||||
{
|
||||
events.AddRange(HistorianEventRowProtocol.Parse(resultBuffer));
|
||||
}
|
||||
|
||||
if (resultBuffer.Length == 0 && errorBuffer.Length == 0)
|
||||
{
|
||||
return events;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
EndEventQuerySafely(retrievalClient, rconn, session.ClientHandle, queryHandle);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (splitChannel) { rconn.Dispose(); }
|
||||
}
|
||||
}
|
||||
|
||||
private void EndEventQuerySafely(
|
||||
GrpcRetrieval.RetrievalService.RetrievalServiceClient client,
|
||||
HistorianGrpcConnection connection,
|
||||
uint clientHandle,
|
||||
uint queryHandle)
|
||||
{
|
||||
try
|
||||
{
|
||||
client.EndEventQuery(
|
||||
new GrpcRetrieval.EndEventQueryRequest { UiHandle = clientHandle, UiQueryHandle = queryHandle },
|
||||
connection.Metadata,
|
||||
Deadline(),
|
||||
CancellationToken.None);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort cleanup; the read result is already collected.
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryRun(Action action)
|
||||
{
|
||||
try { action(); }
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
using Google.Protobuf;
|
||||
using AVEVA.Historian.Client.Models;
|
||||
using AVEVA.Historian.Client.Wcf;
|
||||
using GrpcHistory = ArchestrA.Grpc.Contract.History;
|
||||
using GrpcStatus = ArchestrA.Grpc.Contract.Status;
|
||||
|
||||
namespace AVEVA.Historian.Client.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// 2023 R2 gRPC orchestrator for the event SEND (<see cref="HistorianClient.SendEventAsync"/>).
|
||||
/// Captured live from the native 2023 R2 client (<c>capture-send-event</c> scenario,
|
||||
/// 2026-06-23): an event send rides <c>HistoryService.AddStreamValues</c> with the SAME
|
||||
/// <c>"OS"</c> (0x534F) storage-sample buffer the WCF AddS2 path uses
|
||||
/// (<see cref="HistorianEventWriteProtocol"/>) — NOT a distinct event RPC and NOT the historical
|
||||
/// write's <c>"ON"</c> buffer. The native client's write-enabled Event <c>OpenConnection</c>
|
||||
/// request is byte-identical to the read-only event open (the ReadOnly arg does not change the v8
|
||||
/// open buffer; diffed live — only the per-session client key + credential token differ), so the
|
||||
/// existing <see cref="HistorianGrpcHandshake.OpenSession"/> event path is reused unchanged. The
|
||||
/// chain on a single Event session:
|
||||
/// <list type="number">
|
||||
/// <item>OpenConnection (v8 Event, ExchangeKey ECDH auth) → string storage handle</item>
|
||||
/// <item>CM_EVENT registration: UpdateClientStatus → RegisterTags → EnsureTags (the same
|
||||
/// buffers the gRPC event READ replays — verified byte-identical to the capture)</item>
|
||||
/// <item><c>HistoryService.AddStreamValues</c>(strHandle, "OS" event buffer)</item>
|
||||
/// </list>
|
||||
/// Only original events (<see cref="HistorianEvent.RevisionVersion"/> = 0) with string-valued
|
||||
/// properties have a captured encoding; others throw <see cref="ProtocolEvidenceMissingException"/>
|
||||
/// from <see cref="HistorianEventWriteProtocol"/>.
|
||||
/// </summary>
|
||||
internal sealed class HistorianGrpcEventWriteOrchestrator
|
||||
{
|
||||
private readonly HistorianClientOptions _options;
|
||||
|
||||
public HistorianGrpcEventWriteOrchestrator(HistorianClientOptions options)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
/// <summary>Diagnostic: type+code description of the most recent AddStreamValues error buffer.</summary>
|
||||
public string LastSendErrorDescription { get; private set; } = string.Empty;
|
||||
|
||||
/// <summary>Diagnostic: outcomes of the CM_EVENT registration RPCs.</summary>
|
||||
public string RegistrationDiag { get; private set; } = string.Empty;
|
||||
|
||||
public Task<bool> SendEventAsync(HistorianEvent evt, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(evt);
|
||||
if (!_options.IntegratedSecurity && string.IsNullOrEmpty(_options.UserName))
|
||||
{
|
||||
throw new ProtocolEvidenceMissingException(
|
||||
"Managed gRPC event send currently requires IntegratedSecurity or an explicit UserName + Password.");
|
||||
}
|
||||
|
||||
if (evt.RevisionVersion != 0)
|
||||
{
|
||||
throw new ProtocolEvidenceMissingException(
|
||||
"Only original events (RevisionVersion = 0) have a captured send encoding; " +
|
||||
"revision/update/delete event sends are not yet supported.");
|
||||
}
|
||||
|
||||
return Task.Run(() => Run(evt, cancellationToken), cancellationToken);
|
||||
}
|
||||
|
||||
private bool Run(HistorianEvent evt, CancellationToken cancellationToken)
|
||||
{
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
|
||||
|
||||
// The event SEND uses the same v8 Event connection as the event READ. The write-enabled
|
||||
// open buffer is byte-identical to the read-only one (verified live), so OpenSession's
|
||||
// event path is reused unchanged. Per-call: open + register + send on a fresh session.
|
||||
HistorianGrpcHandshake.Session session = OpenAndRegisterEventSession(connection, cancellationToken);
|
||||
|
||||
return SendEventOnSession(connection, session, evt, cancellationToken);
|
||||
}
|
||||
|
||||
// Spike seam (pending.md A1 broadening, Stage B0b): open a v8 Event connection and drive the
|
||||
// CM_EVENT registration ONCE, returning the warm (connection, session). The per-call Run() uses
|
||||
// it for a single send; the B0b reuse spike calls it once and then issues MULTIPLE
|
||||
// SendEventOnSession ops against the returned session to measure whether a v8 Event session can
|
||||
// be reused across sends (it has NEVER been proven reusable — that is exactly what B0b measures).
|
||||
// The caller owns the connection's lifetime (dispose it).
|
||||
internal HistorianGrpcHandshake.Session OpenAndRegisterEventSession(
|
||||
HistorianGrpcConnection connection,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(
|
||||
connection, _options, cancellationToken, eventConnection: true);
|
||||
|
||||
RegisterCmEventTag(connection, session, cancellationToken);
|
||||
return session;
|
||||
}
|
||||
|
||||
// Spike seam (pending.md A1 broadening, Stage B0b): perform ONLY the event send against an
|
||||
// EXTERNALLY-supplied, already-opened + CM_EVENT-registered v8 Event connection + session —
|
||||
// i.e. NO Create(), NO OpenSession(eventConnection:true), NO RegisterCmEventTag inside it. The
|
||||
// per-call Run() path delegates here so the per-call send and the B0b reuse-spike send share one
|
||||
// implementation (DRY) and stay byte-identical. The spike drives this repeatedly on one warm
|
||||
// session to measure whether the server honors a reused v8 Event session for multiple sends.
|
||||
internal bool SendEventOnSession(
|
||||
HistorianGrpcConnection connection,
|
||||
HistorianGrpcHandshake.Session session,
|
||||
HistorianEvent evt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel);
|
||||
byte[] pBuf = HistorianEventWriteProtocol.SerializeAddStreamValuesBuffer(evt, DateTime.UtcNow);
|
||||
|
||||
GrpcHistory.AddStreamValuesResponse response = historyClient.AddStreamValues(
|
||||
new GrpcHistory.AddStreamValuesRequest
|
||||
{
|
||||
StrHandle = session.StringHandle,
|
||||
BtValues = ByteString.CopyFrom(pBuf),
|
||||
},
|
||||
connection.Metadata,
|
||||
DateTime.UtcNow.Add(_options.RequestTimeout),
|
||||
cancellationToken);
|
||||
|
||||
byte[] error = response.Status?.BtError?.ToByteArray() ?? [];
|
||||
LastSendErrorDescription = HistorianEventRegistrationProtocol.DescribeNativeError(error);
|
||||
return response.Status?.BSuccess ?? false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replays the CM_EVENT registration the native event connection performs before a send:
|
||||
/// UpdateClientStatus → RegisterTags(CM_EVENT) → EnsureTags(CM_EVENT). The buffers are shared
|
||||
/// with the gRPC event READ path (<see cref="HistorianEventRegistrationProtocol"/> +
|
||||
/// <see cref="HistorianAddTagsProtocol.SerializeCmEventEnsureTagsGrpc"/>) and were verified
|
||||
/// byte-identical to the live capture. Best-effort: an individual rejection does not abort the
|
||||
/// send (the server may already hold CM_EVENT registered for the session).
|
||||
/// </summary>
|
||||
private void RegisterCmEventTag(HistorianGrpcConnection connection, HistorianGrpcHandshake.Session session, CancellationToken cancellationToken)
|
||||
{
|
||||
var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel);
|
||||
DateTime Deadline() => DateTime.UtcNow.Add(_options.RequestTimeout);
|
||||
|
||||
byte[] clientStatus = HistorianEventRegistrationProtocol.BuildUpdateClientStatusBlob();
|
||||
try
|
||||
{
|
||||
historyClient.UpdateClientStatus(
|
||||
new GrpcHistory.UpdateClientStatusRequest { StrHandle = session.StringHandle, BtClientStatus = ByteString.CopyFrom(clientStatus) },
|
||||
connection.Metadata, Deadline(), cancellationToken);
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
|
||||
byte[] registerBuffer = HistorianEventRegistrationProtocol.BuildRegisterCmEventInputBuffer();
|
||||
try
|
||||
{
|
||||
GrpcHistory.RegisterTagsResponse rt = historyClient.RegisterTags(
|
||||
new GrpcHistory.RegisterTagsRequest { StrHandle = session.StringHandle, BtTagInfos = ByteString.CopyFrom(registerBuffer) },
|
||||
connection.Metadata, Deadline(), cancellationToken);
|
||||
RegistrationDiag += $"RTag={rt.Status?.BSuccess}; ";
|
||||
}
|
||||
catch (Exception ex) { RegistrationDiag += $"RTag=EX:{ex.GetType().Name}; "; }
|
||||
|
||||
byte[] payload = HistorianAddTagsProtocol.SerializeCmEventEnsureTagsGrpc(DateTime.UtcNow);
|
||||
try
|
||||
{
|
||||
GrpcHistory.EnsureTagsResponse et = historyClient.EnsureTags(
|
||||
new GrpcHistory.EnsureTagsRequest { StrHandle = session.StringHandle, BtTagInfos = ByteString.CopyFrom(payload), ElementCount = 1 },
|
||||
connection.Metadata, Deadline(), cancellationToken);
|
||||
RegistrationDiag += $"EnsT={et.Status?.BSuccess}; ";
|
||||
}
|
||||
catch (Exception ex) { RegistrationDiag += $"EnsT=EX:{ex.GetType().Name}; "; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using System.Security.Cryptography;
|
||||
using Google.Protobuf;
|
||||
using Grpc.Core;
|
||||
using AVEVA.Historian.Client.Wcf;
|
||||
using GrpcHistory = ArchestrA.Grpc.Contract.History;
|
||||
using GrpcStorage = ArchestrA.Grpc.Contract.Storage;
|
||||
|
||||
namespace AVEVA.Historian.Client.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// Shared 2023 R2 gRPC authentication handshake. Opens an authenticated History session over an
|
||||
/// existing <see cref="HistorianGrpcConnection"/> and returns the transient client handle used by
|
||||
/// the Retrieval/Status services. Extracted from <see cref="HistorianGrpcReadOrchestrator"/> so the
|
||||
/// read, status, and (future) browse/metadata gRPC paths all drive the identical chain:
|
||||
/// <c>HistoryService.GetInterfaceVersion → StorageService.ValidateClientCredential (token loop) →
|
||||
/// HistoryService.OpenConnection</c>. The byte payloads (OpenConnection3 v6 request, NTLM token
|
||||
/// framing) are the proven 2020 protocol and transfer unchanged inside protobuf <c>bytes</c> fields.
|
||||
///
|
||||
/// See <see cref="HistorianGrpcReadOrchestrator"/> for the op-routing rationale (the Negotiate loop
|
||||
/// belongs on StorageService.ValidateClientCredential, NOT HistoryService.ExchangeKey).
|
||||
/// </summary>
|
||||
internal static class HistorianGrpcHandshake
|
||||
{
|
||||
/// <summary>Diagnostic: hex of the most recent v8 event-connection OpenConnection request.</summary>
|
||||
internal static string LastEventOpenRequestHex { get; private set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The handles produced by a successful OpenConnection. <see cref="ClientHandle"/> is the
|
||||
/// transient <c>uint</c> session token used by StartQuery/GetSystemParameter and the other
|
||||
/// uint-handle ops. <see cref="StorageSessionId"/> is the storage-session GUID used (formatted
|
||||
/// uppercase via <see cref="StringHandle"/>) by the string-handle ops
|
||||
/// (GetTagInfosFromName, GetTagExtendedPropertiesFromName, ExecuteSqlCommand, ...).
|
||||
/// </summary>
|
||||
internal readonly record struct Session(uint ClientHandle, Guid StorageSessionId)
|
||||
{
|
||||
/// <summary>The storage GUID in the uppercase "D" form the native string-handle ops require.</summary>
|
||||
public string StringHandle => StorageSessionId.ToString("D").ToUpperInvariant();
|
||||
}
|
||||
|
||||
/// <summary>Convenience overload for callers that only need the uint client handle.</summary>
|
||||
public static uint OpenAuthenticatedConnection(
|
||||
HistorianGrpcConnection connection,
|
||||
HistorianClientOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
=> OpenSession(connection, options, cancellationToken).ClientHandle;
|
||||
|
||||
/// <param name="connectionMode">
|
||||
/// The native Open2 connection mode. Defaults to read-only (<c>0x402</c>); pass
|
||||
/// <see cref="HistorianWcfAuthChainHelper.NativeIntegratedWriteEnabledConnectionMode"/>
|
||||
/// (<c>0x401</c>) for write-enabled sessions (e.g. the non-streamed/revision Transaction path,
|
||||
/// which the read-only mode silently rejects with err 132 OperationNotEnabled).
|
||||
/// </param>
|
||||
public static Session OpenSession(
|
||||
HistorianGrpcConnection connection,
|
||||
HistorianClientOptions options,
|
||||
CancellationToken cancellationToken,
|
||||
uint connectionMode = HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode,
|
||||
bool eventConnection = false)
|
||||
{
|
||||
DateTime Deadline() => DateTime.UtcNow.Add(options.RequestTimeout);
|
||||
|
||||
Guid contextKey = Guid.NewGuid();
|
||||
var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel);
|
||||
|
||||
GrpcHistory.GetInterfaceVersionResponse historyVersion = historyClient.GetInterfaceVersion(
|
||||
new GrpcHistory.GetInterfaceVersionRequest(), connection.Metadata, Deadline(), cancellationToken);
|
||||
HistorianServerVersionGate.Validate(HistorianServiceInterface.History, historyVersion.UiVersion, options);
|
||||
|
||||
// The v6 (read/write) path authenticates via StorageService.ValidateClientCredential (Negotiate).
|
||||
// The v8 EVENT path authenticates entirely via ExchangeKey (ECDH) + the RC4 credential token —
|
||||
// the native client does NOT run ValidateClientCredential for an event connection, and doing so
|
||||
// establishes a different session scope under which the event query returns zero rows. So skip it.
|
||||
if (!eventConnection)
|
||||
{
|
||||
var storageClient = new GrpcStorage.StorageService.StorageServiceClient(connection.Channel);
|
||||
HistorianNativeHandshake.RunTokenRounds(
|
||||
(handle, wrapped, _) =>
|
||||
{
|
||||
GrpcStorage.ValidateClientCredentialResponse response = storageClient.ValidateClientCredential(
|
||||
new GrpcStorage.ValidateClientCredentialRequest { Handle = handle, InBuff = ByteString.CopyFrom(wrapped) },
|
||||
connection.Metadata,
|
||||
Deadline(),
|
||||
cancellationToken);
|
||||
byte[] serverOutput = response.OutBuff?.ToByteArray() ?? [];
|
||||
byte[] error = response.Status?.BtError?.ToByteArray() ?? [];
|
||||
bool success = response.Status?.BSuccess ?? false;
|
||||
return new HistorianNativeHandshake.TokenExchangeResult(success, serverOutput, error);
|
||||
},
|
||||
contextKey,
|
||||
options,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
// Event reads require an Event-type connection (ConnectionType=Event), which only the native
|
||||
// v8 OpenConnection format carries — the v6 buffer has no such field. The v8 path authenticates
|
||||
// via HistoryService.ExchangeKey (P-256 ECDH): the shared secret -> SHA256 = the client key, and
|
||||
// the v8 credential token = RC4(password-UTF16LE, key=MD5(clientKey)) (the native HistorianCrypto
|
||||
// aahCryptV2 scheme). The server shares the secret and RC4-decrypts the token to validate the
|
||||
// password. See docs/reverse-engineering/grpc-event-query-capture.md.
|
||||
byte[] eventToken = [];
|
||||
if (eventConnection)
|
||||
{
|
||||
using ECDiffieHellman ecdh = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256);
|
||||
byte[] clientHello = HistorianNativeHandshake.BuildExchangeKeyClientHello(ecdh);
|
||||
string xkHandle = contextKey.ToString("D").ToUpperInvariant();
|
||||
GrpcHistory.ExchangeKeyResponse xk = historyClient.ExchangeKey(
|
||||
new GrpcHistory.ExchangeKeyRequest { StrHandle = xkHandle, BtInput = ByteString.CopyFrom(clientHello) },
|
||||
connection.Metadata,
|
||||
Deadline(),
|
||||
cancellationToken);
|
||||
if (!(xk.Status?.BSuccess ?? false))
|
||||
{
|
||||
byte[] xkErr = xk.Status?.BtError?.ToByteArray() ?? [];
|
||||
HistorianNativeError? xkDecoded = HistorianOpen2Protocol.TryReadNativeError(xkErr);
|
||||
string xkAscii = new(xkErr.Where(b => b is >= 0x20 and < 0x7F).Select(b => (char)b).ToArray());
|
||||
throw new InvalidOperationException(
|
||||
$"gRPC ExchangeKey failed (errorLen={xkErr.Length}, native={xkDecoded?.Type}/{xkDecoded?.Code}, ascii='{xkAscii}').");
|
||||
}
|
||||
|
||||
byte[] clientKey = HistorianNativeHandshake.DeriveExchangeKeyClientKey(ecdh, xk.BtOutput?.ToByteArray() ?? []);
|
||||
eventToken = HistorianNativeHandshake.BuildExchangeKeyCredentialToken(clientKey, options.Password);
|
||||
}
|
||||
|
||||
byte[] open2Request = eventConnection
|
||||
? HistorianNativeHandshake.BuildEventOpenConnectionVersion8Request(contextKey, options.UserName, eventToken)
|
||||
: HistorianNativeHandshake.BuildOpenConnection3Request(options.Host, contextKey, connectionMode);
|
||||
if (eventConnection) { LastEventOpenRequestHex = Convert.ToHexString(open2Request); }
|
||||
|
||||
GrpcHistory.OpenConnectionResponse open2 = historyClient.OpenConnection(
|
||||
new GrpcHistory.OpenConnectionRequest { BtConnectionRequest = ByteString.CopyFrom(open2Request) },
|
||||
connection.Metadata,
|
||||
Deadline(),
|
||||
cancellationToken);
|
||||
|
||||
byte[] open2Response = open2.BtConnectionResponse?.ToByteArray() ?? [];
|
||||
if (!(open2.Status?.BSuccess ?? false))
|
||||
{
|
||||
byte[] err = open2.Status?.BtError?.ToByteArray() ?? [];
|
||||
HistorianNativeError? decoded = HistorianOpen2Protocol.TryReadNativeError(err);
|
||||
string ascii = new(err.Where(b => b is >= 0x20 and < 0x7F).Select(b => (char)b).ToArray());
|
||||
throw new InvalidOperationException(
|
||||
$"gRPC OpenConnection failed (errorLen={err.Length}, responseLen={open2Response.Length}, " +
|
||||
$"native={decoded?.Type}/{decoded?.Code}{(decoded?.Name is { } n ? $" {n}" : "")}, ascii='{ascii}').");
|
||||
}
|
||||
|
||||
(uint clientHandle, Guid storageSessionId) = HistorianNativeHandshake.ParseOpenConnectionResponse(open2Response);
|
||||
return new Session(clientHandle, storageSessionId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using Google.Protobuf;
|
||||
using AVEVA.Historian.Client.Models;
|
||||
using AVEVA.Historian.Client.Wcf;
|
||||
using GrpcHistory = ArchestrA.Grpc.Contract.History;
|
||||
using GrpcRetrieval = ArchestrA.Grpc.Contract.Retrieval;
|
||||
|
||||
namespace AVEVA.Historian.Client.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// 2023 R2 gRPC orchestrator for the M3 historical (non-streamed original / backfill) value write.
|
||||
/// Captured live from the native client (see <c>docs/plans/revision-write-path.md</c> §"R3.1
|
||||
/// CAPTURED"): the historical write rides <c>HistoryService.AddStreamValues</c> with an "ON"
|
||||
/// storage-sample buffer (<see cref="HistorianHistoricalWriteProtocol"/>), NOT the TransactionService
|
||||
/// <c>AddNonStreamValues</c> path. The chain on a single write-enabled (<c>0x401</c>) session:
|
||||
/// <list type="number">
|
||||
/// <item>OpenConnection (write-enabled) → string storage handle</item>
|
||||
/// <item><c>RetrievalService.GetTagInfosFromName</c> → the per-tag GUID (parsed as the tag-info
|
||||
/// record's <c>TypeId</c>) and registers the tag on the session</item>
|
||||
/// <item><c>HistoryService.AddStreamValues</c>(strHandle, "ON" buffer) per sample</item>
|
||||
/// </list>
|
||||
/// The tag must already exist (create it with <c>EnsureTagAsync</c> first). Only the Float value
|
||||
/// encoding is captured; other tag types are rejected by the serializer until captured.
|
||||
/// </summary>
|
||||
internal sealed class HistorianGrpcHistoricalWriteOrchestrator
|
||||
{
|
||||
private readonly HistorianClientOptions _options;
|
||||
|
||||
public HistorianGrpcHistoricalWriteOrchestrator(HistorianClientOptions options)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
public Task<bool> AddHistoricalValuesAsync(
|
||||
string tag,
|
||||
IReadOnlyList<HistorianHistoricalValue> values,
|
||||
CancellationToken cancellationToken)
|
||||
=> Task.Run(() => Run(tag, values, cancellationToken), cancellationToken);
|
||||
|
||||
private bool Run(string tag, IReadOnlyList<HistorianHistoricalValue> values, CancellationToken cancellationToken)
|
||||
{
|
||||
if (values.Count == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
|
||||
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(
|
||||
connection, _options, cancellationToken,
|
||||
connectionMode: HistorianWcfAuthChainHelper.NativeIntegratedWriteEnabledConnectionMode);
|
||||
return RunWriteOnSession(connection, session, tag, values, cancellationToken);
|
||||
}
|
||||
|
||||
// Spike/Phase-1 seam (A1): run the historical write against an EXTERNALLY-supplied, already-
|
||||
// authenticated write-enabled (0x401) connection + session — NO Create()/handshake here. Run()
|
||||
// delegates so the per-call path and the reuse path share one write implementation (DRY).
|
||||
internal bool RunWriteOnSession(
|
||||
HistorianGrpcConnection connection,
|
||||
HistorianGrpcHandshake.Session session,
|
||||
string tag,
|
||||
IReadOnlyList<HistorianHistoricalValue> values,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (values.Count == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
string handle = session.StringHandle;
|
||||
DateTime Deadline() => DateTime.UtcNow.Add(_options.RequestTimeout);
|
||||
|
||||
// Resolve the per-tag GUID (and register the tag on this write session) via
|
||||
// GetTagInfosFromName. The 16-byte GUID the "ON" buffer needs is the tag-info record's TypeId.
|
||||
var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel);
|
||||
GrpcRetrieval.GetTagInfosFromNameResponse tagInfoResponse = retrievalClient.GetTagInfosFromName(
|
||||
new GrpcRetrieval.GetTagInfosFromNameRequest
|
||||
{
|
||||
StrHandle = handle,
|
||||
BtTagNames = ByteString.CopyFrom(HistorianGrpcTagClient.BuildTagNamesBuffer([tag])),
|
||||
UiSequence = 0,
|
||||
},
|
||||
connection.Metadata, Deadline(), cancellationToken);
|
||||
|
||||
if (!(tagInfoResponse.Status?.BSuccess ?? false))
|
||||
{
|
||||
byte[] error = tagInfoResponse.Status?.BtError?.ToByteArray() ?? [];
|
||||
throw new InvalidOperationException(
|
||||
$"gRPC GetTagInfosFromName failed for tag '{tag}' (errorLen={error.Length}); does the tag exist?");
|
||||
}
|
||||
|
||||
byte[] tagInfos = tagInfoResponse.BtTagInfos?.ToByteArray() ?? [];
|
||||
IReadOnlyList<HistorianTagInfoResponse> parsed = HistorianTagQueryProtocol.ParseGetTagInfoResponse(tagInfos);
|
||||
if (parsed.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Tag '{tag}' not found on the server.");
|
||||
}
|
||||
|
||||
Guid tagGuid = parsed[0].TypeId;
|
||||
HistorianDataType dataType = HistorianWcfTagClient.MapDataType(parsed[0].NativeDataTypeDescriptor);
|
||||
|
||||
var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel);
|
||||
foreach (HistorianHistoricalValue value in values)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
byte[] buffer = HistorianHistoricalWriteProtocol.SerializeAddStreamValuesBuffer(
|
||||
tagGuid,
|
||||
dataType,
|
||||
value.TimestampUtc,
|
||||
value.Value,
|
||||
DateTime.UtcNow,
|
||||
value.OpcQuality);
|
||||
|
||||
GrpcHistory.AddStreamValuesResponse response = historyClient.AddStreamValues(
|
||||
new GrpcHistory.AddStreamValuesRequest
|
||||
{
|
||||
StrHandle = handle,
|
||||
BtValues = ByteString.CopyFrom(buffer),
|
||||
},
|
||||
connection.Metadata, Deadline(), cancellationToken);
|
||||
|
||||
if (!(response.Status?.BSuccess ?? false))
|
||||
{
|
||||
byte[] error = response.Status?.BtError?.ToByteArray() ?? [];
|
||||
throw new InvalidOperationException(
|
||||
$"gRPC AddStreamValues failed for tag '{tag}' (errorLen={error.Length}).");
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using Grpc.Core;
|
||||
using GrpcHistory = ArchestrA.Grpc.Contract.History;
|
||||
using GrpcRetrieval = ArchestrA.Grpc.Contract.Retrieval;
|
||||
using GrpcStatus = ArchestrA.Grpc.Contract.Status;
|
||||
|
||||
namespace AVEVA.Historian.Client.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// 2023 R2 gRPC reachability probe (roadmap item R0.4). Mirrors <see cref="Wcf.HistorianWcfProbe"/>
|
||||
/// over the gRPC transport: it calls the unauthenticated <c>GetInterfaceVersion</c> RPC on the
|
||||
/// History, Retrieval, and Status services and applies the same success criteria. No credentials
|
||||
/// are required — these RPCs run before the SSPI/Negotiate token loop — so the probe works even
|
||||
/// when authentication is unavailable.
|
||||
/// </summary>
|
||||
internal static class HistorianGrpcProbe
|
||||
{
|
||||
public static async Task<bool> ProbeAsync(HistorianClientOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
return await Task.Run(() => Probe(options, cancellationToken), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static bool Probe(HistorianClientOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
||||
DateTime deadline = DateTime.UtcNow.Add(options.ConnectTimeout > TimeSpan.Zero ? options.ConnectTimeout : TimeSpan.FromSeconds(5));
|
||||
|
||||
var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel);
|
||||
GrpcHistory.GetInterfaceVersionResponse history = historyClient.GetInterfaceVersion(
|
||||
new GrpcHistory.GetInterfaceVersionRequest(), connection.Metadata, deadline, cancellationToken);
|
||||
|
||||
var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel);
|
||||
GrpcRetrieval.GetRetrievalInterfaceVersionResponse retrieval = retrievalClient.GetRetrievalInterfaceVersion(
|
||||
new GrpcRetrieval.GetRetrievalInterfaceVersionRequest(), connection.Metadata, deadline, cancellationToken);
|
||||
|
||||
var statusClient = new GrpcStatus.StatusService.StatusServiceClient(connection.Channel);
|
||||
GrpcStatus.GetStatusInterfaceVersionResponse status = statusClient.GetStatusInterfaceVersion(
|
||||
new GrpcStatus.GetStatusInterfaceVersionRequest(), connection.Metadata, deadline, cancellationToken);
|
||||
|
||||
return history.UiError == 0
|
||||
&& history.UiVersion > 0
|
||||
&& retrieval.UiError == 0
|
||||
&& retrieval.UiVersion > 0
|
||||
&& status.UiError == 0;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ using Google.Protobuf;
|
||||
using Grpc.Core;
|
||||
using AVEVA.Historian.Client.Models;
|
||||
using AVEVA.Historian.Client.Wcf;
|
||||
using GrpcHistory = ArchestrA.Grpc.Contract.History;
|
||||
using GrpcRetrieval = ArchestrA.Grpc.Contract.Retrieval;
|
||||
|
||||
namespace AVEVA.Historian.Client.Grpc;
|
||||
@@ -16,17 +15,23 @@ namespace AVEVA.Historian.Client.Grpc;
|
||||
///
|
||||
/// Operation mapping (2020 WCF → 2023 R2 gRPC):
|
||||
/// Hist.GetInterfaceVersion → HistoryService.GetInterfaceVersion
|
||||
/// Hist.ValidateClientCredential (loop) → HistoryService.ExchangeKey (loop)
|
||||
/// Hist.ValidateClientCredential (loop) → StorageService.ValidateClientCredential (loop)
|
||||
/// Hist.OpenConnection2 → HistoryService.OpenConnection
|
||||
/// Retr.StartQuery2 → RetrievalService.StartQuery
|
||||
/// Retr.GetNextQueryResultBuffer2 (loop) → RetrievalService.GetNextQueryResultBuffer (loop)
|
||||
/// Retr.EndQuery2 → RetrievalService.EndQuery
|
||||
///
|
||||
/// NOTE: not yet live-verified against a 2023 R2 server. The auth handshake uses
|
||||
/// HistoryService.ExchangeKey because the gRPC HistoryService dropped ValidateClientCredential
|
||||
/// (it now lives only on StorageService) and gained ExchangeKey with the identical
|
||||
/// handle+token→token shape. If a live server rejects this, the handshake op is the first thing
|
||||
/// to revisit — everything else is the proven 2020 byte protocol.
|
||||
/// LIVE-VERIFIED 2026-06-21 against a real 2023 R2 server (interface versions: History=12,
|
||||
/// Retrieval=4, Storage=4). The SSPI/Negotiate token loop maps to
|
||||
/// <c>StorageService.ValidateClientCredential(Handle, InBuff)→(status, OutBuff)</c> — the op that
|
||||
/// kept the 2020 inBuff/outBuff token framing. The gRPC HistoryService dropped
|
||||
/// ValidateClientCredential and gained <c>ExchangeKey</c>, but ExchangeKey is a SEPARATE
|
||||
/// key-exchange/cert-path op, NOT the Negotiate loop: feeding it an NTLM token is rejected at
|
||||
/// round 0 regardless of credentials. An earlier revision wrongly routed the loop to ExchangeKey;
|
||||
/// routing it to StorageService.ValidateClientCredential completes the full read chain. The byte
|
||||
/// payloads (OpenConnection3 v6, token framing, DataQueryRequest, row buffers) are the proven 2020
|
||||
/// protocol and transfer unchanged — only the History interface integer differs (12 vs the 2020
|
||||
/// value 11), and that version is buffer-compatible (a live read returns rows).
|
||||
/// </summary>
|
||||
internal sealed class HistorianGrpcReadOrchestrator
|
||||
{
|
||||
@@ -97,12 +102,45 @@ internal sealed class HistorianGrpcReadOrchestrator
|
||||
}
|
||||
}
|
||||
|
||||
// Spike/Phase-1 seam (pending.md A1): run a raw query against an EXTERNALLY-supplied, already-
|
||||
// authenticated connection + client handle — i.e. NO Create()/handshake here. RunRawChain delegates
|
||||
// to this so the per-call path and the reuse path share one query implementation (DRY). The handshake
|
||||
// reuse-probe test drives this directly to measure whether the server honors a reused session.
|
||||
internal List<HistorianSample> RunRawQueryOnSession(
|
||||
HistorianGrpcConnection connection,
|
||||
uint clientHandle,
|
||||
string tag,
|
||||
DateTime startUtc,
|
||||
DateTime endUtc,
|
||||
int maxValues,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
HistorianDataQueryRequest request = HistorianWcfReadOrchestrator.BuildDataQueryRequest(tag, startUtc, endUtc, maxValues);
|
||||
return RunQuery(connection, clientHandle, request, maxValues, cancellationToken);
|
||||
}
|
||||
|
||||
private List<HistorianSample> RunRawChain(string tag, DateTime startUtc, DateTime endUtc, int maxValues, CancellationToken cancellationToken)
|
||||
{
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
|
||||
uint clientHandle = OpenAuthenticatedConnection(connection, cancellationToken);
|
||||
HistorianDataQueryRequest request = HistorianWcfReadOrchestrator.BuildDataQueryRequest(tag, startUtc, endUtc, maxValues);
|
||||
return RunQuery(connection, clientHandle, request, maxValues, cancellationToken);
|
||||
return RunRawQueryOnSession(connection, clientHandle, tag, startUtc, endUtc, maxValues, cancellationToken);
|
||||
}
|
||||
|
||||
// Spike/Phase-1 seam (pending.md A1): run an aggregate query against an EXTERNALLY-supplied,
|
||||
// already-authenticated connection + client handle — i.e. NO Create()/handshake here.
|
||||
// RunAggregateChain delegates to this so the per-call path and the reuse path share one query
|
||||
// implementation (DRY).
|
||||
internal List<HistorianAggregateSample> RunAggregateQueryOnSession(
|
||||
HistorianGrpcConnection connection,
|
||||
uint clientHandle,
|
||||
string tag,
|
||||
DateTime startUtc,
|
||||
DateTime endUtc,
|
||||
RetrievalMode mode,
|
||||
TimeSpan interval,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return RunAggregateQuery(connection, clientHandle, tag, startUtc, endUtc, mode, interval, ct);
|
||||
}
|
||||
|
||||
private List<HistorianAggregateSample> RunAggregateChain(
|
||||
@@ -110,23 +148,29 @@ internal sealed class HistorianGrpcReadOrchestrator
|
||||
{
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
|
||||
uint clientHandle = OpenAuthenticatedConnection(connection, cancellationToken);
|
||||
return RunAggregateQuery(connection, clientHandle, tag, startUtc, endUtc, mode, interval, cancellationToken);
|
||||
return RunAggregateQueryOnSession(connection, clientHandle, tag, startUtc, endUtc, mode, interval, cancellationToken);
|
||||
}
|
||||
|
||||
private List<HistorianSample> RunAtTimeChain(string tag, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken)
|
||||
// Spike/Phase-1 seam (pending.md A1): run an at-time query against an EXTERNALLY-supplied,
|
||||
// already-authenticated connection + client handle — i.e. NO Create()/handshake here.
|
||||
// RunAtTimeChain delegates to this so the per-call path and the reuse path share one
|
||||
// implementation (DRY).
|
||||
internal List<HistorianSample> RunAtTimeOnSession(
|
||||
HistorianGrpcConnection connection,
|
||||
uint clientHandle,
|
||||
string tag,
|
||||
IReadOnlyList<DateTime> timestampsUtc,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (timestampsUtc.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
|
||||
uint clientHandle = OpenAuthenticatedConnection(connection, cancellationToken);
|
||||
|
||||
List<HistorianSample> results = new(timestampsUtc.Count);
|
||||
foreach (DateTime ts in timestampsUtc)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
ct.ThrowIfCancellationRequested();
|
||||
DateTime tsUtc = ts.ToUniversalTime();
|
||||
List<HistorianAggregateSample> aggregates = RunAggregateQuery(
|
||||
connection,
|
||||
@@ -136,7 +180,7 @@ internal sealed class HistorianGrpcReadOrchestrator
|
||||
tsUtc + TimeSpan.FromTicks(1),
|
||||
RetrievalMode.Interpolated,
|
||||
TimeSpan.FromTicks(2),
|
||||
cancellationToken);
|
||||
ct);
|
||||
|
||||
if (aggregates.Count == 0)
|
||||
{
|
||||
@@ -158,52 +202,21 @@ internal sealed class HistorianGrpcReadOrchestrator
|
||||
return results;
|
||||
}
|
||||
|
||||
private uint OpenAuthenticatedConnection(HistorianGrpcConnection connection, CancellationToken cancellationToken)
|
||||
private List<HistorianSample> RunAtTimeChain(string tag, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken)
|
||||
{
|
||||
Guid contextKey = Guid.NewGuid();
|
||||
var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel);
|
||||
|
||||
GrpcHistory.GetInterfaceVersionResponse historyVersion = historyClient.GetInterfaceVersion(
|
||||
new GrpcHistory.GetInterfaceVersionRequest(), connection.Metadata, Deadline(), cancellationToken);
|
||||
HistorianServerVersionGate.Validate(HistorianServiceInterface.History, historyVersion.UiVersion, _options);
|
||||
|
||||
HistorianNativeHandshake.RunTokenRounds(
|
||||
(handle, wrapped, _) =>
|
||||
{
|
||||
GrpcHistory.ExchangeKeyResponse response = historyClient.ExchangeKey(
|
||||
new GrpcHistory.ExchangeKeyRequest { StrHandle = handle, BtInput = ByteString.CopyFrom(wrapped) },
|
||||
connection.Metadata,
|
||||
Deadline(),
|
||||
cancellationToken);
|
||||
byte[] serverOutput = response.BtOutput?.ToByteArray() ?? [];
|
||||
byte[] error = response.Status?.BtError?.ToByteArray() ?? [];
|
||||
bool success = response.Status?.BSuccess ?? false;
|
||||
return new HistorianNativeHandshake.TokenExchangeResult(success, serverOutput, error);
|
||||
},
|
||||
contextKey,
|
||||
_options,
|
||||
cancellationToken);
|
||||
|
||||
byte[] open2Request = HistorianNativeHandshake.BuildOpenConnection3Request(
|
||||
_options.Host, contextKey, HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode);
|
||||
|
||||
GrpcHistory.OpenConnectionResponse open2 = historyClient.OpenConnection(
|
||||
new GrpcHistory.OpenConnectionRequest { BtConnectionRequest = ByteString.CopyFrom(open2Request) },
|
||||
connection.Metadata,
|
||||
Deadline(),
|
||||
cancellationToken);
|
||||
|
||||
byte[] open2Response = open2.BtConnectionResponse?.ToByteArray() ?? [];
|
||||
if (!(open2.Status?.BSuccess ?? false))
|
||||
if (timestampsUtc.Count == 0)
|
||||
{
|
||||
byte[] err = open2.Status?.BtError?.ToByteArray() ?? [];
|
||||
throw new InvalidOperationException($"gRPC OpenConnection failed (errorLen={err.Length}, responseLen={open2Response.Length}).");
|
||||
return [];
|
||||
}
|
||||
|
||||
(uint clientHandle, _) = HistorianNativeHandshake.ParseOpenConnectionResponse(open2Response);
|
||||
return clientHandle;
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
|
||||
uint clientHandle = OpenAuthenticatedConnection(connection, cancellationToken);
|
||||
return RunAtTimeOnSession(connection, clientHandle, tag, timestampsUtc, cancellationToken);
|
||||
}
|
||||
|
||||
private uint OpenAuthenticatedConnection(HistorianGrpcConnection connection, CancellationToken cancellationToken)
|
||||
=> HistorianGrpcHandshake.OpenAuthenticatedConnection(connection, _options, cancellationToken);
|
||||
|
||||
private List<HistorianSample> RunQuery(
|
||||
HistorianGrpcConnection connection,
|
||||
uint clientHandle,
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
using Google.Protobuf;
|
||||
using AVEVA.Historian.Client.Wcf;
|
||||
using GrpcTransaction = ArchestrA.Grpc.Contract.Transaction;
|
||||
|
||||
namespace AVEVA.Historian.Client.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// Live probe for the M3 (historical / non-streamed original-value write) path over the 2023 R2
|
||||
/// gRPC front door. On 2020 WCF this op group is architecturally blocked: the
|
||||
/// <c>ITransactionServiceContract2.AddNonStreamValuesBegin2</c> relay returns
|
||||
/// <c>UnknownClient (51)</c> because it requires a pre-existing storage-engine pipe session
|
||||
/// (<c>STransactPipeClient2</c> → <c>aaStorageEngine.exe</c>) that no WCF op can establish — see
|
||||
/// <c>docs/plans/revision-write-path.md</c> (the D2 finding).
|
||||
///
|
||||
/// The 2023 R2 decompile shows the native gRPC client driving the SAME op group over
|
||||
/// <c>TransactionService.AddNonStreamValuesBegin/AddNonStreamValues/AddNonStreamValuesEnd</c> and
|
||||
/// passing the HistoryService Open2 session GUID directly as <c>strHandle</c> — i.e. the gRPC
|
||||
/// server is the gateway to the storage engine, so the client never touches the legacy pipe. This
|
||||
/// probe tests whether the SDK's pure-managed handshake can reproduce that: it opens a
|
||||
/// write-enabled session and calls <c>AddNonStreamValuesBegin</c>, surfacing whatever the server
|
||||
/// returns. It writes NO data — if Begin succeeds it immediately calls <c>AddNonStreamValuesEnd</c>
|
||||
/// with <c>bCommit=false</c> to discard the transaction.
|
||||
///
|
||||
/// <para><b>Scope note (corrected 2026-06-23 after a 2023 R2 binary re-read).</b> Despite the type
|
||||
/// name, this probes the <i>non-streamed ORIGINAL / backfill insert</i> capability
|
||||
/// (<c>AddNonStreamValues</c>), which is a <b>distinct capability from a revision EDIT</b>
|
||||
/// (overwriting an existing historized value with a new revision). The stock high-level client
|
||||
/// reaches a revision edit via a separate native transaction trio
|
||||
/// <c>HistorianClient.AddRevisionValuesBegin/AddRevisionValue/AddRevisionValuesEnd</c>
|
||||
/// (<c>ArchestrA.HistorianAccess.AddRevisionValues</c>, REVISION_MODE ∈ InsertLatest/UpdateSingle/
|
||||
/// UpdateMultiple). That trio has <b>NO corresponding RPC in the gRPC contract</b> (no "Revision"
|
||||
/// message type exists in <c>Archestra.Grpc.Contract</c>) — it rides the native storage-engine
|
||||
/// transaction channel only. So R4.2 revision edits remain unreachable over gRPC regardless of this
|
||||
/// probe's outcome; this probe neither covers nor unblocks them.</para>
|
||||
/// </summary>
|
||||
internal sealed class HistorianGrpcRevisionProbe
|
||||
{
|
||||
private readonly HistorianClientOptions _options;
|
||||
|
||||
public HistorianGrpcRevisionProbe(HistorianClientOptions options)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
public Task<HistorianGrpcRevisionProbeResult> ProbeBeginAsync(CancellationToken cancellationToken)
|
||||
=> Task.Run(() => ProbeBegin(cancellationToken), cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Empirical-decode driver for the <c>AddNonStreamValues</c> <c>btInput</c> buffer (R3.1). For
|
||||
/// each candidate buffer it opens a fresh transaction, sends the buffer, records the server's
|
||||
/// accept/reject, and ALWAYS ends with <c>bCommit=false</c> (rollback) so nothing persists.
|
||||
/// The candidate buffers are supplied by the caller (the RE tool) — this method does not invent
|
||||
/// wire bytes, it just reports what the live server says about each. Safe against a real tag key
|
||||
/// because every transaction is discarded.
|
||||
/// </summary>
|
||||
public Task<IReadOnlyList<HistorianGrpcNonStreamedCandidateResult>> ProbeNonStreamedBuffersAsync(
|
||||
IReadOnlyList<(string Label, byte[] Buffer)> candidates,
|
||||
CancellationToken cancellationToken)
|
||||
=> Task.Run<IReadOnlyList<HistorianGrpcNonStreamedCandidateResult>>(
|
||||
() => ProbeNonStreamedBuffers(candidates, cancellationToken), cancellationToken);
|
||||
|
||||
private List<HistorianGrpcNonStreamedCandidateResult> ProbeNonStreamedBuffers(
|
||||
IReadOnlyList<(string Label, byte[] Buffer)> candidates,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var results = new List<HistorianGrpcNonStreamedCandidateResult>();
|
||||
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
|
||||
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(
|
||||
connection, _options, cancellationToken,
|
||||
connectionMode: HistorianWcfAuthChainHelper.NativeIntegratedWriteEnabledConnectionMode);
|
||||
|
||||
var transactionClient = new GrpcTransaction.TransactionService.TransactionServiceClient(connection.Channel);
|
||||
string handle = session.StringHandle;
|
||||
DateTime Deadline() => DateTime.UtcNow.Add(_options.RequestTimeout);
|
||||
|
||||
// Prime the Transaction service session table.
|
||||
try
|
||||
{
|
||||
transactionClient.GetTransactionInterfaceVersion(
|
||||
new GrpcTransaction.GetTransactionInterfaceVersionRequest(), connection.Metadata, Deadline(), cancellationToken);
|
||||
}
|
||||
catch { /* version prime is best-effort */ }
|
||||
|
||||
foreach ((string label, byte[] buffer) in candidates)
|
||||
{
|
||||
var candidate = new HistorianGrpcNonStreamedCandidateResult { Label = label, BufferLength = buffer.Length };
|
||||
string? transactionId = null;
|
||||
try
|
||||
{
|
||||
GrpcTransaction.AddNonStreamValuesBeginResponse begin = transactionClient.AddNonStreamValuesBegin(
|
||||
new GrpcTransaction.AddNonStreamValuesBeginRequest { StrHandle = handle },
|
||||
connection.Metadata, Deadline(), cancellationToken);
|
||||
if (!(begin.Status?.BSuccess ?? false) || string.IsNullOrEmpty(begin.StrTransactionId))
|
||||
{
|
||||
candidate.BeginFailed = true;
|
||||
byte[] be = begin.Status?.BtError?.ToByteArray() ?? [];
|
||||
candidate.AddErrorHex = be.Length == 0 ? null : Convert.ToHexString(be);
|
||||
results.Add(candidate);
|
||||
continue;
|
||||
}
|
||||
|
||||
transactionId = begin.StrTransactionId;
|
||||
|
||||
GrpcTransaction.AddNonStreamValuesResponse add = transactionClient.AddNonStreamValues(
|
||||
new GrpcTransaction.AddNonStreamValuesRequest
|
||||
{
|
||||
StrHandle = handle,
|
||||
StrTransactionId = transactionId,
|
||||
BtInput = ByteString.CopyFrom(buffer),
|
||||
},
|
||||
connection.Metadata, Deadline(), cancellationToken);
|
||||
|
||||
candidate.AddSucceeded = add.Status?.BSuccess ?? false;
|
||||
byte[] ae = add.Status?.BtError?.ToByteArray() ?? [];
|
||||
candidate.AddErrorHex = ae.Length == 0 ? null : Convert.ToHexString(ae);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
candidate.Exception = $"{ex.GetType().Name}: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Always roll back — bCommit=false writes nothing.
|
||||
if (!string.IsNullOrEmpty(transactionId))
|
||||
{
|
||||
try
|
||||
{
|
||||
transactionClient.AddNonStreamValuesEnd(
|
||||
new GrpcTransaction.AddNonStreamValuesEndRequest
|
||||
{
|
||||
StrHandle = handle,
|
||||
StrTransactionId = transactionId,
|
||||
BCommit = false,
|
||||
},
|
||||
connection.Metadata, Deadline(), cancellationToken);
|
||||
}
|
||||
catch { /* rollback best-effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
results.Add(candidate);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private HistorianGrpcRevisionProbeResult ProbeBegin(CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new HistorianGrpcRevisionProbeResult();
|
||||
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
|
||||
|
||||
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(
|
||||
connection,
|
||||
_options,
|
||||
cancellationToken,
|
||||
connectionMode: HistorianWcfAuthChainHelper.NativeIntegratedWriteEnabledConnectionMode);
|
||||
|
||||
result.OpenSucceeded = true;
|
||||
result.ClientHandle = session.ClientHandle;
|
||||
result.StorageSessionId = session.StorageSessionId;
|
||||
|
||||
var transactionClient = new GrpcTransaction.TransactionService.TransactionServiceClient(connection.Channel);
|
||||
DateTime Deadline() => DateTime.UtcNow.Add(_options.RequestTimeout);
|
||||
|
||||
// Register the client with the Transaction service's session table (matches the
|
||||
// cross-service GetV priming the WCF write path uses).
|
||||
try
|
||||
{
|
||||
GrpcTransaction.GetTransactionInterfaceVersionResponse version = transactionClient.GetTransactionInterfaceVersion(
|
||||
new GrpcTransaction.GetTransactionInterfaceVersionRequest(), connection.Metadata, Deadline(), cancellationToken);
|
||||
result.TrxInterfaceVersionError = version.Error;
|
||||
result.TrxInterfaceVersion = version.Version;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.TrxInterfaceVersionException = $"{ex.GetType().Name}: {ex.Message}";
|
||||
}
|
||||
|
||||
// The decompiled native client passes the Open2 storage-session GUID (string) as strHandle.
|
||||
// Try that first (uppercase "D" form, as the other string-handle ops require), then a couple
|
||||
// of fallbacks mirroring the WCF probe, so a wrong-format rejection is distinguishable from a
|
||||
// genuine server-side block.
|
||||
foreach ((string label, string handle) in new[]
|
||||
{
|
||||
("storageSessionId-upper", session.StringHandle),
|
||||
("storageSessionId-lower", session.StorageSessionId.ToString("D")),
|
||||
("clientHandle-as-string", session.ClientHandle.ToString()),
|
||||
})
|
||||
{
|
||||
var attempt = new HistorianGrpcRevisionBeginAttempt { HandleLabel = label, HandleSent = handle };
|
||||
try
|
||||
{
|
||||
GrpcTransaction.AddNonStreamValuesBeginResponse begin = transactionClient.AddNonStreamValuesBegin(
|
||||
new GrpcTransaction.AddNonStreamValuesBeginRequest { StrHandle = handle },
|
||||
connection.Metadata, Deadline(), cancellationToken);
|
||||
|
||||
attempt.Succeeded = begin.Status?.BSuccess ?? false;
|
||||
attempt.TransactionId = begin.StrTransactionId;
|
||||
byte[] error = begin.Status?.BtError?.ToByteArray() ?? [];
|
||||
attempt.ErrorHex = error.Length == 0 ? null : Convert.ToHexString(error);
|
||||
result.BeginAttempts.Add(attempt);
|
||||
|
||||
if (attempt.Succeeded && !string.IsNullOrEmpty(attempt.TransactionId))
|
||||
{
|
||||
result.BeginSucceeded = true;
|
||||
result.BeginTransactionId = attempt.TransactionId;
|
||||
|
||||
// Discard immediately — bCommit=false writes nothing. This keeps the probe
|
||||
// read-only against the live (production) server.
|
||||
try
|
||||
{
|
||||
GrpcTransaction.AddNonStreamValuesEndResponse end = transactionClient.AddNonStreamValuesEnd(
|
||||
new GrpcTransaction.AddNonStreamValuesEndRequest
|
||||
{
|
||||
StrHandle = handle,
|
||||
StrTransactionId = attempt.TransactionId,
|
||||
BCommit = false,
|
||||
},
|
||||
connection.Metadata, Deadline(), cancellationToken);
|
||||
result.EndDiscardSucceeded = end.Status?.BSuccess ?? false;
|
||||
byte[] endError = end.Status?.BtError?.ToByteArray() ?? [];
|
||||
result.EndDiscardErrorHex = endError.Length == 0 ? null : Convert.ToHexString(endError);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.EndDiscardException = $"{ex.GetType().Name}: {ex.Message}";
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
attempt.Exception = $"{ex.GetType().Name}: {ex.Message}";
|
||||
result.BeginAttempts.Add(attempt);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class HistorianGrpcRevisionProbeResult
|
||||
{
|
||||
public bool OpenSucceeded { get; set; }
|
||||
public uint ClientHandle { get; set; }
|
||||
public Guid StorageSessionId { get; set; }
|
||||
public uint? TrxInterfaceVersionError { get; set; }
|
||||
public uint? TrxInterfaceVersion { get; set; }
|
||||
public string? TrxInterfaceVersionException { get; set; }
|
||||
public bool BeginSucceeded { get; set; }
|
||||
public string? BeginTransactionId { get; set; }
|
||||
public bool EndDiscardSucceeded { get; set; }
|
||||
public string? EndDiscardErrorHex { get; set; }
|
||||
public string? EndDiscardException { get; set; }
|
||||
public List<HistorianGrpcRevisionBeginAttempt> BeginAttempts { get; } = new();
|
||||
}
|
||||
|
||||
internal sealed class HistorianGrpcRevisionBeginAttempt
|
||||
{
|
||||
public string HandleLabel { get; set; } = "";
|
||||
public string HandleSent { get; set; } = "";
|
||||
public bool Succeeded { get; set; }
|
||||
public string? TransactionId { get; set; }
|
||||
public string? ErrorHex { get; set; }
|
||||
public string? Exception { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class HistorianGrpcNonStreamedCandidateResult
|
||||
{
|
||||
public string Label { get; set; } = "";
|
||||
public int BufferLength { get; set; }
|
||||
public bool BeginFailed { get; set; }
|
||||
public bool AddSucceeded { get; set; }
|
||||
public string? AddErrorHex { get; set; }
|
||||
public string? Exception { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using Google.Protobuf;
|
||||
using AVEVA.Historian.Client.Models;
|
||||
using AVEVA.Historian.Client.Protocol;
|
||||
using AVEVA.Historian.Client.Wcf;
|
||||
using GrpcRetrieval = ArchestrA.Grpc.Contract.Retrieval;
|
||||
|
||||
namespace AVEVA.Historian.Client.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// Executes SQL commands over the 2023 R2 gRPC transport (HCAL R1.1), mirroring
|
||||
/// <see cref="HistorianWcfSqlClient"/>'s two-op <c>ExeC</c>/<c>GetR</c> flow. The 2020 WCF path uses a
|
||||
/// dedicated <c>GetRecordSetByteStream</c> op; the gRPC front door has no such RPC, so the NRBF
|
||||
/// recordset stream would be fetched through the generic <c>RetrievalService.GetNextQueryResultBuffer</c>
|
||||
/// keyed by the query handle <c>ExecuteSqlCommand</c> returns. <c>ExecuteSqlCommand</c> takes the
|
||||
/// uppercase string session handle; the result-buffer fetch takes the transient <c>uint</c> client
|
||||
/// handle (both come from the one Open2 session).
|
||||
/// <para>
|
||||
/// <b>SERVER-WALLED (captured 2026-06-22).</b> The 2023 R2 front-door
|
||||
/// <c>RetrievalService.ExecuteSqlCommand</c> faults server-side before returning a query handle:
|
||||
/// the response carries native error 38 wrapping a managed
|
||||
/// <c>System.IndexOutOfRangeException ... at aahClientAccessPoint.CSrvDbConnection.ExecuteSqlCommand</c>.
|
||||
/// This is a server-side <c>CSrvDbConnection</c> (SQL DB-connection) precondition that the pure
|
||||
/// managed gRPC session does not establish — the same class of wall as
|
||||
/// <c>StorageService.OpenStorageConnection</c>. Priming <c>Retr.GetV</c> does not clear it, and
|
||||
/// <b>a <c>HistoryService.RegisterTags</c> prime does NOT clear it either</b> (tried live 2026-06-22 on
|
||||
/// both read-only <c>0x402</c> and write-enabled <c>0x401</c> sessions: <c>RegisterTags</c> itself
|
||||
/// returned false and <c>ExecuteSqlCommand</c> faulted with the identical native-38 IndexOutOfRange) —
|
||||
/// so unlike the OpenStorageConnection wall, the SQL DB-connection context is not established by the
|
||||
/// RegisterTags family. The request framing here is the captured/expected shape; the op stays bounded
|
||||
/// behind <see cref="ProtocolEvidenceMissingException"/> until the DB-connection registration is
|
||||
/// reproduced. Use the WCF transport for SQL.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class HistorianGrpcSqlClient
|
||||
{
|
||||
// GetNextQueryResultBuffer is byte-stream-paged; a small record set returns in one page. Runaway guard.
|
||||
private const int MaxPages = 4096;
|
||||
|
||||
public static Task<HistorianSqlResult> ExecuteSqlCommandAsync(
|
||||
HistorianClientOptions options,
|
||||
string command,
|
||||
HistorianSqlExecuteOption option,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(command);
|
||||
return Task.Run(() => ExecuteSqlCommand(options, command, option, cancellationToken), cancellationToken);
|
||||
}
|
||||
|
||||
private static HistorianSqlResult ExecuteSqlCommand(
|
||||
HistorianClientOptions options,
|
||||
string command,
|
||||
HistorianSqlExecuteOption option,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
||||
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, cancellationToken);
|
||||
var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel);
|
||||
DateTime Deadline() => DateTime.UtcNow.Add(options.RequestTimeout);
|
||||
|
||||
// Prime the Retrieval service version handshake (Retr.GetV) before the string-handle SQL op, as
|
||||
// the native WCF SQL path does — the server-side ExecuteSqlCommand otherwise faults.
|
||||
retrievalClient.GetRetrievalInterfaceVersion(
|
||||
new GrpcRetrieval.GetRetrievalInterfaceVersionRequest(), connection.Metadata, Deadline(), cancellationToken);
|
||||
|
||||
GrpcRetrieval.ExecuteSqlCommandResponse exec = retrievalClient.ExecuteSqlCommand(
|
||||
new GrpcRetrieval.ExecuteSqlCommandRequest
|
||||
{
|
||||
StrHandle = session.StringHandle,
|
||||
StrCommand = command,
|
||||
UiOption = (uint)option,
|
||||
UiQueryHandle = 0
|
||||
},
|
||||
connection.Metadata,
|
||||
Deadline(),
|
||||
cancellationToken);
|
||||
|
||||
if (!(exec.Status?.BSuccess ?? false))
|
||||
{
|
||||
// Captured 2026-06-22: the server-side CSrvDbConnection.ExecuteSqlCommand throws
|
||||
// IndexOutOfRange (native error 38) — a DB-connection precondition the pure managed gRPC
|
||||
// session doesn't establish. Surface the SDK's evidence-missing signal rather than a raw
|
||||
// server fault. See the class remarks.
|
||||
throw new ProtocolEvidenceMissingException(
|
||||
"ExecuteSqlCommand over gRPC: server-side CSrvDbConnection.ExecuteSqlCommand faults " +
|
||||
"(IndexOutOfRange / native error 38) — an unmet DB-connection precondition (gRPC transport). Use WCF.");
|
||||
}
|
||||
|
||||
int returnValue = exec.IRetValue;
|
||||
uint queryHandle = exec.UiQueryHandle;
|
||||
|
||||
using MemoryStream accumulated = new();
|
||||
for (int page = 0; page < MaxPages; page++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
GrpcRetrieval.GetNextQueryResultBufferResponse buffer = retrievalClient.GetNextQueryResultBuffer(
|
||||
new GrpcRetrieval.GetNextQueryResultBufferRequest { UiHandle = session.ClientHandle, UiQueryHandle = queryHandle },
|
||||
connection.Metadata,
|
||||
Deadline(),
|
||||
cancellationToken);
|
||||
|
||||
byte[] resultBuffer = buffer.BtQueryResult?.ToByteArray() ?? [];
|
||||
|
||||
// GetR is false-even-on-success: the final page returns false with the data still in the
|
||||
// buffer, so always consume the buffer first, then stop on a false status or an empty page.
|
||||
if (resultBuffer.Length > 0)
|
||||
{
|
||||
accumulated.Write(resultBuffer, 0, resultBuffer.Length);
|
||||
}
|
||||
|
||||
if (!(buffer.Status?.BSuccess ?? false) || resultBuffer.Length == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return HistorianSqlResultProtocol.Parse(accumulated.ToArray(), returnValue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
using Google.Protobuf;
|
||||
using Grpc.Core;
|
||||
using AVEVA.Historian.Client.Models;
|
||||
using AVEVA.Historian.Client.Wcf;
|
||||
using GrpcStatus = ArchestrA.Grpc.Contract.Status;
|
||||
|
||||
namespace AVEVA.Historian.Client.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// 2023 R2 gRPC status client (roadmap item R0.3). Mirrors
|
||||
/// <see cref="Wcf.HistorianWcfStatusClient"/> over the gRPC transport: it opens an authenticated
|
||||
/// History session via <see cref="HistorianGrpcHandshake"/> and queries the StatusService for the
|
||||
/// resulting client handle. <c>GetSystemParameter</c> carries the parameter name as a protobuf
|
||||
/// string and returns the value string directly — there is no opaque native buffer to decode.
|
||||
/// </summary>
|
||||
internal static class HistorianGrpcStatusClient
|
||||
{
|
||||
public static Task<string?> GetSystemParameterAsync(
|
||||
HistorianClientOptions options,
|
||||
string parameterName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(parameterName);
|
||||
return Task.Run(() => GetSystemParameter(options, parameterName, cancellationToken), cancellationToken);
|
||||
}
|
||||
|
||||
private static string? GetSystemParameter(HistorianClientOptions options, string parameterName, CancellationToken cancellationToken)
|
||||
{
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
||||
uint clientHandle = HistorianGrpcHandshake.OpenAuthenticatedConnection(connection, options, cancellationToken);
|
||||
return GetSystemParameterOnSession(connection, clientHandle, options, parameterName, cancellationToken);
|
||||
}
|
||||
|
||||
// Spike/Phase-1 seam (pending.md A1): run GetSystemParameter against an EXTERNALLY-supplied,
|
||||
// already-authenticated connection + client handle — NO Create()/handshake here. GetSystemParameter
|
||||
// delegates so the per-call path and the reuse path share one RPC implementation (DRY).
|
||||
internal static string? GetSystemParameterOnSession(
|
||||
HistorianGrpcConnection connection,
|
||||
uint clientHandle,
|
||||
HistorianClientOptions options,
|
||||
string parameterName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var statusClient = new GrpcStatus.StatusService.StatusServiceClient(connection.Channel);
|
||||
GrpcStatus.GetSystemParameterResponse response = statusClient.GetSystemParameter(
|
||||
new GrpcStatus.GetSystemParameterRequest { UiHandle = clientHandle, StrParameterName = parameterName },
|
||||
connection.Metadata,
|
||||
DateTime.UtcNow.Add(options.RequestTimeout),
|
||||
cancellationToken);
|
||||
|
||||
return (response.Status?.BSuccess ?? false) ? response.StrParameterValue : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a <em>measured</em> store-forward status over the 2023 R2 gRPC transport (R4.3).
|
||||
/// <para>
|
||||
/// Unlike the non-gRPC <see cref="Wcf.HistorianWcfStatusClient"/> path — which synthesizes an
|
||||
/// all-false result <em>without contacting the server</em> — this opens an authenticated session
|
||||
/// and calls <c>StatusService.GetHistorianConsoleStatus</c>, the only SF-adjacent signal reachable
|
||||
/// from a pure managed client. (The direct <c>StorageService.GetSFParameter</c> /
|
||||
/// <c>GetRemainingSnapshotsSize</c> RPCs that carry the SF buffer magnitude require the
|
||||
/// <c>OpenStorageConnection</c> storage-engine console handle, which is gated behind the D2
|
||||
/// storage-engine-pipe wall and is unobtainable here — see
|
||||
/// <c>docs/plans/store-forward-cache-reverse-engineering.md</c> §9.7.)
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Semantics: a successful console-status read means the server is reachable and its storage
|
||||
/// console is reporting normally ⇒ the not-storing baseline (all flags false), but now
|
||||
/// <em>measured</em> rather than blindly assumed. If the server cannot be reached/authenticated,
|
||||
/// or the console-status call itself fails, <see cref="HistorianStoreForwardStatus.ErrorOccurred"/>
|
||||
/// is set with the underlying error. The active-SF state (<see cref="HistorianStoreForwardStatus.Storing"/>
|
||||
/// / <see cref="HistorianStoreForwardStatus.Pending"/> / <see cref="HistorianStoreForwardStatus.DataStored"/>
|
||||
/// magnitude) is NOT observable from this signal and remains false; populating it requires the
|
||||
/// D2-gated storage-console path.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static Task<HistorianStoreForwardStatus> GetStoreForwardStatusAsync(
|
||||
HistorianClientOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
=> Task.Run(() => GetStoreForwardStatus(options, cancellationToken), cancellationToken);
|
||||
|
||||
private static HistorianStoreForwardStatus GetStoreForwardStatus(HistorianClientOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
HistorianStoreForwardStatus NotStoring(bool errorOccurred, string? error) => new(
|
||||
ServerName: options.Host,
|
||||
Pending: false,
|
||||
ErrorOccurred: errorOccurred,
|
||||
Error: error,
|
||||
DataStored: false,
|
||||
Storing: false,
|
||||
ConnectionKind: HistorianConnectionKind.Process);
|
||||
|
||||
try
|
||||
{
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
||||
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, cancellationToken);
|
||||
return GetStoreForwardStatusOnSession(connection, session.StringHandle, options, NotStoring, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Server unreachable / auth failed — genuinely measured: report it instead of a silent all-false.
|
||||
return NotStoring(errorOccurred: true, error: $"{ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// Spike/Phase-1 seam (pending.md A1): run GetHistorianConsoleStatus against an EXTERNALLY-supplied,
|
||||
// already-authenticated connection + string handle — NO Create()/handshake here. GetStoreForwardStatus
|
||||
// delegates so the per-call path and the reuse path share one RPC implementation (DRY). The
|
||||
// unreachable/auth-failure try/catch (which must also cover the handshake) stays with the per-call
|
||||
// method; this seam runs only the RPC + result mapping against the supplied session.
|
||||
internal static HistorianStoreForwardStatus GetStoreForwardStatusOnSession(
|
||||
HistorianGrpcConnection connection,
|
||||
string stringHandle,
|
||||
HistorianClientOptions options,
|
||||
Func<bool, string?, HistorianStoreForwardStatus> notStoring,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var statusClient = new GrpcStatus.StatusService.StatusServiceClient(connection.Channel);
|
||||
GrpcStatus.GetHistorianConsoleStatusResponse response = statusClient.GetHistorianConsoleStatus(
|
||||
new GrpcStatus.GetHistorianConsoleStatusRequest { StrHandle = stringHandle },
|
||||
connection.Metadata,
|
||||
DateTime.UtcNow.Add(options.RequestTimeout),
|
||||
cancellationToken);
|
||||
|
||||
if (response.Status?.BSuccess ?? false)
|
||||
{
|
||||
// Measured: server reachable, storage console reporting normally → not-storing baseline.
|
||||
return notStoring(false, null);
|
||||
}
|
||||
|
||||
byte[] err = response.Status?.BtError?.ToByteArray() ?? [];
|
||||
string detail = err.Length == 0 ? "GetHistorianConsoleStatus returned failure." : Convert.ToHexString(err);
|
||||
return notStoring(true, $"GetHistorianConsoleStatus failed: {detail}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a <em>measured</em> connection status over the 2023 R2 gRPC transport (plan #5). Mirrors
|
||||
/// <see cref="Wcf.HistorianWcfStatusClient"/>'s synthesize-from-handshake approach: it opens an
|
||||
/// authenticated session and reports <see cref="HistorianConnectionStatus.ConnectedToServer"/> /
|
||||
/// <see cref="HistorianConnectionStatus.ConnectedToServerStorage"/> from whether the handshake
|
||||
/// (GetInterfaceVersion → ValidateClientCredential token loop → OpenConnection, which yields the
|
||||
/// storage-session GUID) succeeds. There is no dedicated connection-status RPC on either transport.
|
||||
/// Store-forward connectivity is not observable here (D2-gated) and stays false.
|
||||
/// </summary>
|
||||
public static Task<HistorianConnectionStatus> GetConnectionStatusAsync(
|
||||
HistorianClientOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
=> Task.Run(() => GetConnectionStatus(options, cancellationToken), cancellationToken);
|
||||
|
||||
private static HistorianConnectionStatus GetConnectionStatus(HistorianClientOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
bool connected;
|
||||
string? error = null;
|
||||
try
|
||||
{
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
||||
// A successful OpenConnection yields a non-empty storage-session GUID — proof the server and
|
||||
// its storage session are reachable, the gRPC analog of the WCF handshake probe.
|
||||
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, cancellationToken);
|
||||
(connected, error) = EvaluateConnectionStatusOnSession(connection, session);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
connected = false;
|
||||
error = $"{ex.GetType().Name}: {ex.Message}";
|
||||
}
|
||||
|
||||
return new HistorianConnectionStatus(
|
||||
ServerName: options.Host,
|
||||
Pending: false,
|
||||
ErrorOccurred: !connected,
|
||||
Error: error,
|
||||
ConnectedToServer: connected,
|
||||
ConnectedToServerStorage: connected,
|
||||
ConnectedToStoreForward: false,
|
||||
ConnectionKind: HistorianConnectionKind.Process);
|
||||
}
|
||||
|
||||
// Spike/Phase-1 seam (pending.md A1): evaluate connection status against an EXTERNALLY-supplied,
|
||||
// already-authenticated connection + session — NO Create()/handshake here. GetConnectionStatus
|
||||
// delegates so the per-call path and the reuse path share one evaluation (DRY). Unlike the other
|
||||
// status seams there is no follow-on RPC: connectivity is derived entirely from the handshake's own
|
||||
// storage-session GUID (a successful OpenConnection yields a non-empty GUID). The unreachable/auth
|
||||
// try/catch (which must also cover the handshake) stays with the per-call method.
|
||||
internal static (bool Connected, string? Error) EvaluateConnectionStatusOnSession(
|
||||
HistorianGrpcConnection connection,
|
||||
HistorianGrpcHandshake.Session session)
|
||||
{
|
||||
bool connected = session.StorageSessionId != Guid.Empty;
|
||||
string? error = connected ? null : "OpenConnection returned an empty storage-session handle.";
|
||||
return (connected, error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the Historian server's system time-zone name (roadmap item R1.3,
|
||||
/// <c>StatusService.GetSystemTimeZoneName</c>). Unlike the 2020 WCF surface — where the native
|
||||
/// <c>GetSystemTimeZoneName</c> is a client-side stub that returns an empty string — the 2023 R2
|
||||
/// gRPC front door returns the real Windows time-zone display name (live-verified:
|
||||
/// "Eastern Daylight Time"). Takes the transient <c>uint</c> client handle; the response carries
|
||||
/// the value as a protobuf string with no opaque buffer to decode.
|
||||
/// </summary>
|
||||
public static Task<string?> GetSystemTimeZoneNameAsync(
|
||||
HistorianClientOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
=> Task.Run(() => GetSystemTimeZoneName(options, cancellationToken), cancellationToken);
|
||||
|
||||
private static string? GetSystemTimeZoneName(HistorianClientOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
||||
uint clientHandle = HistorianGrpcHandshake.OpenAuthenticatedConnection(connection, options, cancellationToken);
|
||||
|
||||
var statusClient = new GrpcStatus.StatusService.StatusServiceClient(connection.Channel);
|
||||
GrpcStatus.GetSystemTimeZoneNameResponse response = statusClient.GetSystemTimeZoneName(
|
||||
new GrpcStatus.GetSystemTimeZoneNameRequest { UiHandle = clientHandle },
|
||||
connection.Metadata,
|
||||
DateTime.UtcNow.Add(options.RequestTimeout),
|
||||
cancellationToken);
|
||||
|
||||
if (!(response.Status?.BSuccess ?? false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string? value = response.StrSystemTimeZoneName;
|
||||
return string.IsNullOrEmpty(value) ? null : value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a Historian runtime parameter over gRPC (<c>StatusService.GetRuntimeParameter</c>).
|
||||
/// The request/response byte buffers are the proven 2020 <c>GETRP</c> wire format
|
||||
/// (<see cref="HistorianRuntimeParameterProtocol"/>) carried unchanged inside the protobuf
|
||||
/// <c>btRequest</c>/<c>btResponse</c> fields; the op keys on the uppercase string session handle.
|
||||
/// </summary>
|
||||
public static Task<string?> GetRuntimeParameterAsync(
|
||||
HistorianClientOptions options,
|
||||
string parameterName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(parameterName);
|
||||
return Task.Run(() => GetRuntimeParameter(options, parameterName, cancellationToken), cancellationToken);
|
||||
}
|
||||
|
||||
private static string? GetRuntimeParameter(HistorianClientOptions options, string parameterName, CancellationToken cancellationToken)
|
||||
{
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
||||
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, cancellationToken);
|
||||
|
||||
byte[] request = HistorianRuntimeParameterProtocol.SerializeRequest(parameterName);
|
||||
|
||||
var statusClient = new GrpcStatus.StatusService.StatusServiceClient(connection.Channel);
|
||||
GrpcStatus.GetRuntimeParameterResponse response = statusClient.GetRuntimeParameter(
|
||||
new GrpcStatus.GetRuntimeParameterRequest
|
||||
{
|
||||
StrHandle = session.StringHandle,
|
||||
BtRequest = ByteString.CopyFrom(request)
|
||||
},
|
||||
connection.Metadata,
|
||||
DateTime.UtcNow.Add(options.RequestTimeout),
|
||||
cancellationToken);
|
||||
|
||||
if (!(response.Status?.BSuccess ?? false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
byte[] responseBuffer = response.BtResponse?.ToByteArray() ?? [];
|
||||
return HistorianRuntimeParameterProtocol.ParseSingleStringResult(responseBuffer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using Google.Protobuf;
|
||||
using AVEVA.Historian.Client.Wcf;
|
||||
using GrpcStorage = ArchestrA.Grpc.Contract.Storage;
|
||||
|
||||
namespace AVEVA.Historian.Client.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// Live probe for the M3 follow-up step that the R3.1 decode pinned as the missing precondition:
|
||||
/// <c>StorageService.OpenStorageConnection</c>. The R3.1 finding (see
|
||||
/// <c>docs/plans/revision-write-path.md</c> §R3.1) was that <c>AddNonStreamValues</c> reaches the
|
||||
/// server-side <c>CHistStorageConnection::StoreNonStreamValues</c>, which routes to the
|
||||
/// <c>\\.\pipe\aahStorageEngine\console,sid(...)</c> named pipe and fails for lack of a console
|
||||
/// session. <c>OpenStorageConnection</c> is the op that creates exactly that console <c>sid</c>
|
||||
/// session (returning its own <c>uint</c> handle + a NEW storage-session GUID, distinct from the
|
||||
/// Open2 session).
|
||||
///
|
||||
/// Unlike <c>AddNonStreamValues</c>, this op has NO opaque <c>btInput</c> buffer — all 12 request
|
||||
/// fields are typed protobuf fields (see <c>StorageService.proto</c>). So there are no wire bytes to
|
||||
/// guess; the only unknowns are the VALUES for a handful of inferable fields (ConnectionMode, the
|
||||
/// in/out StorageSessionId, FreeDiskSpace, credential framing). This probe sweeps a small matrix of
|
||||
/// those and reports the server's response for each, so one live run reveals which combination the
|
||||
/// storage engine accepts. It writes NO historical data — on success it immediately calls
|
||||
/// <c>CloseStorageConnection</c> to release the console session it opened.
|
||||
/// </summary>
|
||||
internal sealed class HistorianGrpcStorageConnectionProbe
|
||||
{
|
||||
// Native client identity constants, mirrored from HistorianNativeHandshake so the storage
|
||||
// engine sees the same client fingerprint the Open2 handshake presented.
|
||||
private const uint NativeClientType = 4;
|
||||
private const uint NativeClientVersionInt = 999_999;
|
||||
private const string EngineConsolePath = @"\\.\pipe\aahStorageEngine\console";
|
||||
|
||||
private readonly HistorianClientOptions _options;
|
||||
|
||||
public HistorianGrpcStorageConnectionProbe(HistorianClientOptions options)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
public Task<HistorianGrpcOpenStorageConnectionResult> ProbeAsync(CancellationToken cancellationToken)
|
||||
=> Task.Run(() => Probe(cancellationToken), cancellationToken);
|
||||
|
||||
private HistorianGrpcOpenStorageConnectionResult Probe(CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new HistorianGrpcOpenStorageConnectionResult();
|
||||
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
|
||||
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(
|
||||
connection, _options, cancellationToken,
|
||||
connectionMode: HistorianWcfAuthChainHelper.NativeIntegratedWriteEnabledConnectionMode);
|
||||
|
||||
result.OpenSucceeded = true;
|
||||
result.ClientHandle = session.ClientHandle;
|
||||
result.StorageSessionId = session.StorageSessionId;
|
||||
|
||||
var storageClient = new GrpcStorage.StorageService.StorageServiceClient(connection.Channel);
|
||||
DateTime Deadline() => DateTime.UtcNow.Add(_options.RequestTimeout);
|
||||
|
||||
// Prime the Storage service's interface-version / session table (matches the cross-service
|
||||
// GetV priming the other write paths use).
|
||||
try
|
||||
{
|
||||
GrpcStorage.GetInterfaceVersionResponse version = storageClient.GetInterfaceVersion(
|
||||
new GrpcStorage.GetInterfaceVersionRequest(), connection.Metadata, Deadline(), cancellationToken);
|
||||
result.StorageInterfaceVersion = version.UiVersion;
|
||||
result.StorageInterfaceVersionError = version.UiError;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.StorageInterfaceVersionException = $"{ex.GetType().Name}: {ex.Message}";
|
||||
}
|
||||
|
||||
Process current = Process.GetCurrentProcess();
|
||||
string machineName = Environment.MachineName;
|
||||
string processName = string.IsNullOrEmpty(current.ProcessName) ? "AVEVA.Historian.Client" : current.ProcessName;
|
||||
uint processId = checked((uint)current.Id);
|
||||
string upperGuid = session.StringHandle;
|
||||
|
||||
// Password framing: the gRPC session is already NTLM-authenticated (ValidateClientCredential),
|
||||
// so attempt 1 sends no credential (rely on the authenticated channel). If the storage engine
|
||||
// demands its own credential we'll see an auth-shaped error and add a credential-bearing
|
||||
// attempt next iteration. For explicit creds we still try UTF-16LE password bytes as a probe.
|
||||
byte[] emptyPwd = [];
|
||||
|
||||
// Sweep the genuinely-uncertain fields. Order = most-likely-correct first; stop at first
|
||||
// success. ConnectionMode 0x401 = write-enabled (Process|Write|IntegratedSecurity), the same
|
||||
// mode Open2 used for the write session. StorageSessionId-in: the native client threads the
|
||||
// Open2 storage GUID through here (in/out); empty-string is the "create fresh" fallback.
|
||||
var attempts = new List<(string Label, uint ConnectionMode, string SessionIdIn, uint FreeDiskSpace, byte[] Password)>
|
||||
{
|
||||
("mode=0x401, sid=open2-upper", 0x401, upperGuid, 0u, emptyPwd),
|
||||
("mode=0x401, sid=empty", 0x401, string.Empty, 0u, emptyPwd),
|
||||
("mode=0x402, sid=open2-upper", 0x402, upperGuid, 0u, emptyPwd),
|
||||
("mode=0x1, sid=open2-upper", 0x1, upperGuid, 0u, emptyPwd),
|
||||
("mode=0x401, sid=open2, disk=big", 0x401, upperGuid, 0xFFFFFFFFu, emptyPwd),
|
||||
};
|
||||
|
||||
foreach ((string label, uint mode, string sidIn, uint freeDisk, byte[] pwd) in attempts)
|
||||
{
|
||||
var attempt = new HistorianGrpcOpenStorageConnectionAttempt
|
||||
{
|
||||
Label = label,
|
||||
ConnectionMode = mode,
|
||||
SessionIdIn = sidIn,
|
||||
};
|
||||
try
|
||||
{
|
||||
var request = new GrpcStorage.OpenStorageConnectionRequest
|
||||
{
|
||||
HostName = machineName,
|
||||
EnginePath = EngineConsolePath,
|
||||
FreeDiskSpace = freeDisk,
|
||||
ProcessName = processName,
|
||||
ProcessId = processId,
|
||||
UserName = _options.IntegratedSecurity ? string.Empty : _options.UserName,
|
||||
Password = ByteString.CopyFrom(pwd),
|
||||
PwdLength = (uint)pwd.Length,
|
||||
ClientType = NativeClientType,
|
||||
ClientVersion = NativeClientVersionInt,
|
||||
ConnectionMode = mode,
|
||||
ConnectionTimeout = (uint)Math.Max(1, _options.RequestTimeout.TotalMilliseconds),
|
||||
StorageSessionId = sidIn,
|
||||
};
|
||||
|
||||
GrpcStorage.OpenStorageConnectionResponse response = storageClient.OpenStorageConnection(
|
||||
request, connection.Metadata, Deadline(), cancellationToken);
|
||||
|
||||
attempt.Succeeded = response.Status?.BSuccess ?? false;
|
||||
attempt.NewHandle = response.Handle;
|
||||
attempt.NewStorageSessionId = response.StorageSessionId;
|
||||
attempt.ServerStatus = response.ServerStatus;
|
||||
attempt.ConnectionTime = response.ConnectionTime;
|
||||
byte[] error = response.Status?.BtError?.ToByteArray() ?? [];
|
||||
attempt.ErrorHex = error.Length == 0 ? null : Convert.ToHexString(error);
|
||||
attempt.ErrorPreview = DescribeError(error);
|
||||
|
||||
result.Attempts.Add(attempt);
|
||||
|
||||
if (attempt.Succeeded)
|
||||
{
|
||||
result.OpenStorageSucceeded = true;
|
||||
result.AcceptedAttempt = label;
|
||||
result.NewStorageHandle = response.Handle;
|
||||
result.NewStorageSessionId = response.StorageSessionId;
|
||||
|
||||
// Release the console session immediately — this probe persists nothing.
|
||||
try
|
||||
{
|
||||
GrpcStorage.CloseStorageConnectionResponse close = storageClient.CloseStorageConnection(
|
||||
new GrpcStorage.CloseStorageConnectionRequest { Handle = response.Handle },
|
||||
connection.Metadata, Deadline(), cancellationToken);
|
||||
result.CloseSucceeded = close.Status?.BSuccess ?? false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.CloseException = $"{ex.GetType().Name}: {ex.Message}";
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
attempt.Exception = $"{ex.GetType().Name}: {ex.Message}";
|
||||
result.Attempts.Add(attempt);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Short printable preview of a server error buffer (status codes/messages, no secrets).</summary>
|
||||
private static string? DescribeError(byte[] error)
|
||||
{
|
||||
if (error.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
ReadOnlySpan<byte> preview = error.AsSpan(0, Math.Min(error.Length, 96));
|
||||
var sb = new StringBuilder(preview.Length);
|
||||
foreach (byte b in preview)
|
||||
{
|
||||
sb.Append(b is >= 0x20 and < 0x7F ? (char)b : '.');
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class HistorianGrpcOpenStorageConnectionResult
|
||||
{
|
||||
public bool OpenSucceeded { get; set; }
|
||||
public uint ClientHandle { get; set; }
|
||||
public Guid StorageSessionId { get; set; }
|
||||
public uint? StorageInterfaceVersion { get; set; }
|
||||
public uint? StorageInterfaceVersionError { get; set; }
|
||||
public string? StorageInterfaceVersionException { get; set; }
|
||||
public bool OpenStorageSucceeded { get; set; }
|
||||
public string? AcceptedAttempt { get; set; }
|
||||
public uint NewStorageHandle { get; set; }
|
||||
public string? NewStorageSessionId { get; set; }
|
||||
public bool CloseSucceeded { get; set; }
|
||||
public string? CloseException { get; set; }
|
||||
public List<HistorianGrpcOpenStorageConnectionAttempt> Attempts { get; } = new();
|
||||
}
|
||||
|
||||
internal sealed class HistorianGrpcOpenStorageConnectionAttempt
|
||||
{
|
||||
public string Label { get; set; } = "";
|
||||
public uint ConnectionMode { get; set; }
|
||||
public string SessionIdIn { get; set; } = "";
|
||||
public bool Succeeded { get; set; }
|
||||
public uint NewHandle { get; set; }
|
||||
public string? NewStorageSessionId { get; set; }
|
||||
public uint ServerStatus { get; set; }
|
||||
public ulong ConnectionTime { get; set; }
|
||||
public string? ErrorHex { get; set; }
|
||||
public string? ErrorPreview { get; set; }
|
||||
public string? Exception { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
using System.Text;
|
||||
using Google.Protobuf;
|
||||
using AVEVA.Historian.Client.Wcf;
|
||||
using GrpcStorage = ArchestrA.Grpc.Contract.Storage;
|
||||
using GrpcStatus = ArchestrA.Grpc.Contract.Status;
|
||||
|
||||
namespace AVEVA.Historian.Client.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// R4.3 discovery probe (see <c>docs/plans/store-forward-cache-reverse-engineering.md</c> §9).
|
||||
/// Reads store-forward (SF) state from the 2023 R2 <c>StorageService</c> via the recovered PULL
|
||||
/// RPCs — no duplex/callback contract, no native <c>HISTORIAN_STORAGE_STATUS</c> struct decode:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>GetRemainingSnapshotsSize(Handle) → uint64 SnapshotSize</c> — the pending-buffer
|
||||
/// magnitude in one call (non-zero ⇒ data queued).</item>
|
||||
/// <item><c>GetSFParameter(Handle, ParameterName) → string</c> — the string-keyed SF state read,
|
||||
/// the analogue of the already-shipped <c>GetSystemParameter</c>.</item>
|
||||
/// </list>
|
||||
/// The one surviving unknown (§9.3) is which <c>uint Handle</c> these RPCs want: the cheap session
|
||||
/// <c>ClientHandle</c> (unblocked) or the <c>OpenStorageConnection</c> console handle (the D2
|
||||
/// storage-engine-pipe wall). This probe tries the session handle FIRST and, only if those calls
|
||||
/// fail handle-shaped, falls back to opening a storage console session to disambiguate — releasing
|
||||
/// it immediately. It writes NOTHING.
|
||||
/// </summary>
|
||||
internal sealed class HistorianGrpcStoreForwardStatusProbe
|
||||
{
|
||||
/// <summary>Candidate SF parameter names swept through <c>GetSFParameter</c>. Derived from the
|
||||
/// managed <c>HistorianStoreForwardStatus</c> fields + the native SF getter vocabulary; the
|
||||
/// server reveals which it accepts.</summary>
|
||||
private static readonly string[] CandidateParameterNames =
|
||||
[
|
||||
"Status", "Storing", "Pending", "DataStored", "ErrorOccurred", "Error",
|
||||
"SFStatus", "SF.Status", "StoreForward", "StoreForwardStatus", "Forward",
|
||||
"ForwardingStatus", "CacheSize", "SnapshotSize", "RemainingSize", "Enabled",
|
||||
];
|
||||
|
||||
private readonly HistorianClientOptions _options;
|
||||
private readonly bool _writeEnabledSession;
|
||||
|
||||
public HistorianGrpcStoreForwardStatusProbe(HistorianClientOptions options, bool writeEnabledSession = false)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_writeEnabledSession = writeEnabledSession;
|
||||
}
|
||||
|
||||
public Task<HistorianGrpcStoreForwardStatusProbeResult> ProbeAsync(CancellationToken cancellationToken)
|
||||
=> Task.Run(() => Probe(cancellationToken), cancellationToken);
|
||||
|
||||
private HistorianGrpcStoreForwardStatusProbeResult Probe(CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new HistorianGrpcStoreForwardStatusProbeResult();
|
||||
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
|
||||
// The idle probe found GetRemainingSnapshotsSize returns err 132 OperationNotEnabled under a
|
||||
// read-only session — the same 0x402-vs-0x401 gate the write paths flip. So allow opening the
|
||||
// session write-enabled to confirm the op succeeds when enabled.
|
||||
uint connectionMode = _writeEnabledSession
|
||||
? HistorianWcfAuthChainHelper.NativeIntegratedWriteEnabledConnectionMode
|
||||
: HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode;
|
||||
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(
|
||||
connection, _options, cancellationToken, connectionMode);
|
||||
|
||||
result.OpenSucceeded = true;
|
||||
result.WriteEnabledSession = _writeEnabledSession;
|
||||
result.ClientHandle = session.ClientHandle;
|
||||
result.StringHandle = session.StringHandle;
|
||||
|
||||
var storageClient = new GrpcStorage.StorageService.StorageServiceClient(connection.Channel);
|
||||
DateTime Deadline() => DateTime.UtcNow.Add(_options.RequestTimeout);
|
||||
|
||||
// Session-handle StatusService route (the old plan's Q2): GetHistorianConsoleStatus +
|
||||
// GetHistorianInfo take the STRING handle, so they're NOT gated on the OpenStorageConnection
|
||||
// console handle (the D2 wall). This is the most promising idle-baseline lever.
|
||||
ProbeStatusService(result, connection, Deadline, session, cancellationToken);
|
||||
|
||||
// Prime the Storage service's interface-version / session table.
|
||||
try
|
||||
{
|
||||
GrpcStorage.GetInterfaceVersionResponse version = storageClient.GetInterfaceVersion(
|
||||
new GrpcStorage.GetInterfaceVersionRequest(), connection.Metadata, Deadline(), cancellationToken);
|
||||
result.StorageInterfaceVersion = version.UiVersion;
|
||||
result.StorageInterfaceVersionError = version.UiError;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.StorageInterfaceVersionException = $"{ex.GetType().Name}: {ex.Message}";
|
||||
}
|
||||
|
||||
// Phase 1: try the cheap session ClientHandle (best case — status reads shouldn't need a
|
||||
// console writer session).
|
||||
result.SessionHandleAttempt = QueryWithHandle(
|
||||
storageClient, connection, Deadline, session.ClientHandle, "session.ClientHandle", cancellationToken);
|
||||
|
||||
// Phase 2 (disambiguation, §9.3): only if every Phase-1 call failed, try the
|
||||
// OpenStorageConnection console handle to learn whether SF reads are gated on the D2 wall.
|
||||
if (!result.SessionHandleAttempt.AnySucceeded)
|
||||
{
|
||||
TryConsoleHandleFallback(result, storageClient, connection, Deadline, session, cancellationToken);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static HistorianGrpcSfHandleAttempt QueryWithHandle(
|
||||
GrpcStorage.StorageService.StorageServiceClient storageClient,
|
||||
HistorianGrpcConnection connection,
|
||||
Func<DateTime> deadline,
|
||||
uint handle,
|
||||
string handleLabel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var attempt = new HistorianGrpcSfHandleAttempt { HandleLabel = handleLabel, Handle = handle };
|
||||
|
||||
// GetRemainingSnapshotsSize — the single cleanest pending/idle signal.
|
||||
try
|
||||
{
|
||||
GrpcStorage.GetRemainingSnapshotsSizeResponse resp = storageClient.GetRemainingSnapshotsSize(
|
||||
new GrpcStorage.GetRemainingSnapshotsSizeRequest { Handle = handle },
|
||||
connection.Metadata, deadline(), cancellationToken);
|
||||
byte[] err = resp.Status?.BtError?.ToByteArray() ?? [];
|
||||
attempt.RemainingSnapshotsSizeSucceeded = resp.Status?.BSuccess ?? false;
|
||||
attempt.RemainingSnapshotsSize = resp.SnapshotSize;
|
||||
attempt.RemainingSnapshotsSizeError = DescribeError(err);
|
||||
attempt.RemainingSnapshotsSizeErrorHex = err.Length == 0 ? null : Convert.ToHexString(err);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
attempt.RemainingSnapshotsSizeException = $"{ex.GetType().Name}: {ex.Message}";
|
||||
}
|
||||
|
||||
// Sweep GetSFParameter over the candidate name vocabulary.
|
||||
foreach (string name in CandidateParameterNames)
|
||||
{
|
||||
var pr = new HistorianGrpcSfParameterResult { Name = name };
|
||||
try
|
||||
{
|
||||
GrpcStorage.GetSFParameterResponse resp = storageClient.GetSFParameter(
|
||||
new GrpcStorage.GetSFParameterRequest { Handle = handle, ParameterName = name },
|
||||
connection.Metadata, deadline(), cancellationToken);
|
||||
pr.Succeeded = resp.Status?.BSuccess ?? false;
|
||||
pr.Value = resp.ParamaterValue;
|
||||
pr.Error = DescribeError(resp.Status?.BtError?.ToByteArray() ?? []);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
pr.Exception = $"{ex.GetType().Name}: {ex.Message}";
|
||||
}
|
||||
attempt.Parameters.Add(pr);
|
||||
}
|
||||
|
||||
return attempt;
|
||||
}
|
||||
|
||||
private void TryConsoleHandleFallback(
|
||||
HistorianGrpcStoreForwardStatusProbeResult result,
|
||||
GrpcStorage.StorageService.StorageServiceClient storageClient,
|
||||
HistorianGrpcConnection connection,
|
||||
Func<DateTime> deadline,
|
||||
HistorianGrpcHandshake.Session session,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
result.ConsoleHandleFallbackAttempted = true;
|
||||
try
|
||||
{
|
||||
var request = new GrpcStorage.OpenStorageConnectionRequest
|
||||
{
|
||||
HostName = Environment.MachineName,
|
||||
EnginePath = @"\\.\pipe\aahStorageEngine\console",
|
||||
FreeDiskSpace = 0,
|
||||
ProcessName = "AVEVA.Historian.Client",
|
||||
ProcessId = (uint)Environment.ProcessId,
|
||||
UserName = _options.IntegratedSecurity ? string.Empty : _options.UserName,
|
||||
Password = ByteString.Empty,
|
||||
PwdLength = 0,
|
||||
ClientType = 4,
|
||||
ClientVersion = 999_999,
|
||||
ConnectionMode = HistorianWcfAuthChainHelper.NativeIntegratedWriteEnabledConnectionMode,
|
||||
ConnectionTimeout = (uint)Math.Max(1, _options.RequestTimeout.TotalMilliseconds),
|
||||
StorageSessionId = session.StringHandle,
|
||||
};
|
||||
GrpcStorage.OpenStorageConnectionResponse open = storageClient.OpenStorageConnection(
|
||||
request, connection.Metadata, deadline(), cancellationToken);
|
||||
|
||||
byte[] openErr = open.Status?.BtError?.ToByteArray() ?? [];
|
||||
result.OpenStorageConnectionSucceeded = open.Status?.BSuccess ?? false;
|
||||
result.OpenStorageConnectionError = DescribeError(openErr);
|
||||
result.OpenStorageConnectionErrorHex = openErr.Length == 0 ? null : Convert.ToHexString(openErr);
|
||||
|
||||
if (result.OpenStorageConnectionSucceeded)
|
||||
{
|
||||
result.ConsoleHandleAttempt = QueryWithHandle(
|
||||
storageClient, connection, deadline, open.Handle, "OpenStorageConnection.Handle", cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
storageClient.CloseStorageConnection(
|
||||
new GrpcStorage.CloseStorageConnectionRequest { Handle = open.Handle },
|
||||
connection.Metadata, deadline(), cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.CloseStorageConnectionException = $"{ex.GetType().Name}: {ex.Message}";
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.OpenStorageConnectionException = $"{ex.GetType().Name}: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private void ProbeStatusService(
|
||||
HistorianGrpcStoreForwardStatusProbeResult result,
|
||||
HistorianGrpcConnection connection,
|
||||
Func<DateTime> deadline,
|
||||
HistorianGrpcHandshake.Session session,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var status = new HistorianGrpcSfStatusServiceProbe();
|
||||
var statusClient = new GrpcStatus.StatusService.StatusServiceClient(connection.Channel);
|
||||
string strHandle = session.StringHandle;
|
||||
|
||||
// GetHistorianConsoleStatus(strHandle) → uiConsoleStatus. The "console" is the storage-engine
|
||||
// console where SF lives; this uint status may encode the SF/storing state.
|
||||
try
|
||||
{
|
||||
GrpcStatus.GetHistorianConsoleStatusResponse resp = statusClient.GetHistorianConsoleStatus(
|
||||
new GrpcStatus.GetHistorianConsoleStatusRequest { StrHandle = strHandle },
|
||||
connection.Metadata, deadline(), cancellationToken);
|
||||
byte[] err = resp.Status?.BtError?.ToByteArray() ?? [];
|
||||
status.ConsoleStatusSucceeded = resp.Status?.BSuccess ?? false;
|
||||
status.ConsoleStatusValue = resp.UiConsoleStatus;
|
||||
status.ConsoleStatusError = DescribeError(err);
|
||||
status.ConsoleStatusErrorHex = err.Length == 0 ? null : Convert.ToHexString(err);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
status.ConsoleStatusException = $"{ex.GetType().Name}: {ex.Message}";
|
||||
}
|
||||
|
||||
// GetHistorianInfo(strHandle, btRequest) → btHistorianInfo. btRequest framing is unknown; try a
|
||||
// small set of candidates and report whichever the server accepts + the returned blob (hex).
|
||||
var infoCandidates = new List<(string Label, byte[] Request)>
|
||||
{
|
||||
("empty", []),
|
||||
("u32(0)", [0, 0, 0, 0]),
|
||||
("ascii:StoreForward", Encoding.ASCII.GetBytes("StoreForward")),
|
||||
("utf16:StoreForward", Encoding.Unicode.GetBytes("StoreForward")),
|
||||
};
|
||||
foreach ((string label, byte[] request) in infoCandidates)
|
||||
{
|
||||
var info = new HistorianGrpcSfHistorianInfoResult { Label = label };
|
||||
try
|
||||
{
|
||||
GrpcStatus.GetHistorianInfoResponse resp = statusClient.GetHistorianInfo(
|
||||
new GrpcStatus.GetHistorianInfoRequest { StrHandle = strHandle, BtRequest = ByteString.CopyFrom(request) },
|
||||
connection.Metadata, deadline(), cancellationToken);
|
||||
byte[] blob = resp.BtHistorianInfo?.ToByteArray() ?? [];
|
||||
byte[] err = resp.Status?.BtError?.ToByteArray() ?? [];
|
||||
info.Succeeded = resp.Status?.BSuccess ?? false;
|
||||
info.InfoLength = blob.Length;
|
||||
info.InfoHex = blob.Length == 0 ? null : Convert.ToHexString(blob.AsSpan(0, Math.Min(blob.Length, 256)));
|
||||
info.InfoText = DescribeError(blob);
|
||||
info.Error = DescribeError(err);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
info.Exception = $"{ex.GetType().Name}: {ex.Message}";
|
||||
}
|
||||
status.HistorianInfo.Add(info);
|
||||
}
|
||||
|
||||
result.StatusService = status;
|
||||
}
|
||||
|
||||
/// <summary>Short printable preview of a server error buffer (status text only, no secrets).</summary>
|
||||
private static string? DescribeError(byte[] error)
|
||||
{
|
||||
if (error.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
ReadOnlySpan<byte> preview = error.AsSpan(0, Math.Min(error.Length, 96));
|
||||
var sb = new StringBuilder(preview.Length);
|
||||
foreach (byte b in preview)
|
||||
{
|
||||
sb.Append(b is >= 0x20 and < 0x7F ? (char)b : '.');
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class HistorianGrpcStoreForwardStatusProbeResult
|
||||
{
|
||||
public bool OpenSucceeded { get; set; }
|
||||
public bool WriteEnabledSession { get; set; }
|
||||
public uint ClientHandle { get; set; }
|
||||
public string? StringHandle { get; set; }
|
||||
public uint? StorageInterfaceVersion { get; set; }
|
||||
public uint? StorageInterfaceVersionError { get; set; }
|
||||
public string? StorageInterfaceVersionException { get; set; }
|
||||
|
||||
public HistorianGrpcSfStatusServiceProbe? StatusService { get; set; }
|
||||
|
||||
public HistorianGrpcSfHandleAttempt? SessionHandleAttempt { get; set; }
|
||||
|
||||
public bool ConsoleHandleFallbackAttempted { get; set; }
|
||||
public bool OpenStorageConnectionSucceeded { get; set; }
|
||||
public string? OpenStorageConnectionError { get; set; }
|
||||
public string? OpenStorageConnectionErrorHex { get; set; }
|
||||
public string? OpenStorageConnectionException { get; set; }
|
||||
public HistorianGrpcSfHandleAttempt? ConsoleHandleAttempt { get; set; }
|
||||
public string? CloseStorageConnectionException { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class HistorianGrpcSfStatusServiceProbe
|
||||
{
|
||||
public bool ConsoleStatusSucceeded { get; set; }
|
||||
public uint ConsoleStatusValue { get; set; }
|
||||
public string? ConsoleStatusError { get; set; }
|
||||
public string? ConsoleStatusErrorHex { get; set; }
|
||||
public string? ConsoleStatusException { get; set; }
|
||||
|
||||
public List<HistorianGrpcSfHistorianInfoResult> HistorianInfo { get; } = new();
|
||||
}
|
||||
|
||||
internal sealed class HistorianGrpcSfHistorianInfoResult
|
||||
{
|
||||
public string Label { get; set; } = "";
|
||||
public bool Succeeded { get; set; }
|
||||
public int InfoLength { get; set; }
|
||||
public string? InfoHex { get; set; }
|
||||
public string? InfoText { get; set; }
|
||||
public string? Error { get; set; }
|
||||
public string? Exception { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class HistorianGrpcSfHandleAttempt
|
||||
{
|
||||
public string HandleLabel { get; set; } = "";
|
||||
public uint Handle { get; set; }
|
||||
|
||||
public bool RemainingSnapshotsSizeSucceeded { get; set; }
|
||||
public ulong RemainingSnapshotsSize { get; set; }
|
||||
public string? RemainingSnapshotsSizeError { get; set; }
|
||||
public string? RemainingSnapshotsSizeErrorHex { get; set; }
|
||||
public string? RemainingSnapshotsSizeException { get; set; }
|
||||
|
||||
public List<HistorianGrpcSfParameterResult> Parameters { get; } = new();
|
||||
|
||||
/// <summary>True when any pull RPC (size or a parameter) returned bSuccess for this handle.</summary>
|
||||
public bool AnySucceeded =>
|
||||
RemainingSnapshotsSizeSucceeded || Parameters.Exists(static p => p.Succeeded);
|
||||
}
|
||||
|
||||
internal sealed class HistorianGrpcSfParameterResult
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public bool Succeeded { get; set; }
|
||||
public string? Value { get; set; }
|
||||
public string? Error { get; set; }
|
||||
public string? Exception { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
using System.Text;
|
||||
using Google.Protobuf;
|
||||
using Grpc.Core;
|
||||
using AVEVA.Historian.Client.Models;
|
||||
using AVEVA.Historian.Client.Wcf;
|
||||
using GrpcRetrieval = ArchestrA.Grpc.Contract.Retrieval;
|
||||
|
||||
namespace AVEVA.Historian.Client.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// 2023 R2 gRPC tag-metadata + browse client (roadmap items R0.2 metadata, R0.1 browse).
|
||||
/// Browse drives <c>StartTagQuery</c> (OData filter) → paged <c>QueryTag</c> → <c>EndTagQuery</c>
|
||||
/// (see <see cref="BrowseTagNamesAsync"/> and <c>docs/reverse-engineering/grpc-tag-query-odata.md</c>).
|
||||
/// Unlike the WCF singular
|
||||
/// <c>GetTagInfoFromName</c> (a <c>uint</c>-handle op), the gRPC front door exposes the plural
|
||||
/// <c>RetrievalService.GetTagInfosFromName</c> — a <b>string-handle</b> op keyed off the Open2
|
||||
/// storage-session GUID (uppercase). The request <c>btTagNames</c> buffer and response
|
||||
/// <c>btTagInfos</c> buffer carry the proven native encodings:
|
||||
/// <list type="bullet">
|
||||
/// <item>request <c>btTagNames</c> = <c>uint count</c> + per-name(<c>uint charCount</c> + UTF-16LE)</item>
|
||||
/// <item>response <c>btTagInfos</c> = <c>uint tagCount</c> + per-tag CTagMetadata record
|
||||
/// (the same record <see cref="HistorianTagQueryProtocol.ParseGetTagInfoResponse"/> decodes)</item>
|
||||
/// </list>
|
||||
/// The string-handle "wall" that blocks this op family on the 2020 WCF transport does not apply on
|
||||
/// the gRPC front door (different envelope/registration) — see
|
||||
/// <c>docs/reverse-engineering/wcf-string-handle-wall.md</c>.
|
||||
/// </summary>
|
||||
internal static class HistorianGrpcTagClient
|
||||
{
|
||||
public static Task<HistorianTagMetadata?> GetTagMetadataAsync(
|
||||
HistorianClientOptions options,
|
||||
string tag,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
|
||||
return Task.Run(() => GetTagMetadata(options, tag, cancellationToken), cancellationToken);
|
||||
}
|
||||
|
||||
private static HistorianTagMetadata? GetTagMetadata(HistorianClientOptions options, string tag, CancellationToken cancellationToken)
|
||||
{
|
||||
byte[] tagInfos = GetTagInfosRaw(options, [tag], cancellationToken);
|
||||
return ParseTagMetadata(tagInfos);
|
||||
}
|
||||
|
||||
// Spike/Phase-1 seam (pending.md A1): resolve tag metadata against an EXTERNALLY-supplied,
|
||||
// already-authenticated connection + session — i.e. NO Create()/handshake here. The per-call
|
||||
// GetTagMetadata and this seam share the parse tail (ParseTagMetadata) so neither duplicates the
|
||||
// decode logic (DRY).
|
||||
internal static HistorianTagMetadata? GetTagMetadataOnSession(
|
||||
HistorianGrpcConnection connection,
|
||||
HistorianGrpcHandshake.Session session,
|
||||
string tag,
|
||||
HistorianClientOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
byte[] tagInfos = GetTagInfosRawOnSession(connection, session, [tag], options, cancellationToken);
|
||||
return ParseTagMetadata(tagInfos);
|
||||
}
|
||||
|
||||
// Shared parse tail for both the per-call GetTagMetadata and the reuse-path GetTagMetadataOnSession.
|
||||
private static HistorianTagMetadata? ParseTagMetadata(byte[] tagInfos)
|
||||
{
|
||||
if (tagInfos.Length < 4)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
IReadOnlyList<HistorianTagInfoResponse> parsed = HistorianTagQueryProtocol.ParseGetTagInfoResponse(tagInfos);
|
||||
if (parsed.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
HistorianTagInfoResponse info = parsed[0];
|
||||
return new HistorianTagMetadata(
|
||||
Name: info.TagName,
|
||||
Key: info.TagKey,
|
||||
DataType: HistorianWcfTagClient.MapDataType(info.NativeDataTypeDescriptor),
|
||||
Description: info.Description ?? info.MetadataProvider,
|
||||
EngineeringUnit: info.EngineeringUnit ?? string.Empty,
|
||||
MinRaw: info.MinEU,
|
||||
MaxRaw: info.MaxEU);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issues a single <c>GetTagInfosFromName</c> call and returns the raw native <c>btTagInfos</c>
|
||||
/// response buffer. Internal so reverse-engineering probes can capture the framing.
|
||||
/// </summary>
|
||||
internal static byte[] GetTagInfosRaw(HistorianClientOptions options, IReadOnlyList<string> tags, CancellationToken cancellationToken)
|
||||
{
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
||||
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, cancellationToken);
|
||||
return GetTagInfosRawOnSession(connection, session, tags, options, cancellationToken);
|
||||
}
|
||||
|
||||
// Spike/Phase-1 seam (pending.md A1): issue GetTagInfosFromName against an EXTERNALLY-supplied,
|
||||
// already-authenticated connection + session — i.e. NO Create()/handshake here. GetTagInfosRaw
|
||||
// delegates to this so the per-call path and the reuse path share one query implementation (DRY).
|
||||
internal static byte[] GetTagInfosRawOnSession(
|
||||
HistorianGrpcConnection connection,
|
||||
HistorianGrpcHandshake.Session session,
|
||||
IReadOnlyList<string> tags,
|
||||
HistorianClientOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel);
|
||||
byte[] requestBuffer = BuildTagNamesBuffer(tags);
|
||||
GrpcRetrieval.GetTagInfosFromNameResponse response = retrievalClient.GetTagInfosFromName(
|
||||
new GrpcRetrieval.GetTagInfosFromNameRequest
|
||||
{
|
||||
StrHandle = session.StringHandle,
|
||||
BtTagNames = ByteString.CopyFrom(requestBuffer),
|
||||
UiSequence = 0
|
||||
},
|
||||
connection.Metadata,
|
||||
DateTime.UtcNow.Add(options.RequestTimeout),
|
||||
cancellationToken);
|
||||
|
||||
if (!(response.Status?.BSuccess ?? false))
|
||||
{
|
||||
byte[] error = response.Status?.BtError?.ToByteArray() ?? [];
|
||||
throw new InvalidOperationException($"gRPC GetTagInfosFromName failed (errorLen={error.Length}).");
|
||||
}
|
||||
|
||||
return response.BtTagInfos?.ToByteArray() ?? [];
|
||||
}
|
||||
|
||||
// GetTagExtendedPropertiesFromName is sequence-paged; a single tag returns everything on page 0
|
||||
// and an empty/false buffer next. The cap is a runaway guard (mirrors the WCF path).
|
||||
private const int MaxExtendedPropertyPages = 64;
|
||||
|
||||
/// <summary>
|
||||
/// Reads a tag's extended (user-defined) properties over gRPC
|
||||
/// (<c>RetrievalService.GetTagExtendedPropertiesFromName</c>, a string-handle op). The request
|
||||
/// <c>btTagNames</c> and response <c>btTeps</c> buffers are the proven 2020 <c>GetTepByNm</c> wire
|
||||
/// format (<see cref="HistorianTagExtendedPropertyProtocol"/>) carried unchanged; paging follows
|
||||
/// the same sequence loop as the WCF path.
|
||||
/// </summary>
|
||||
public static Task<IReadOnlyList<HistorianTagExtendedProperty>> GetTagExtendedPropertiesAsync(
|
||||
HistorianClientOptions options,
|
||||
string tag,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
|
||||
return Task.Run(() => GetTagExtendedProperties(options, tag, cancellationToken), cancellationToken);
|
||||
}
|
||||
|
||||
// No …OnSession seam: extended-properties browse stays per-call (not amortized through the session
|
||||
// pool — out of A1-broadening scope). Add a seam here only if the pool ever needs to route it.
|
||||
/// <summary>
|
||||
/// Issues a single page-0 <c>GetTagExtendedPropertiesFromName</c> call and returns the raw native
|
||||
/// <c>btTeps</c> response buffer (empty when the server reports no rows / non-success). Internal so
|
||||
/// reverse-engineering probes can capture the framing.
|
||||
/// </summary>
|
||||
internal static byte[] GetTagExtendedPropertiesRaw(HistorianClientOptions options, string tag, CancellationToken cancellationToken)
|
||||
{
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
||||
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, cancellationToken);
|
||||
var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel);
|
||||
byte[] tagNames = HistorianTagExtendedPropertyProtocol.SerializeRequest(tag);
|
||||
GrpcRetrieval.GetTagExtendedPropertiesFromNameResponse response = retrievalClient.GetTagExtendedPropertiesFromName(
|
||||
new GrpcRetrieval.GetTagExtendedPropertiesFromNameRequest
|
||||
{
|
||||
StrHandle = session.StringHandle,
|
||||
BtTagNames = ByteString.CopyFrom(tagNames),
|
||||
UiSequence = 0
|
||||
},
|
||||
connection.Metadata,
|
||||
DateTime.UtcNow.Add(options.RequestTimeout),
|
||||
cancellationToken);
|
||||
|
||||
return (response.Status?.BSuccess ?? false) ? response.BtTeps?.ToByteArray() ?? [] : [];
|
||||
}
|
||||
|
||||
private static IReadOnlyList<HistorianTagExtendedProperty> GetTagExtendedProperties(
|
||||
HistorianClientOptions options, string tag, CancellationToken cancellationToken)
|
||||
{
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
||||
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, cancellationToken);
|
||||
var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel);
|
||||
|
||||
byte[] tagNames = HistorianTagExtendedPropertyProtocol.SerializeRequest(tag);
|
||||
List<HistorianTagExtendedProperty> properties = [];
|
||||
uint sequence = 0;
|
||||
|
||||
for (int page = 0; page < MaxExtendedPropertyPages; page++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
GrpcRetrieval.GetTagExtendedPropertiesFromNameResponse response = retrievalClient.GetTagExtendedPropertiesFromName(
|
||||
new GrpcRetrieval.GetTagExtendedPropertiesFromNameRequest
|
||||
{
|
||||
StrHandle = session.StringHandle,
|
||||
BtTagNames = ByteString.CopyFrom(tagNames),
|
||||
UiSequence = sequence
|
||||
},
|
||||
connection.Metadata,
|
||||
DateTime.UtcNow.Add(options.RequestTimeout),
|
||||
cancellationToken);
|
||||
|
||||
if (!(response.Status?.BSuccess ?? false))
|
||||
{
|
||||
// A non-success terminates paging. The server signals "no more rows" with a
|
||||
// CClientUtil::FillBufferFromVector marker (live-confirmed) — including on page 0 when
|
||||
// the tag has no user-defined properties, which is a legitimate empty result, not an
|
||||
// error. This mirrors the WCF path, which also breaks (returns empty) rather than throws.
|
||||
break;
|
||||
}
|
||||
|
||||
IReadOnlyList<HistorianTagExtendedPropertyRow> rows =
|
||||
HistorianTagExtendedPropertyProtocol.ParseResponse(response.BtTeps?.ToByteArray() ?? []);
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
foreach (HistorianTagExtendedPropertyRow row in rows)
|
||||
{
|
||||
properties.Add(new HistorianTagExtendedProperty(row.PropertyName, row.Value));
|
||||
}
|
||||
|
||||
sequence = response.UiSequence;
|
||||
}
|
||||
|
||||
return properties;
|
||||
}
|
||||
|
||||
// QueryTag (browse paging) request framing, recovered from the .rdata packet-descriptor table
|
||||
// in aahClientManaged.dll (entries {0x6751,1}=StartTagQuery, {0x6752,1}=QueryTag) and confirmed
|
||||
// live: btRequest = u16 marker(0x6752) + u16 version(1) + u16 queryType + u32 startIndex + u32 count.
|
||||
private const ushort QueryTagPacketMarker = 0x6752;
|
||||
private const ushort TagQueryHeaderVersion = 1;
|
||||
private const ushort QueryTagModeNames = 1; // queryType 1 returns tag-name rows
|
||||
private const uint BrowsePageSize = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Browses tag names over gRPC (roadmap item R0.1). Drives
|
||||
/// <c>StartTagQuery</c> (OData filter) → paged <c>QueryTag</c> → <c>EndTagQuery</c> on the
|
||||
/// RetrievalService. The 2023 R2 metadata-server parses the filter as <b>OData</b>, so the SDK's
|
||||
/// glob filter is translated via <see cref="GlobToODataFilter"/>. Each QueryTag page returns
|
||||
/// <c>uint count + per-name(uint charCount + UTF-16LE)</c>, decoded by
|
||||
/// <see cref="HistorianTagQueryProtocol.ParseGetLikeTagNamesResponse"/>.
|
||||
/// </summary>
|
||||
public static async IAsyncEnumerable<string> BrowseTagNamesAsync(
|
||||
HistorianClientOptions options,
|
||||
string filter,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
IReadOnlyList<string> names = await Task.Run(() => BrowseTagNames(options, filter, cancellationToken), cancellationToken).ConfigureAwait(false);
|
||||
foreach (string name in names)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
yield return name;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> BrowseTagNames(HistorianClientOptions options, string filter, CancellationToken cancellationToken)
|
||||
{
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
||||
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, cancellationToken);
|
||||
return BrowseTagNamesOnSession(connection, session, filter, options, cancellationToken);
|
||||
}
|
||||
|
||||
// Spike/Phase-1 seam (pending.md A1): drive StartTagQuery → paged QueryTag → EndTagQuery against an
|
||||
// EXTERNALLY-supplied, already-authenticated connection + session — i.e. NO Create()/handshake here.
|
||||
// BrowseTagNames delegates to this so the per-call path and the reuse path share one browse
|
||||
// implementation (DRY).
|
||||
internal static List<string> BrowseTagNamesOnSession(
|
||||
HistorianGrpcConnection connection,
|
||||
HistorianGrpcHandshake.Session session,
|
||||
string filter,
|
||||
HistorianClientOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel);
|
||||
DateTime Deadline() => DateTime.UtcNow.Add(options.RequestTimeout);
|
||||
|
||||
byte[] startRequest = HistorianTagQueryProtocol.CreateStartTagQueryAttempt(GlobToODataFilter(filter)).RequestBuffer;
|
||||
GrpcRetrieval.StartTagQueryResponse start = retrievalClient.StartTagQuery(
|
||||
new GrpcRetrieval.StartTagQueryRequest { StrHandle = session.StringHandle, BtRequest = ByteString.CopyFrom(startRequest) },
|
||||
connection.Metadata, Deadline(), cancellationToken);
|
||||
if (!(start.Status?.BSuccess ?? false))
|
||||
{
|
||||
byte[] error = start.Status?.BtError?.ToByteArray() ?? [];
|
||||
throw new InvalidOperationException($"gRPC StartTagQuery failed (errorLen={error.Length}).");
|
||||
}
|
||||
|
||||
HistorianTagQueryStartResponse parsed = HistorianTagQueryProtocol.ParseStartTagQueryResponse(start.BtResponse?.ToByteArray() ?? []);
|
||||
List<string> names = new(checked((int)parsed.TagCount));
|
||||
try
|
||||
{
|
||||
uint startIndex = 0;
|
||||
while (names.Count < parsed.TagCount)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
uint page = Math.Min(BrowsePageSize, parsed.TagCount - (uint)names.Count);
|
||||
GrpcRetrieval.QueryTagResponse query = retrievalClient.QueryTag(
|
||||
new GrpcRetrieval.QueryTagRequest
|
||||
{
|
||||
StrHandle = session.StringHandle,
|
||||
UiQueryHandle = parsed.QueryHandle,
|
||||
BtRequest = ByteString.CopyFrom(BuildQueryTagRequest(QueryTagModeNames, startIndex, page))
|
||||
},
|
||||
connection.Metadata, Deadline(), cancellationToken);
|
||||
if (!(query.Status?.BSuccess ?? false))
|
||||
{
|
||||
byte[] error = query.Status?.BtError?.ToByteArray() ?? [];
|
||||
throw new InvalidOperationException($"gRPC QueryTag failed (errorLen={error.Length}).");
|
||||
}
|
||||
|
||||
IReadOnlyList<string> pageNames = HistorianTagQueryProtocol.ParseTagNameQueryPage(query.BtResonse?.ToByteArray() ?? []);
|
||||
if (pageNames.Count == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
names.AddRange(pageNames);
|
||||
startIndex += (uint)pageNames.Count;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
retrievalClient.EndTagQuery(
|
||||
new GrpcRetrieval.EndTagQueryRequest { StrHandle = session.StringHandle, UiQueryHandle = parsed.QueryHandle },
|
||||
connection.Metadata, Deadline(), CancellationToken.None);
|
||||
}
|
||||
catch { /* best-effort cleanup */ }
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
/// <summary>Builds the QueryTag paging request: u16 marker(0x6752) + u16 version + u16 queryType + u32 startIndex + u32 count.</summary>
|
||||
internal static byte[] BuildQueryTagRequest(ushort queryType, uint startIndex, uint count)
|
||||
{
|
||||
using MemoryStream stream = new();
|
||||
using BinaryWriter writer = new(stream, Encoding.Unicode, leaveOpen: true);
|
||||
writer.Write(QueryTagPacketMarker);
|
||||
writer.Write(TagQueryHeaderVersion);
|
||||
writer.Write(queryType);
|
||||
writer.Write(startIndex);
|
||||
writer.Write(count);
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Translates the SDK's glob filter (<c>*</c> wildcard) into the OData filter the 2023 R2
|
||||
/// metadata-server's <c>StartActiveTagnamesQuery</c> expects. Single-quotes are OData-escaped.
|
||||
/// <list type="bullet">
|
||||
/// <item><c>*</c> / empty → no filter (all tags)</item>
|
||||
/// <item><c>Pre*</c> → <c>startswith(TagName,'Pre')</c></item>
|
||||
/// <item><c>*suf</c> → <c>endswith(TagName,'suf')</c></item>
|
||||
/// <item><c>*mid*</c> → <c>contains(TagName,'mid')</c></item>
|
||||
/// <item><c>a*b</c> → <c>startswith(TagName,'a') and endswith(TagName,'b')</c></item>
|
||||
/// <item><c>Exact</c> → <c>TagName eq 'Exact'</c></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
internal static string GlobToODataFilter(string filter)
|
||||
{
|
||||
if (string.IsNullOrEmpty(filter) || filter == "*")
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
static string Esc(string s) => s.Replace("'", "''");
|
||||
|
||||
bool starStart = filter.StartsWith('*');
|
||||
bool starEnd = filter.EndsWith('*');
|
||||
string core = filter.Trim('*');
|
||||
if (core.Length == 0)
|
||||
{
|
||||
return string.Empty; // "**" etc.
|
||||
}
|
||||
|
||||
if (filter.IndexOf('*') < 0)
|
||||
{
|
||||
return $"TagName eq '{Esc(filter)}'";
|
||||
}
|
||||
|
||||
if (starStart && starEnd && !core.Contains('*'))
|
||||
{
|
||||
return $"contains(TagName,'{Esc(core)}')";
|
||||
}
|
||||
|
||||
if (starEnd && !core.Contains('*') && !starStart)
|
||||
{
|
||||
return $"startswith(TagName,'{Esc(core)}')";
|
||||
}
|
||||
|
||||
if (starStart && !core.Contains('*') && !starEnd)
|
||||
{
|
||||
return $"endswith(TagName,'{Esc(core)}')";
|
||||
}
|
||||
|
||||
// Internal wildcard(s): anchor on the prefix before the first '*' and the suffix after the last.
|
||||
string prefix = filter[..filter.IndexOf('*')];
|
||||
string suffix = filter[(filter.LastIndexOf('*') + 1)..];
|
||||
List<string> parts = [];
|
||||
if (prefix.Length > 0)
|
||||
{
|
||||
parts.Add($"startswith(TagName,'{Esc(prefix)}')");
|
||||
}
|
||||
if (suffix.Length > 0)
|
||||
{
|
||||
parts.Add($"endswith(TagName,'{Esc(suffix)}')");
|
||||
}
|
||||
return parts.Count > 0 ? string.Join(" and ", parts) : string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Builds the native tag-names request buffer: uint count + per-name(uint charCount + UTF-16LE).</summary>
|
||||
internal static byte[] BuildTagNamesBuffer(IReadOnlyList<string> tags)
|
||||
{
|
||||
using MemoryStream stream = new();
|
||||
using BinaryWriter writer = new(stream, Encoding.Unicode, leaveOpen: true);
|
||||
|
||||
writer.Write((uint)tags.Count);
|
||||
foreach (string tag in tags)
|
||||
{
|
||||
writer.Write((uint)tag.Length);
|
||||
if (tag.Length > 0)
|
||||
{
|
||||
writer.Write(Encoding.Unicode.GetBytes(tag));
|
||||
}
|
||||
}
|
||||
|
||||
return stream.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
using Google.Protobuf;
|
||||
using AVEVA.Historian.Client.Models;
|
||||
using AVEVA.Historian.Client.Wcf;
|
||||
using GrpcHistory = ArchestrA.Grpc.Contract.History;
|
||||
using GrpcRetrieval = ArchestrA.Grpc.Contract.Retrieval;
|
||||
|
||||
namespace AVEVA.Historian.Client.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// Tag-configuration write ops over the 2023 R2 gRPC transport, mirroring
|
||||
/// <see cref="HistorianWcfTagWriteOrchestrator"/>. Each op opens a <b>write-enabled</b> Open2 session
|
||||
/// (<c>0x401</c>) and reuses the proven 2020 byte serializers verbatim inside the protobuf
|
||||
/// <c>bytes</c> fields:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="EnsureTagAsync"/> → <c>HistoryService.EnsureTags</c> (string handle,
|
||||
/// <c>btTagInfos</c> = <see cref="HistorianTagWriteProtocol.SerializeAnalogCTagMetadata"/>)</item>
|
||||
/// <item><see cref="DeleteTagAsync"/> → <c>HistoryService.DeleteTags</c> (uint handle,
|
||||
/// <c>btTagnames</c> = <see cref="HistorianTagWriteProtocol.SerializeDeleteTagNames"/>)</item>
|
||||
/// <item><see cref="RenameTagsAsync"/> → <c>HistoryService.StartJob</c> (string handle,
|
||||
/// <c>btInput</c> = <see cref="HistorianTagRenameProtocol.SerializeRenameJob"/>)</item>
|
||||
/// <item><see cref="AddTagExtendedPropertiesAsync"/> → <c>HistoryService.AddTagExtendedProperties</c>
|
||||
/// (string handle, <c>btTeps</c> = <see cref="HistorianTagExtendedPropertyProtocol.SerializeAddRequest"/>)</item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// <b>Tooled but not yet live-verified.</b> The request framing reuses the WCF serializers proven on
|
||||
/// the 2020 transport, and the read-side config ops confirm WCF config buffers ride the gRPC RPC
|
||||
/// unchanged — but these mutate server state (create/delete/rename tags, write properties), so they
|
||||
/// are gated behind a sandbox-tag in the integration tests and have not been run destructively against
|
||||
/// a shared live server. The WCF path additionally runs a priming "discovery dance" (UpdC3 + system
|
||||
/// parameters + cross-service GetV) before the write; the gRPC front door established the equivalent
|
||||
/// session state in the M3 non-streamed-write probe without it, so it is omitted here pending live
|
||||
/// confirmation. If a live run is rejected, that priming is the first thing to add.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class HistorianGrpcTagWriteOrchestrator
|
||||
{
|
||||
private const uint WriteEnabledConnectionMode = HistorianWcfAuthChainHelper.NativeIntegratedWriteEnabledConnectionMode;
|
||||
|
||||
private readonly HistorianClientOptions _options;
|
||||
|
||||
public HistorianGrpcTagWriteOrchestrator(HistorianClientOptions options)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
public Task<bool> EnsureTagAsync(HistorianTagDefinition definition, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(definition);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(definition.TagName, nameof(definition));
|
||||
// Surface unsupported (non-analog) types early, exactly as the WCF path does.
|
||||
_ = HistorianTagWriteProtocol.GetAnalogDataTypeCode(definition.DataType);
|
||||
return Task.Run(() => EnsureTag(definition, cancellationToken), cancellationToken);
|
||||
}
|
||||
|
||||
private bool EnsureTag(HistorianTagDefinition definition, CancellationToken cancellationToken)
|
||||
{
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
|
||||
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, _options, cancellationToken, WriteEnabledConnectionMode);
|
||||
return EnsureTagOnSession(connection, session, definition, cancellationToken);
|
||||
}
|
||||
|
||||
// Spike/Phase-1 seam (pending.md A1): run EnsureTags against an EXTERNALLY-supplied, already-
|
||||
// authenticated write-enabled (0x401) connection + session — NO Create()/handshake here. EnsureTag
|
||||
// delegates so the per-call path and the reuse path share one op implementation (DRY).
|
||||
internal bool EnsureTagOnSession(
|
||||
HistorianGrpcConnection connection,
|
||||
HistorianGrpcHandshake.Session session,
|
||||
HistorianTagDefinition definition,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
byte[] payload = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
|
||||
tagName: definition.TagName,
|
||||
description: definition.Description,
|
||||
engineeringUnit: definition.EngineeringUnit,
|
||||
dateCreatedUtc: DateTime.UtcNow,
|
||||
dataType: definition.DataType,
|
||||
minEU: definition.MinEU,
|
||||
maxEU: definition.MaxEU,
|
||||
minRaw: definition.MinRaw,
|
||||
maxRaw: definition.MaxRaw,
|
||||
storageRateMs: definition.StorageRateMs,
|
||||
applyScaling: definition.ApplyScaling,
|
||||
storageType: definition.StorageType,
|
||||
integralDivisor: definition.IntegralDivisor);
|
||||
|
||||
var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel);
|
||||
GrpcHistory.EnsureTagsResponse response = historyClient.EnsureTags(
|
||||
new GrpcHistory.EnsureTagsRequest
|
||||
{
|
||||
StrHandle = session.StringHandle,
|
||||
BtTagInfos = ByteString.CopyFrom(payload),
|
||||
ElementCount = 1
|
||||
},
|
||||
connection.Metadata,
|
||||
DateTime.UtcNow.Add(_options.RequestTimeout),
|
||||
cancellationToken);
|
||||
|
||||
return response.Status?.BSuccess ?? false;
|
||||
}
|
||||
|
||||
public Task<bool> DeleteTagAsync(string tagName, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tagName);
|
||||
return Task.Run(() => DeleteTag(tagName, cancellationToken), cancellationToken);
|
||||
}
|
||||
|
||||
private bool DeleteTag(string tagName, CancellationToken cancellationToken)
|
||||
{
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
|
||||
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, _options, cancellationToken, WriteEnabledConnectionMode);
|
||||
return DeleteTagOnSession(connection, session, tagName, cancellationToken);
|
||||
}
|
||||
|
||||
// Spike/Phase-1 seam (pending.md A1): run DeleteTags against an EXTERNALLY-supplied, already-
|
||||
// authenticated write-enabled (0x401) connection + session — NO Create()/handshake here. DeleteTag
|
||||
// delegates so the per-call path and the reuse path share one op implementation (DRY).
|
||||
internal bool DeleteTagOnSession(
|
||||
HistorianGrpcConnection connection,
|
||||
HistorianGrpcHandshake.Session session,
|
||||
string tagName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// DeleteTags takes the transient uint client handle (not the string handle), per the WCF wire capture.
|
||||
byte[] tagNames = HistorianTagWriteProtocol.SerializeDeleteTagNames([tagName]);
|
||||
var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel);
|
||||
GrpcHistory.DeleteTagsResponse response = historyClient.DeleteTags(
|
||||
new GrpcHistory.DeleteTagsRequest
|
||||
{
|
||||
UiHandle = session.ClientHandle,
|
||||
BtTagnames = ByteString.CopyFrom(tagNames)
|
||||
},
|
||||
connection.Metadata,
|
||||
DateTime.UtcNow.Add(_options.RequestTimeout),
|
||||
cancellationToken);
|
||||
|
||||
return response.Status?.BSuccess ?? false;
|
||||
}
|
||||
|
||||
public Task<bool> AddTagExtendedPropertiesAsync(
|
||||
string tagName, IReadOnlyList<HistorianTagExtendedProperty> properties, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tagName);
|
||||
ArgumentNullException.ThrowIfNull(properties);
|
||||
if (properties.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one extended property is required.", nameof(properties));
|
||||
}
|
||||
return Task.Run(() => AddTagExtendedProperties(tagName, properties, cancellationToken), cancellationToken);
|
||||
}
|
||||
|
||||
private bool AddTagExtendedProperties(
|
||||
string tagName, IReadOnlyList<HistorianTagExtendedProperty> properties, CancellationToken cancellationToken)
|
||||
{
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
|
||||
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, _options, cancellationToken, WriteEnabledConnectionMode);
|
||||
return AddTagExtendedPropertiesOnSession(connection, session, tagName, properties, cancellationToken);
|
||||
}
|
||||
|
||||
// Spike/Phase-1 seam (pending.md A1): run AddTagExtendedProperties against an EXTERNALLY-supplied,
|
||||
// already-authenticated write-enabled (0x401) connection + session — NO Create()/handshake here.
|
||||
// AddTagExtendedProperties delegates so the per-call path and the reuse path share one op
|
||||
// implementation (DRY).
|
||||
internal bool AddTagExtendedPropertiesOnSession(
|
||||
HistorianGrpcConnection connection,
|
||||
HistorianGrpcHandshake.Session session,
|
||||
string tagName,
|
||||
IReadOnlyList<HistorianTagExtendedProperty> properties,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
byte[] inBuff = HistorianTagExtendedPropertyProtocol.SerializeAddRequest(tagName, properties);
|
||||
var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel);
|
||||
GrpcHistory.AddTagExtendedPropertiesResponse response = historyClient.AddTagExtendedProperties(
|
||||
new GrpcHistory.AddTagExtendedPropertiesRequest
|
||||
{
|
||||
StrHandle = session.StringHandle,
|
||||
BtTeps = ByteString.CopyFrom(inBuff)
|
||||
},
|
||||
connection.Metadata,
|
||||
DateTime.UtcNow.Add(_options.RequestTimeout),
|
||||
cancellationToken);
|
||||
|
||||
return response.Status?.BSuccess ?? false;
|
||||
}
|
||||
|
||||
/// <summary>Outcome of the <see cref="ProbeDeleteTagExtendedPropertiesAsync"/> single-channel delete probe.</summary>
|
||||
/// <param name="Accepted">True if the server's <c>DelTep</c> returned success.</param>
|
||||
/// <param name="ErrorDescription">Decoded native error (byte0 0x84 + LE code + facility/file/message) when rejected.</param>
|
||||
/// <param name="TagInfoPrimeBytes">Bytes returned by the GetTgByNm prime (tag-identity working-set load).</param>
|
||||
/// <param name="ExtPropPrimePages">GetTepByNm prime pages that returned success (extended-property working-set load).</param>
|
||||
internal readonly record struct DeleteTagExtendedPropertiesProbeResult(
|
||||
bool Accepted, string? ErrorDescription, int TagInfoPrimeBytes, int ExtPropPrimePages);
|
||||
|
||||
/// <summary>
|
||||
/// <b>Reverse-engineering probe (not a public op).</b> Tests whether <c>DelTep</c>
|
||||
/// (DeleteTagExtendedProperties) — server-blocked on the 2020 WCF transport — succeeds over gRPC.
|
||||
/// The WCF failure is structural: the server's <c>CHistStorage::DeleteTagExtendedProperties</c>
|
||||
/// resolves each property from a <i>per-connection working set</i> the native client populates by
|
||||
/// multiplexing <c>GetTgByNm</c> + <c>GetTepByNm</c> + <c>DelTep</c> over ONE physical connection.
|
||||
/// The WCF SDK uses a separate channel per service, so the prime and the delete never share a
|
||||
/// connection and the working set is empty at delete time (SErrorException). Over gRPC every service
|
||||
/// client is built on the SAME <see cref="HistorianGrpcConnection.Channel"/>, so this probe runs the
|
||||
/// identical native sequence — GetTgByNm prime, GetTepByNm prime, then DelTep — on one write-enabled
|
||||
/// (0x401) session/channel, to see whether the multiplexed channel satisfies the working-set check.
|
||||
/// Returns the decoded outcome rather than throwing so the caller can record a positive or negative
|
||||
/// result. See docs/reverse-engineering/wcf-add-tag-extended-properties.md §Delete.
|
||||
/// </summary>
|
||||
internal Task<DeleteTagExtendedPropertiesProbeResult> ProbeDeleteTagExtendedPropertiesAsync(
|
||||
string tagName, IReadOnlyList<string> propertyNames, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tagName);
|
||||
ArgumentNullException.ThrowIfNull(propertyNames);
|
||||
if (propertyNames.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one property name is required.", nameof(propertyNames));
|
||||
}
|
||||
return Task.Run(() => ProbeDeleteTagExtendedProperties(tagName, propertyNames, cancellationToken), cancellationToken);
|
||||
}
|
||||
|
||||
private DeleteTagExtendedPropertiesProbeResult ProbeDeleteTagExtendedProperties(
|
||||
string tagName, IReadOnlyList<string> propertyNames, CancellationToken cancellationToken)
|
||||
{
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
|
||||
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, _options, cancellationToken, WriteEnabledConnectionMode);
|
||||
|
||||
var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel);
|
||||
var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel);
|
||||
DateTime Deadline() => DateTime.UtcNow.Add(_options.RequestTimeout);
|
||||
|
||||
// Prime 1 — GetTgByNm: load the tag identity into the storage session's working set (same channel).
|
||||
int tagInfoPrimeBytes = 0;
|
||||
try
|
||||
{
|
||||
GrpcRetrieval.GetTagInfosFromNameResponse tg = retrievalClient.GetTagInfosFromName(
|
||||
new GrpcRetrieval.GetTagInfosFromNameRequest
|
||||
{
|
||||
StrHandle = session.StringHandle,
|
||||
BtTagNames = ByteString.CopyFrom(HistorianGrpcTagClient.BuildTagNamesBuffer([tagName])),
|
||||
UiSequence = 0
|
||||
},
|
||||
connection.Metadata, Deadline(), cancellationToken);
|
||||
tagInfoPrimeBytes = tg.BtTagInfos?.Length ?? 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort prime; the delete still runs so the error buffer is captured.
|
||||
}
|
||||
|
||||
// Prime 2 — GetTepByNm: load the tag's extended properties into the working set (same channel),
|
||||
// exactly as the native register->read->delete sequence does on its single connection.
|
||||
int extPropPrimePages = 0;
|
||||
byte[] tepRequest = HistorianTagExtendedPropertyProtocol.SerializeRequest(tagName);
|
||||
uint sequence = 0;
|
||||
for (int page = 0; page < 64; page++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
GrpcRetrieval.GetTagExtendedPropertiesFromNameResponse tep = retrievalClient.GetTagExtendedPropertiesFromName(
|
||||
new GrpcRetrieval.GetTagExtendedPropertiesFromNameRequest
|
||||
{
|
||||
StrHandle = session.StringHandle,
|
||||
BtTagNames = ByteString.CopyFrom(tepRequest),
|
||||
UiSequence = sequence
|
||||
},
|
||||
connection.Metadata, Deadline(), cancellationToken);
|
||||
if (!(tep.Status?.BSuccess ?? false))
|
||||
{
|
||||
break;
|
||||
}
|
||||
extPropPrimePages++;
|
||||
if (HistorianTagExtendedPropertyProtocol.ParseResponse(tep.BtTeps?.ToByteArray() ?? []).Count == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
sequence = tep.UiSequence;
|
||||
}
|
||||
|
||||
// DelTep on the SAME channel/session, while the priming reads are part of the same working set.
|
||||
byte[] inBuff = HistorianTagExtendedPropertyProtocol.SerializeDeleteRequest(tagName, propertyNames);
|
||||
GrpcHistory.DeleteTagExtendedPropertiesResponse delete = historyClient.DeleteTagExtendedProperties(
|
||||
new GrpcHistory.DeleteTagExtendedPropertiesRequest
|
||||
{
|
||||
StrHandle = session.StringHandle,
|
||||
BtInput = ByteString.CopyFrom(inBuff)
|
||||
},
|
||||
connection.Metadata, Deadline(), cancellationToken);
|
||||
|
||||
bool accepted = delete.Status?.BSuccess ?? false;
|
||||
string? errorDescription = accepted
|
||||
? null
|
||||
: HistorianEventRegistrationProtocol.DescribeNativeError(delete.Status?.BtError?.ToByteArray() ?? []);
|
||||
return new DeleteTagExtendedPropertiesProbeResult(accepted, errorDescription, tagInfoPrimeBytes, extPropPrimePages);
|
||||
}
|
||||
|
||||
public Task<HistorianTagRenameResult> RenameTagsAsync(
|
||||
IReadOnlyList<(string OldName, string NewName)> pairs, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(pairs);
|
||||
if (pairs.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one (old,new) name pair is required.", nameof(pairs));
|
||||
}
|
||||
foreach ((string oldName, string newName) in pairs)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(oldName, nameof(pairs));
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(newName, nameof(pairs));
|
||||
}
|
||||
return Task.Run(() => RenameTags(pairs, cancellationToken), cancellationToken);
|
||||
}
|
||||
|
||||
private HistorianTagRenameResult RenameTags(IReadOnlyList<(string OldName, string NewName)> pairs, CancellationToken cancellationToken)
|
||||
{
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
|
||||
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, _options, cancellationToken, WriteEnabledConnectionMode);
|
||||
return RenameTagsOnSession(connection, session, pairs, cancellationToken);
|
||||
}
|
||||
|
||||
// Spike/Phase-1 seam (pending.md A1): run StartJob (rename) against an EXTERNALLY-supplied, already-
|
||||
// authenticated write-enabled (0x401) connection + session — NO Create()/handshake here. RenameTags
|
||||
// delegates so the per-call path and the reuse path share one op implementation (DRY).
|
||||
internal HistorianTagRenameResult RenameTagsOnSession(
|
||||
HistorianGrpcConnection connection,
|
||||
HistorianGrpcHandshake.Session session,
|
||||
IReadOnlyList<(string OldName, string NewName)> pairs,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
byte[] jobBuffer = HistorianTagRenameProtocol.SerializeRenameJob(pairs);
|
||||
var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel);
|
||||
GrpcHistory.StartJobResponse response = historyClient.StartJob(
|
||||
new GrpcHistory.StartJobRequest
|
||||
{
|
||||
StrHandle = session.StringHandle,
|
||||
BtInput = ByteString.CopyFrom(jobBuffer)
|
||||
},
|
||||
connection.Metadata,
|
||||
DateTime.UtcNow.Add(_options.RequestTimeout),
|
||||
cancellationToken);
|
||||
|
||||
bool ok = response.Status?.BSuccess ?? false;
|
||||
Guid parsedJobId = Guid.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(response.StrJobid))
|
||||
{
|
||||
Guid.TryParse(response.StrJobid.Trim().Trim('$', '{', '}'), out parsedJobId);
|
||||
}
|
||||
|
||||
return new HistorianTagRenameResult
|
||||
{
|
||||
Accepted = ok,
|
||||
JobId = parsedJobId,
|
||||
PairCount = pairs.Count,
|
||||
Error = ok ? null : "Server rejected the rename job (StartJob returned false). Check that the 'AllowRenameTags' system parameter is enabled.",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,9 @@ public sealed class HistorianClient : IAsyncDisposable
|
||||
|
||||
public async Task<bool> ProbeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await HistorianWcfProbe.ProbeAsync(_options, cancellationToken).ConfigureAwait(false);
|
||||
return _options.Transport == HistorianTransport.RemoteGrpc
|
||||
? await Grpc.HistorianGrpcProbe.ProbeAsync(_options, cancellationToken).ConfigureAwait(false)
|
||||
: await HistorianWcfProbe.ProbeAsync(_options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<HistorianSample> ReadRawAsync(
|
||||
@@ -90,19 +92,90 @@ public sealed class HistorianClient : IAsyncDisposable
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ValidateTimeRange(startUtc, endUtc);
|
||||
return _protocol.ReadEventsAsync(startUtc, endUtc, cancellationToken);
|
||||
return _protocol.ReadEventsAsync(startUtc, endUtc, filter: null, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads events in the time window, server-filtered by a single predicate
|
||||
/// (<paramref name="filter"/>) — e.g. <c>Type Equal "User.Write"</c> or
|
||||
/// <c>Area Contains "Tank"</c>. The historian applies the filter and returns only matching
|
||||
/// events. Filtering is a real server-side operation (live-verified: a non-matching predicate
|
||||
/// returns zero events). Single string-valued predicates only; see <see cref="HistorianEventFilter"/>.
|
||||
/// </summary>
|
||||
public IAsyncEnumerable<HistorianEvent> ReadEventsAsync(
|
||||
DateTime startUtc,
|
||||
DateTime endUtc,
|
||||
HistorianEventFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(filter);
|
||||
ValidateTimeRange(startUtc, endUtc);
|
||||
return _protocol.ReadEventsAsync(startUtc, endUtc, filter, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a single <see cref="HistorianEvent"/> to the Historian's built-in CM_EVENT tag.
|
||||
/// Over WCF this runs Open2 event mode → CM_EVENT registration → AddS2; over the 2023 R2
|
||||
/// <see cref="HistorianTransport.RemoteGrpc"/> transport it runs the captured-equivalent
|
||||
/// v8 Event OpenConnection → CM_EVENT registration → <c>HistoryService.AddStreamValues</c>
|
||||
/// with the same "OS" event buffer (live-captured 2026-06-23 — the send rides the same RPC
|
||||
/// and buffer as the WCF path, not a distinct event RPC). The event is appended to the
|
||||
/// historian's event history and is readable back via <see cref="ReadEventsAsync"/> /
|
||||
/// the <c>v_AlarmEventHistory2</c> view. Only original events
|
||||
/// (<see cref="HistorianEvent.RevisionVersion"/> = 0) with string-valued properties are
|
||||
/// supported; other property value types and revision/update/delete events throw
|
||||
/// <see cref="ProtocolEvidenceMissingException"/> until their wire encoding is captured.
|
||||
/// </summary>
|
||||
public Task<bool> SendEventAsync(HistorianEvent historianEvent, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(historianEvent);
|
||||
return _options.Transport == HistorianTransport.RemoteGrpc
|
||||
? new Grpc.HistorianGrpcEventWriteOrchestrator(_options).SendEventAsync(historianEvent, cancellationToken)
|
||||
: new HistorianWcfEventOrchestrator(_options).SendEventAsync(historianEvent, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inserts historical (non-streamed original / backfill) values for an existing tag. Captured
|
||||
/// live from the native 2023 R2 client: the write rides <c>HistoryService.AddStreamValues</c>
|
||||
/// (an "ON" storage-sample buffer) over the gRPC front door — see
|
||||
/// <c>docs/plans/revision-write-path.md</c> §"R3.1 CAPTURED". Only the
|
||||
/// <see cref="HistorianTransport.RemoteGrpc"/> transport is supported (the 2020 WCF path is
|
||||
/// architecturally blocked — D2); other transports throw
|
||||
/// <see cref="ProtocolEvidenceMissingException"/>. The tag must already exist
|
||||
/// (create it with <see cref="EnsureTagAsync"/>). Value encoding is captured for Float tags.
|
||||
/// </summary>
|
||||
public Task<bool> AddHistoricalValuesAsync(
|
||||
string tag,
|
||||
IReadOnlyList<HistorianHistoricalValue> values,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
|
||||
ArgumentNullException.ThrowIfNull(values);
|
||||
|
||||
if (_options.Transport != HistorianTransport.RemoteGrpc)
|
||||
{
|
||||
throw new ProtocolEvidenceMissingException(
|
||||
"AddHistoricalValuesAsync is only supported over the 2023 R2 RemoteGrpc transport; the 2020 WCF " +
|
||||
"non-streamed write is architecturally blocked (see docs/plans/revision-write-path.md, D2).");
|
||||
}
|
||||
|
||||
return new Grpc.HistorianGrpcHistoricalWriteOrchestrator(_options).AddHistoricalValuesAsync(tag, values, cancellationToken);
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<string> BrowseTagNamesAsync(string filter = "*", CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(filter);
|
||||
return HistorianWcfTagClient.BrowseTagNamesAsync(_options, filter, cancellationToken);
|
||||
return _options.Transport == HistorianTransport.RemoteGrpc
|
||||
? Grpc.HistorianGrpcTagClient.BrowseTagNamesAsync(_options, filter, cancellationToken)
|
||||
: HistorianWcfTagClient.BrowseTagNamesAsync(_options, filter, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<HistorianTagMetadata?> GetTagMetadataAsync(string tag, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
|
||||
return HistorianWcfTagClient.GetTagMetadataAsync(_options, tag, cancellationToken);
|
||||
return _options.Transport == HistorianTransport.RemoteGrpc
|
||||
? Grpc.HistorianGrpcTagClient.GetTagMetadataAsync(_options, tag, cancellationToken)
|
||||
: HistorianWcfTagClient.GetTagMetadataAsync(_options, tag, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<HistorianConnectionStatus> GetConnectionStatusAsync(CancellationToken cancellationToken = default)
|
||||
@@ -121,6 +194,99 @@ public sealed class HistorianClient : IAsyncDisposable
|
||||
return _protocol.GetSystemParameterAsync(name, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the Historian server's system time-zone name (e.g. "Eastern Daylight Time").
|
||||
/// <para>
|
||||
/// Only the 2023 R2 <see cref="HistorianTransport.RemoteGrpc"/> front door exposes a real value;
|
||||
/// the 2020 WCF <c>GetSystemTimeZoneName</c> is a client-side stub, so this throws
|
||||
/// <see cref="ProtocolEvidenceMissingException"/> on the non-gRPC transports. Returns null when a
|
||||
/// gRPC server reports no value.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public Task<string?> GetServerTimeZoneAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _protocol.GetServerTimeZoneAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a named Historian <em>runtime</em> parameter (the live server state surface,
|
||||
/// distinct from the configuration <see cref="GetSystemParameterAsync"/>). Returns the
|
||||
/// string value, or null when the server reports no value. Single string-valued parameters
|
||||
/// only (the evidence-backed surface); see <c>HistorianRuntimeParameterProtocol</c>.
|
||||
/// </summary>
|
||||
public Task<string?> GetRuntimeParameterAsync(string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
return _protocol.GetRuntimeParameterAsync(name, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the extended (user-defined) properties attached to a tag via the 2020 WCF
|
||||
/// <c>GetTepByNm</c> op. Returns the property name/value pairs (empty when the tag has none).
|
||||
/// String-valued properties only (the evidence-backed surface); other value variants throw
|
||||
/// <see cref="ProtocolEvidenceMissingException"/>. See
|
||||
/// <c>HistorianTagExtendedPropertyProtocol</c>.
|
||||
/// </summary>
|
||||
public Task<IReadOnlyList<HistorianTagExtendedProperty>> GetTagExtendedPropertiesAsync(string tag, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
|
||||
return _protocol.GetTagExtendedPropertiesAsync(tag, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds (or updates) extended (user-defined) properties on an existing tag via the 2020 WCF
|
||||
/// <c>AddTEx</c> (AddTagExtendedProperties) op. Requires a write-enabled connection. String-valued
|
||||
/// properties only (the evidence-backed surface). The new properties are read back via
|
||||
/// <see cref="GetTagExtendedPropertiesAsync"/>. See <c>HistorianTagExtendedPropertyProtocol</c>.
|
||||
/// </summary>
|
||||
public Task<bool> AddTagExtendedPropertiesAsync(string tag, IReadOnlyList<HistorianTagExtendedProperty> properties, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
|
||||
ArgumentNullException.ThrowIfNull(properties);
|
||||
return _options.Transport == HistorianTransport.RemoteGrpc
|
||||
? new Grpc.HistorianGrpcTagWriteOrchestrator(_options).AddTagExtendedPropertiesAsync(tag, properties, cancellationToken)
|
||||
: new HistorianWcfTagWriteOrchestrator(_options).AddTagExtendedPropertiesAsync(tag, properties, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Convenience overload of <see cref="AddTagExtendedPropertiesAsync"/> for a single
|
||||
/// string-valued property.</summary>
|
||||
public Task<bool> AddTagExtendedPropertyAsync(string tag, string name, string value, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
return AddTagExtendedPropertiesAsync(tag, [new HistorianTagExtendedProperty(name, value ?? string.Empty)], cancellationToken);
|
||||
}
|
||||
|
||||
// Extended-property DELETE (DelTep) is intentionally NOT exposed publicly. Its wire format is
|
||||
// captured and the serializer (HistorianTagExtendedPropertyProtocol.SerializeDeleteRequest) is
|
||||
// golden-verified against a server-accepted buffer, but the SDK cannot yet make the 2020 server
|
||||
// accept the delete: the server's CHistStorage::DeleteTagExtendedProperties consults a
|
||||
// per-connection working set that the native client populates by multiplexing GetTepByNm and
|
||||
// DelTep over ONE connection, which the SDK's per-service WCF channels don't reproduce. The gRPC
|
||||
// transport — where every service client shares ONE channel — was probed 2026-06-22 to test that
|
||||
// multiplexing hypothesis (GetTgByNm + GetTepByNm prime then DelTep on one write-enabled session,
|
||||
// HistorianGrpcTagWriteOrchestrator.ProbeDeleteTagExtendedPropertiesAsync): both primes succeed on
|
||||
// the shared channel yet the server STILL rejects the delete (native code=1), so gRPC does not lift
|
||||
// the wall either. The working set is evidently populated by the native client's in-process
|
||||
// registration state, not the wire session. See the documented-but-blocked path in
|
||||
// HistorianWcfTagWriteOrchestrator and docs/reverse-engineering/wcf-add-tag-extended-properties.md §Delete.
|
||||
|
||||
/// <summary>
|
||||
/// Executes a SQL command against the Historian over the WCF <c>ExeC</c>/<c>GetR</c> ops and
|
||||
/// returns the record set as a <see cref="HistorianSqlResult"/> (the managed equivalent of the
|
||||
/// native <c>DataTable</c>). The record-set path (<see cref="HistorianSqlExecuteOption.ExecuteRecord"/>,
|
||||
/// the default) is the evidence-backed surface; the result is decoded from the server's
|
||||
/// NRBF-serialized DataTable without BinaryFormatter. See <c>HistorianSqlResultProtocol</c>.
|
||||
/// </summary>
|
||||
public Task<HistorianSqlResult> ExecuteSqlCommandAsync(
|
||||
string command,
|
||||
HistorianSqlExecuteOption option = HistorianSqlExecuteOption.ExecuteRecord,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(command);
|
||||
return _protocol.ExecuteSqlCommandAsync(command, option, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates or updates the named tag in the Historian Runtime database via
|
||||
/// <c>EnsureTags2</c>. Currently only <see cref="HistorianDataType.Float"/> is
|
||||
@@ -132,7 +298,9 @@ public sealed class HistorianClient : IAsyncDisposable
|
||||
public Task<bool> EnsureTagAsync(HistorianTagDefinition definition, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(definition);
|
||||
return new HistorianWcfTagWriteOrchestrator(_options).EnsureTagAsync(definition, cancellationToken);
|
||||
return _options.Transport == HistorianTransport.RemoteGrpc
|
||||
? new Grpc.HistorianGrpcTagWriteOrchestrator(_options).EnsureTagAsync(definition, cancellationToken)
|
||||
: new HistorianWcfTagWriteOrchestrator(_options).EnsureTagAsync(definition, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -146,7 +314,104 @@ public sealed class HistorianClient : IAsyncDisposable
|
||||
public Task<bool> DeleteTagAsync(string tagName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tagName);
|
||||
return new HistorianWcfTagWriteOrchestrator(_options).DeleteTagAsync(tagName, cancellationToken);
|
||||
return _options.Transport == HistorianTransport.RemoteGrpc
|
||||
? new Grpc.HistorianGrpcTagWriteOrchestrator(_options).DeleteTagAsync(tagName, cancellationToken)
|
||||
: new HistorianWcfTagWriteOrchestrator(_options).DeleteTagAsync(tagName, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renames one tag, submitting an asynchronous rename job via the History <c>StartJob</c> (StJb)
|
||||
/// operation. Convenience wrapper over <see cref="RenameTagsAsync"/> for a single (old,new) pair.
|
||||
/// Requires the server's <c>AllowRenameTags</c> system parameter to be enabled.
|
||||
/// </summary>
|
||||
public Task<HistorianTagRenameResult> RenameTagAsync(string oldName, string newName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(oldName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(newName);
|
||||
return RenameTagsAsync([(oldName, newName)], cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renames a batch of tags. Each pair is (current name, new name). Rename is an asynchronous
|
||||
/// server-side job: the batch is submitted via the History <c>StartJob</c> (StJb) operation and
|
||||
/// the returned <see cref="HistorianTagRenameResult"/> reports whether the server accepted/queued
|
||||
/// the job (and its job id); the renames apply in the background. The server's
|
||||
/// <c>AllowRenameTags</c> system parameter must be enabled or the server rejects the job. See
|
||||
/// <c>docs/reverse-engineering/wcf-rename-tags.md</c>.
|
||||
/// </summary>
|
||||
public Task<HistorianTagRenameResult> RenameTagsAsync(IReadOnlyList<(string OldName, string NewName)> pairs, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(pairs);
|
||||
return _options.Transport == HistorianTransport.RemoteGrpc
|
||||
? new Grpc.HistorianGrpcTagWriteOrchestrator(_options).RenameTagsAsync(pairs, cancellationToken)
|
||||
: new HistorianWcfTagWriteOrchestrator(_options).RenameTagsAsync(pairs, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a reusable authenticated <see cref="HistorianSession"/> over the 2023 R2 gRPC transport.
|
||||
/// The caller owns the session and must dispose it. Reusing the session across ops amortizes the auth
|
||||
/// handshake; the server idle-expires it in ~20-25s, so keep it warm (HistorianSession.PingAsync) or
|
||||
/// re-open. RemoteGrpc only.
|
||||
/// </summary>
|
||||
public async Task<HistorianSession> OpenSessionAsync(HistorianSessionKind kind, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_options.Transport != HistorianTransport.RemoteGrpc)
|
||||
{
|
||||
throw new ProtocolEvidenceMissingException(
|
||||
"HistorianSession is only supported over the 2023 R2 RemoteGrpc transport.");
|
||||
}
|
||||
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
uint mode = kind == HistorianSessionKind.WriteEnabled
|
||||
? HistorianWcfAuthChainHelper.NativeIntegratedWriteEnabledConnectionMode
|
||||
: HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode;
|
||||
|
||||
Grpc.HistorianGrpcConnection connection = Grpc.HistorianGrpcChannelFactory.Create(_options);
|
||||
try
|
||||
{
|
||||
Grpc.HistorianGrpcHandshake.Session session =
|
||||
Grpc.HistorianGrpcHandshake.OpenSession(connection, _options, cancellationToken, connectionMode: mode);
|
||||
return new HistorianSession(connection, session, _options, kind);
|
||||
}
|
||||
catch
|
||||
{
|
||||
connection.Dispose(); // don't leak the channel if the handshake fails
|
||||
throw;
|
||||
}
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a reusable v8 EVENT session (ECDH + RegisterCmEventTag ONCE) over the 2023 R2 gRPC
|
||||
/// transport. The caller owns the session and must dispose it. Reusing the session across sends
|
||||
/// amortizes the ECDH+register cost (~10-16×, spike-proven); the server idle-expires it in ~25s,
|
||||
/// so keep it warm (HistorianEventSession.PingAsync) or re-open. For SendEvent amortization only —
|
||||
/// event reads are gated (C2) and not exposed here. RemoteGrpc only.
|
||||
/// </summary>
|
||||
public async Task<HistorianEventSession> OpenEventSessionAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_options.Transport != HistorianTransport.RemoteGrpc)
|
||||
{
|
||||
throw new ProtocolEvidenceMissingException(
|
||||
"HistorianEventSession is only supported over the 2023 R2 RemoteGrpc transport.");
|
||||
}
|
||||
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
Grpc.HistorianGrpcConnection connection = Grpc.HistorianGrpcChannelFactory.Create(_options);
|
||||
try
|
||||
{
|
||||
var orch = new Grpc.HistorianGrpcEventWriteOrchestrator(_options);
|
||||
Grpc.HistorianGrpcHandshake.Session session = orch.OpenAndRegisterEventSession(connection, cancellationToken);
|
||||
return new HistorianEventSession(connection, session, _options);
|
||||
}
|
||||
catch
|
||||
{
|
||||
connection.Dispose(); // don't leak the channel if the handshake fails
|
||||
throw;
|
||||
}
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
|
||||
@@ -53,6 +53,25 @@ public sealed class HistorianClientOptions
|
||||
/// </summary>
|
||||
public string? ServerDnsIdentity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional WCF "Via" address (e.g. <c>net.tcp://host:42568</c>). When set, the SDK's WCF
|
||||
/// channel factories <b>connect</b> to this address while still addressing the SOAP message
|
||||
/// <c>To</c> the logical endpoint built from <see cref="Host"/>/<see cref="Port"/>. Use this when
|
||||
/// the Historian is reached through a port-forwarding tunnel or proxy whose local port differs
|
||||
/// from the server's real service port: point <see cref="Host"/>/<see cref="Port"/> at the
|
||||
/// server's real endpoint (so the server's WCF AddressFilter matches) and set this to the tunnel
|
||||
/// endpoint. Has no effect on the gRPC transport. Default null (connect == address).
|
||||
/// </summary>
|
||||
public string? ConnectViaAddress { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic override for the native OpenConnection mode the WCF event-read chain uses (default
|
||||
/// <c>0x402</c>, read-only process). Set to e.g. <c>0x501</c> (event) or <c>0x401</c> (write-enabled)
|
||||
/// to probe whether CM_EVENT registration / event-row retrieval needs a different connection type on a
|
||||
/// 2023 R2 server. Null = the default read-only process mode. Intended for protocol investigation.
|
||||
/// </summary>
|
||||
public uint? EventReadConnectionModeOverride { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// For <see cref="HistorianTransport.RemoteGrpc"/>: when true the channel uses TLS
|
||||
/// (<c>https://</c>); when false it uses plaintext (<c>http://</c>). Matches the stock
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
using AVEVA.Historian.Client.Grpc;
|
||||
using AVEVA.Historian.Client.Models;
|
||||
|
||||
namespace AVEVA.Historian.Client;
|
||||
|
||||
/// <summary>A live, reusable authenticated v8 EVENT session: holds one event gRPC connection + one
|
||||
/// open+registered Event handle and runs SendEvent on it WITHOUT re-handshaking. Reuse amortizes the
|
||||
/// ECDH+register cost (~10-16×, spike-proven). SendEvent only — event READS are gated (C2) and stay
|
||||
/// per-call. Keep in sync with <see cref="HistorianSession"/> (the v6 sibling).</summary>
|
||||
public sealed class HistorianEventSession : IAsyncDisposable
|
||||
{
|
||||
private readonly HistorianGrpcConnection _connection;
|
||||
private readonly HistorianGrpcHandshake.Session _session;
|
||||
private readonly HistorianClientOptions _options;
|
||||
private int _disposed;
|
||||
|
||||
internal HistorianEventSession(
|
||||
HistorianGrpcConnection connection, HistorianGrpcHandshake.Session session, HistorianClientOptions options)
|
||||
{
|
||||
_connection = connection;
|
||||
_session = session;
|
||||
_options = options;
|
||||
}
|
||||
|
||||
/// <summary>Exposes the held event gRPC connection for internal callers (e.g. the round-trip test
|
||||
/// verifying the keepalive op directly). Not part of the public surface.</summary>
|
||||
internal HistorianGrpcConnection Connection => _connection;
|
||||
|
||||
/// <summary>Exposes the held open+registered Event session handle for internal callers (e.g. the
|
||||
/// round-trip test verifying the keepalive op directly). Not part of the public surface.</summary>
|
||||
internal HistorianGrpcHandshake.Session Session => _session;
|
||||
|
||||
/// <summary>Sends one event on the held (open+registered) v8 Event session.</summary>
|
||||
public Task<bool> SendEventAsync(HistorianEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(evt);
|
||||
if (evt.RevisionVersion != 0)
|
||||
{
|
||||
throw new ProtocolEvidenceMissingException(
|
||||
"Only original events (RevisionVersion = 0) have a captured send encoding; " +
|
||||
"revision/update/delete event sends are not yet supported.");
|
||||
}
|
||||
|
||||
var orch = new HistorianGrpcEventWriteOrchestrator(_options);
|
||||
return Task.Run(() => orch.SendEventOnSession(_connection, _session, evt, ct), ct);
|
||||
}
|
||||
|
||||
/// <summary>Keepalive via a lightweight <c>GetSystemParameter</c> status read on the event session's
|
||||
/// <see cref="HistorianGrpcHandshake.Session.ClientHandle"/> (the same status op the native pre-query
|
||||
/// sequence issues against an authenticated Event session), under the server idle floor. Mirrors
|
||||
/// <see cref="HistorianSession.PingAsync"/>. The op's effectiveness on a v8 Event handle is
|
||||
/// live-verified by the round-trip test.</summary>
|
||||
public Task PingAsync(CancellationToken ct = default)
|
||||
=> Task.Run(() => HistorianGrpcStatusClient.GetSystemParameterOnSession(
|
||||
_connection, _session.ClientHandle, _options, "HistorianVersion", ct), ct);
|
||||
|
||||
/// <summary>Disposes the underlying event connection (idempotent).</summary>
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) == 0)
|
||||
{
|
||||
_connection.Dispose();
|
||||
}
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -29,13 +29,15 @@ internal enum HistorianServiceInterface
|
||||
/// <item>Retrieval (<c>Retr</c>) interface version = 4</item>
|
||||
/// <item>Transaction (<c>Trx</c>) interface version = 2</item>
|
||||
/// </list>
|
||||
/// 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.
|
||||
/// The Status (<c>Stat</c>) service's <c>GetInterfaceVersion</c> is not a real version (0 on
|
||||
/// 2020 WCF, 4 on 2023 R2 gRPC) — it carries no meaning for the byte serializers either way — 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 <see cref="HistorianClientOptions.VerifyServerInterfaceVersion"/> set to
|
||||
/// <see langword="false"/>.
|
||||
/// 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
|
||||
/// <see cref="AcceptedVersions"/>), so a v12 server passes with the default
|
||||
/// <see cref="HistorianClientOptions.VerifyServerInterfaceVersion"/>=<see langword="true"/>;
|
||||
/// the opt-out is only a safety valve for some future, not-yet-captured interface integer.
|
||||
/// </summary>
|
||||
internal static class HistorianServerVersionGate
|
||||
{
|
||||
@@ -43,9 +45,23 @@ internal static class HistorianServerVersionGate
|
||||
public const uint RetrievalInterfaceVersion = 4;
|
||||
public const uint TransactionInterfaceVersion = 2;
|
||||
|
||||
/// <summary>
|
||||
/// The 2023 R2 gRPC HistoryService reports interface version 12. It is buffer-compatible with
|
||||
/// the 2020 version 11 — the OpenConnection3 v6 / token / DataQueryRequest / row buffers are
|
||||
/// byte-identical — confirmed by a live end-to-end gRPC read against a real 2023 R2 server
|
||||
/// (2026-06-21). So both 11 and 12 are accepted for History.
|
||||
///
|
||||
/// Retrieval=4, Transaction=2, and Status UiError=0 are now confirmed captured live over the
|
||||
/// 2023 R2 gRPC transport (2026-06-25, unauthenticated GetInterfaceVersion RPCs); see
|
||||
/// <c>docs/reverse-engineering/grpc-interface-versions.md</c>. All captured values were already
|
||||
/// accepted — no widening of <see cref="AcceptedVersions"/> was required.
|
||||
/// </summary>
|
||||
public const uint HistoryInterfaceVersionGrpc2023R2 = 12;
|
||||
|
||||
/// <summary>
|
||||
/// True when the service interface reports a meaningful version that should be matched.
|
||||
/// Status is reachability-only (its <c>GetInterfaceVersion</c> returns 0).
|
||||
/// Status is reachability-only (its <c>GetInterfaceVersion</c> is not a real version —
|
||||
/// 0 on 2020 WCF, 4 on 2023 R2 gRPC).
|
||||
/// </summary>
|
||||
public static bool IsValueGated(HistorianServiceInterface service) => service switch
|
||||
{
|
||||
@@ -56,7 +72,7 @@ internal static class HistorianServerVersionGate
|
||||
_ => false
|
||||
};
|
||||
|
||||
/// <summary>The interface version this SDK's serializers target for a value-gated service.</summary>
|
||||
/// <summary>The canonical interface version this SDK's serializers target for a value-gated service.</summary>
|
||||
public static uint ExpectedVersion(HistorianServiceInterface service) => service switch
|
||||
{
|
||||
HistorianServiceInterface.History => HistoryInterfaceVersion,
|
||||
@@ -65,6 +81,18 @@ internal static class HistorianServerVersionGate
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(service), service, "Service interface is not value-gated.")
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// All interface versions accepted for a value-gated service. Usually a single value, but
|
||||
/// History accepts both the 2020 value (11) and the buffer-compatible 2023 R2 gRPC value (12).
|
||||
/// </summary>
|
||||
public static uint[] AcceptedVersions(HistorianServiceInterface service) => service switch
|
||||
{
|
||||
HistorianServiceInterface.History => [HistoryInterfaceVersion, HistoryInterfaceVersionGrpc2023R2],
|
||||
HistorianServiceInterface.Retrieval => [RetrievalInterfaceVersion],
|
||||
HistorianServiceInterface.Transaction => [TransactionInterfaceVersion],
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(service), service, "Service interface is not value-gated.")
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Throws <see cref="ProtocolEvidenceMissingException"/> when version verification is enabled
|
||||
/// and the server's reported interface version differs from the version this SDK targets.
|
||||
@@ -80,14 +108,15 @@ internal static class HistorianServerVersionGate
|
||||
return;
|
||||
}
|
||||
|
||||
uint expected = ExpectedVersion(service);
|
||||
if (reportedVersion == expected)
|
||||
uint[] accepted = AcceptedVersions(service);
|
||||
if (Array.IndexOf(accepted, reportedVersion) >= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string acceptedList = string.Join(", ", accepted);
|
||||
throw new ProtocolEvidenceMissingException(
|
||||
$"{service} interface version {reportedVersion} (this SDK's serializers target version {expected}); " +
|
||||
$"{service} interface version {reportedVersion} (this SDK's serializers target version {acceptedList}); " +
|
||||
$"set {nameof(HistorianClientOptions)}.{nameof(HistorianClientOptions.VerifyServerInterfaceVersion)}=false to bypass at your own risk");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using AVEVA.Historian.Client.Grpc;
|
||||
using AVEVA.Historian.Client.Models;
|
||||
|
||||
namespace AVEVA.Historian.Client;
|
||||
|
||||
/// <summary>A live, reusable authenticated Historian session: holds one gRPC connection + one
|
||||
/// OpenConnection handle and runs ops on them WITHOUT re-handshaking. Reuse across ops amortizes the
|
||||
/// auth handshake. Idle-expires server-side in ~20-25s — callers keep it warm (PingAsync) or re-open.
|
||||
/// Reads, browse/metadata, historical-write, tag-write and status; events are NOT exposed (separate channel+auth).</summary>
|
||||
public sealed class HistorianSession : IAsyncDisposable
|
||||
{
|
||||
private readonly HistorianGrpcConnection _connection;
|
||||
private readonly HistorianGrpcHandshake.Session _session;
|
||||
private readonly HistorianClientOptions _options;
|
||||
private int _disposed;
|
||||
|
||||
/// <summary>Whether this session was opened read-only or write-enabled (the Open2 connection mode).</summary>
|
||||
public HistorianSessionKind Kind { get; }
|
||||
|
||||
internal HistorianSession(
|
||||
HistorianGrpcConnection connection,
|
||||
HistorianGrpcHandshake.Session session,
|
||||
HistorianClientOptions options,
|
||||
HistorianSessionKind kind)
|
||||
{
|
||||
_connection = connection;
|
||||
_session = session;
|
||||
_options = options;
|
||||
Kind = kind;
|
||||
}
|
||||
|
||||
// --- reads (mirror the orchestrators' async-enumerable wrapping; call the …OnSession seams,
|
||||
// which take the transient uint client handle) ---
|
||||
|
||||
/// <summary>Raw values for <paramref name="tag"/> in [<paramref name="startUtc"/>,
|
||||
/// <paramref name="endUtc"/>], capped at <paramref name="maxValues"/>, on the held session.</summary>
|
||||
public async IAsyncEnumerable<HistorianSample> ReadRawAsync(
|
||||
string tag,
|
||||
DateTime startUtc,
|
||||
DateTime endUtc,
|
||||
int maxValues,
|
||||
[EnumeratorCancellation] CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var orch = new HistorianGrpcReadOrchestrator(_options);
|
||||
IReadOnlyList<HistorianSample> rows = await Task.Run(
|
||||
() => orch.RunRawQueryOnSession(_connection, _session.ClientHandle, tag, startUtc, endUtc, maxValues, ct), ct)
|
||||
.ConfigureAwait(false);
|
||||
foreach (HistorianSample sample in rows)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
yield return sample;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Aggregate values for <paramref name="tag"/> over [<paramref name="startUtc"/>,
|
||||
/// <paramref name="endUtc"/>] in <paramref name="interval"/> buckets, using the supplied
|
||||
/// retrieval <paramref name="mode"/>, on the held session.</summary>
|
||||
public async IAsyncEnumerable<HistorianAggregateSample> ReadAggregateAsync(
|
||||
string tag,
|
||||
DateTime startUtc,
|
||||
DateTime endUtc,
|
||||
RetrievalMode mode,
|
||||
TimeSpan interval,
|
||||
[EnumeratorCancellation] CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var orch = new HistorianGrpcReadOrchestrator(_options);
|
||||
IReadOnlyList<HistorianAggregateSample> rows = await Task.Run(
|
||||
() => orch.RunAggregateQueryOnSession(_connection, _session.ClientHandle, tag, startUtc, endUtc, mode, interval, ct), ct)
|
||||
.ConfigureAwait(false);
|
||||
foreach (HistorianAggregateSample sample in rows)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
yield return sample;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Interpolated values for <paramref name="tag"/> at each of the supplied
|
||||
/// <paramref name="timestampsUtc"/>, on the held session.</summary>
|
||||
public Task<IReadOnlyList<HistorianSample>> ReadAtTimeAsync(
|
||||
string tag,
|
||||
IReadOnlyList<DateTime> timestampsUtc,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var orch = new HistorianGrpcReadOrchestrator(_options);
|
||||
return Task.Run<IReadOnlyList<HistorianSample>>(
|
||||
() => orch.RunAtTimeOnSession(_connection, _session.ClientHandle, tag, timestampsUtc, ct), ct);
|
||||
}
|
||||
|
||||
// --- browse / metadata (call the …OnSession seams, which take the full Session for the string handle) ---
|
||||
|
||||
/// <summary>Browses tag names matching <paramref name="filter"/> on the held session.</summary>
|
||||
public async IAsyncEnumerable<string> BrowseTagNamesAsync(
|
||||
string filter = "*",
|
||||
[EnumeratorCancellation] CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
List<string> names = await Task.Run(
|
||||
() => HistorianGrpcTagClient.BrowseTagNamesOnSession(_connection, _session, filter, _options, ct), ct)
|
||||
.ConfigureAwait(false);
|
||||
foreach (string name in names)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
yield return name;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads metadata for <paramref name="tag"/> on the held session (null if unknown).</summary>
|
||||
public Task<HistorianTagMetadata?> GetTagMetadataAsync(string tag, CancellationToken ct = default)
|
||||
=> Task.Run(() => HistorianGrpcTagClient.GetTagMetadataOnSession(_connection, _session, tag, _options, ct), ct);
|
||||
|
||||
// --- writes (the …OnSession seams take the full Session, since the historical write keys on the
|
||||
// string handle + tag GUID and the tag-config ops mix string/uint handles) ---
|
||||
|
||||
/// <summary>Writes historical/backfill values for <paramref name="tag"/> on the held (write-enabled) session.</summary>
|
||||
public Task<bool> AddHistoricalValuesAsync(
|
||||
string tag,
|
||||
IReadOnlyList<HistorianHistoricalValue> values,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var orch = new HistorianGrpcHistoricalWriteOrchestrator(_options);
|
||||
return Task.Run(() => orch.RunWriteOnSession(_connection, _session, tag, values, ct), ct);
|
||||
}
|
||||
|
||||
/// <summary>Ensures the tag described by <paramref name="definition"/> exists on the held (write-enabled) session.</summary>
|
||||
public Task<bool> EnsureTagAsync(HistorianTagDefinition definition, CancellationToken ct = default)
|
||||
{
|
||||
var orch = new HistorianGrpcTagWriteOrchestrator(_options);
|
||||
return Task.Run(() => orch.EnsureTagOnSession(_connection, _session, definition, ct), ct);
|
||||
}
|
||||
|
||||
/// <summary>Deletes <paramref name="tagName"/> on the held (write-enabled) session.</summary>
|
||||
public Task<bool> DeleteTagAsync(string tagName, CancellationToken ct = default)
|
||||
{
|
||||
var orch = new HistorianGrpcTagWriteOrchestrator(_options);
|
||||
return Task.Run(() => orch.DeleteTagOnSession(_connection, _session, tagName, ct), ct);
|
||||
}
|
||||
|
||||
/// <summary>Renames the supplied (old,new) tag-name <paramref name="pairs"/> on the held (write-enabled) session.</summary>
|
||||
public Task<HistorianTagRenameResult> RenameTagsAsync(
|
||||
IReadOnlyList<(string OldName, string NewName)> pairs,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var orch = new HistorianGrpcTagWriteOrchestrator(_options);
|
||||
return Task.Run(() => orch.RenameTagsOnSession(_connection, _session, pairs, ct), ct);
|
||||
}
|
||||
|
||||
/// <summary>Adds extended <paramref name="properties"/> to <paramref name="tagName"/> on the held (write-enabled) session.</summary>
|
||||
public Task<bool> AddTagExtendedPropertiesAsync(
|
||||
string tagName,
|
||||
IReadOnlyList<HistorianTagExtendedProperty> properties,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var orch = new HistorianGrpcTagWriteOrchestrator(_options);
|
||||
return Task.Run(() => orch.AddTagExtendedPropertiesOnSession(_connection, _session, tagName, properties, ct), ct);
|
||||
}
|
||||
|
||||
// --- status + keepalive (the status seams are static; handle shape differs per op) ---
|
||||
|
||||
/// <summary>Reads the named system parameter (e.g. "HistorianVersion") on the held session.</summary>
|
||||
public Task<string?> GetSystemParameterAsync(string parameterName, CancellationToken ct = default)
|
||||
=> Task.Run(() => HistorianGrpcStatusClient.GetSystemParameterOnSession(
|
||||
_connection, _session.ClientHandle, _options, parameterName, ct), ct);
|
||||
|
||||
/// <summary>Reports connection status derived from the held session's storage handle (no follow-on RPC).</summary>
|
||||
public Task<HistorianConnectionStatus> GetConnectionStatusAsync(CancellationToken ct = default)
|
||||
=> Task.Run(() =>
|
||||
{
|
||||
(bool connected, string? error) = HistorianGrpcStatusClient.EvaluateConnectionStatusOnSession(_connection, _session);
|
||||
return new HistorianConnectionStatus(
|
||||
ServerName: _options.Host,
|
||||
Pending: false,
|
||||
ErrorOccurred: !connected,
|
||||
Error: error,
|
||||
ConnectedToServer: connected,
|
||||
ConnectedToServerStorage: connected,
|
||||
ConnectedToStoreForward: false,
|
||||
ConnectionKind: HistorianConnectionKind.Process);
|
||||
}, ct);
|
||||
|
||||
/// <summary>Reports a measured store-forward status (GetHistorianConsoleStatus) on the held session.</summary>
|
||||
public Task<HistorianStoreForwardStatus> GetStoreForwardStatusAsync(CancellationToken ct = default)
|
||||
=> Task.Run(() =>
|
||||
{
|
||||
HistorianStoreForwardStatus NotStoring(bool errorOccurred, string? error) => new(
|
||||
ServerName: _options.Host,
|
||||
Pending: false,
|
||||
ErrorOccurred: errorOccurred,
|
||||
Error: error,
|
||||
DataStored: false,
|
||||
Storing: false,
|
||||
ConnectionKind: HistorianConnectionKind.Process);
|
||||
|
||||
return HistorianGrpcStatusClient.GetStoreForwardStatusOnSession(
|
||||
_connection, _session.StringHandle, _options, NotStoring, ct);
|
||||
}, ct);
|
||||
|
||||
/// <summary>Keeps the session warm against the server's ~20-25s idle expiry by issuing a cheap
|
||||
/// system-parameter read. Call periodically (or before a latency-sensitive op) to avoid re-handshaking.</summary>
|
||||
public Task PingAsync(CancellationToken ct = default) => GetSystemParameterAsync("HistorianVersion", ct);
|
||||
|
||||
/// <summary>Disposes the underlying gRPC connection (closes the channel). The server-side session
|
||||
/// also idle-expires on its own; this releases the local channel resources immediately.</summary>
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) == 0)
|
||||
{
|
||||
_connection.Dispose();
|
||||
}
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace AVEVA.Historian.Client;
|
||||
|
||||
/// <summary>Connection mode for an authenticated session. WriteEnabled (0x401) is a superset that
|
||||
/// also serves reads (live-verified 2026-06-25); ReadOnly (0x402) is read-only.</summary>
|
||||
public enum HistorianSessionKind { ReadOnly, WriteEnabled }
|
||||
@@ -0,0 +1,40 @@
|
||||
namespace AVEVA.Historian.Client.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Comparison operator for a <see cref="HistorianEventFilter"/>. Values mirror the native
|
||||
/// <c>ArchestrA.HistorianComparisionType</c> ordinals and travel on the wire as a UInt16.
|
||||
/// </summary>
|
||||
public enum HistorianEventComparison : ushort
|
||||
{
|
||||
Equal = 0,
|
||||
NotEqual = 1,
|
||||
LessThan = 2,
|
||||
NotLessThan = 3,
|
||||
GreaterThan = 4,
|
||||
NotGreaterThan = 5,
|
||||
LessThanEqual = 6,
|
||||
NotLessThanEqual = 7,
|
||||
GreaterThanEqual = 8,
|
||||
NotGreaterThanEqual = 9,
|
||||
Begins = 10,
|
||||
NotBegins = 11,
|
||||
Contains = 12,
|
||||
NotContains = 13,
|
||||
Exists = 14,
|
||||
NotExists = 15,
|
||||
EndWith = 16,
|
||||
NotEndWith = 17,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single server-side event filter predicate: <c>PropertyName Comparison Value</c>
|
||||
/// (e.g. <c>Type Equal "User.Write"</c>, <c>Area Contains "Tank"</c>). Applied to
|
||||
/// <c>ReadEventsAsync</c>; the server returns only events whose named property satisfies the
|
||||
/// comparison. For <see cref="HistorianEventComparison.Exists"/> /
|
||||
/// <see cref="HistorianEventComparison.NotExists"/> the value is ignored but still required by
|
||||
/// the wire format (pass any non-null string).
|
||||
/// </summary>
|
||||
public sealed record HistorianEventFilter(
|
||||
string PropertyName,
|
||||
HistorianEventComparison Comparison,
|
||||
string Value);
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace AVEVA.Historian.Client.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A single historical (backfill) value to insert via
|
||||
/// <see cref="HistorianClient.AddHistoricalValuesAsync"/>. The historian stores the value against
|
||||
/// the tag at <paramref name="TimestampUtc"/> as original (non-streamed) data.
|
||||
/// </summary>
|
||||
/// <param name="TimestampUtc">The value timestamp (UTC). Treated as UTC if unspecified-kind.</param>
|
||||
/// <param name="Value">The numeric value. Captured/supported for Float tags today.</param>
|
||||
/// <param name="OpcQuality">OPC quality; defaults to 192 (good).</param>
|
||||
public sealed record HistorianHistoricalValue(DateTime TimestampUtc, double Value, ushort OpcQuality = 192);
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace AVEVA.Historian.Client.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Execution mode for <c>ExecuteSqlCommandAsync</c>, mirroring the native
|
||||
/// <c>HistorianSqlExecuteOption</c> enum passed to the <c>ExeC</c> op. Only
|
||||
/// <see cref="ExecuteRecord"/> (the record-set path) is evidence-backed end-to-end.
|
||||
/// </summary>
|
||||
public enum HistorianSqlExecuteOption
|
||||
{
|
||||
/// <summary>Execute and return a record set (the captured/proven path).</summary>
|
||||
ExecuteRecord = 0,
|
||||
|
||||
/// <summary>Execute without returning a record set; the result carries only the return value.</summary>
|
||||
ExecuteNonQuery = 1,
|
||||
|
||||
/// <summary>Execute and return a single scalar value.</summary>
|
||||
ExecuteScalar = 2,
|
||||
|
||||
/// <summary>Execute a record set directly (server-side direct path).</summary>
|
||||
ExecuteRecordDirect = 3,
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace AVEVA.Historian.Client.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A column in a <see cref="HistorianSqlResult"/>: its name and the XSD type from the result-set
|
||||
/// schema (e.g. <c>xs:int</c>, <c>xs:string</c>, <c>xs:dateTime</c>).
|
||||
/// </summary>
|
||||
public sealed record HistorianSqlColumn(string Name, string SchemaType);
|
||||
|
||||
/// <summary>
|
||||
/// The result of <c>ExecuteSqlCommandAsync</c> — the managed equivalent of the <c>DataTable</c>
|
||||
/// the native client returns. Columns carry name + XSD type; each row is a list of values aligned
|
||||
/// to <see cref="Columns"/> (typed per the schema where the XSD type is recognized, otherwise the
|
||||
/// raw string; <c>null</c> for a SQL NULL / absent cell).
|
||||
/// </summary>
|
||||
public sealed class HistorianSqlResult
|
||||
{
|
||||
public HistorianSqlResult(
|
||||
IReadOnlyList<HistorianSqlColumn> columns,
|
||||
IReadOnlyList<IReadOnlyList<object?>> rows,
|
||||
int returnValue)
|
||||
{
|
||||
Columns = columns;
|
||||
Rows = rows;
|
||||
ReturnValue = returnValue;
|
||||
}
|
||||
|
||||
/// <summary>The result-set columns, in order.</summary>
|
||||
public IReadOnlyList<HistorianSqlColumn> Columns { get; }
|
||||
|
||||
/// <summary>The rows; each row's values align positionally to <see cref="Columns"/>.</summary>
|
||||
public IReadOnlyList<IReadOnlyList<object?>> Rows { get; }
|
||||
|
||||
/// <summary>The native <c>retValue</c> from <c>ExeC</c> (e.g. rows affected for non-queries).</summary>
|
||||
public int ReturnValue { get; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace AVEVA.Historian.Client.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A single extended (user-defined) property attached to a Historian tag — a name/value pair
|
||||
/// returned by <c>GetTagExtendedPropertiesAsync</c>. Extended properties are stored separately
|
||||
/// from the core tag metadata (server table <c>_TagExtendedProperty</c>) and are exposed over the
|
||||
/// 2020 WCF <c>aa/Retr/GetTepByNm</c> op.
|
||||
/// </summary>
|
||||
/// <param name="Name">The property name (e.g., <c>Location</c>).</param>
|
||||
/// <param name="Value">The property value as a string (the wire format is a VT_BSTR variant).</param>
|
||||
public sealed record HistorianTagExtendedProperty(string Name, string Value);
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace AVEVA.Historian.Client.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Result of <see cref="HistorianClient.RenameTagsAsync"/>. Tag rename on the Historian is an
|
||||
/// asynchronous server-side <em>job</em>: the client submits the rename batch via the History
|
||||
/// <c>StartJob</c> (StJb) operation and the server returns a job id, then applies the renames in the
|
||||
/// background (the native client polls <c>GetJobStatus</c>/<c>GtJb</c> until the job reports done).
|
||||
///
|
||||
/// <para><see cref="Accepted"/> reflects whether the server accepted and queued the job. The renames
|
||||
/// themselves complete asynchronously (observed: well under a couple of seconds for a small batch on
|
||||
/// the local server). The server gate <c>AllowRenameTags</c> must be enabled, or the native client
|
||||
/// library rejects the call before it reaches the wire (error 132 OperationNotEnabled).</para>
|
||||
/// </summary>
|
||||
public sealed record HistorianTagRenameResult
|
||||
{
|
||||
/// <summary>True when the server accepted and queued the rename job (StartJob returned success
|
||||
/// with an empty error buffer).</summary>
|
||||
public required bool Accepted { get; init; }
|
||||
|
||||
/// <summary>The server-assigned job id for the submitted rename batch (the <c>strJobid</c>
|
||||
/// returned by StartJob). <see cref="Guid.Empty"/> if the server returned no parseable id.</summary>
|
||||
public Guid JobId { get; init; }
|
||||
|
||||
/// <summary>Number of (old,new) name pairs submitted in the batch.</summary>
|
||||
public int PairCount { get; init; }
|
||||
|
||||
/// <summary>Server error text when <see cref="Accepted"/> is false; otherwise null.</summary>
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
@@ -42,29 +42,86 @@ internal sealed class Historian2020ProtocolDialect
|
||||
return Missing<HistorianBlock>("StartBlockRetrievalQuery", cancellationToken);
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<HistorianEvent> ReadEventsAsync(DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken)
|
||||
public IAsyncEnumerable<HistorianEvent> ReadEventsAsync(DateTime startUtc, DateTime endUtc, HistorianEventFilter? filter, CancellationToken cancellationToken)
|
||||
{
|
||||
HistorianWcfEventOrchestrator orchestrator = new(_options);
|
||||
return orchestrator.ReadEventsAsync(startUtc, endUtc, cancellationToken);
|
||||
return UseGrpc
|
||||
? new HistorianGrpcEventOrchestrator(_options).ReadEventsAsync(startUtc, endUtc, filter, cancellationToken)
|
||||
: new HistorianWcfEventOrchestrator(_options).ReadEventsAsync(startUtc, endUtc, filter, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<HistorianConnectionStatus> GetConnectionStatusAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return Wcf.HistorianWcfStatusClient.GetConnectionStatusAsync(_options, cancellationToken);
|
||||
|
||||
// Over gRPC (2023 R2) the status is measured from the gRPC handshake (the WCF synthesize-from-
|
||||
// probe path uses the MDAS binding, which can't reach the gRPC port). Non-gRPC stays on WCF.
|
||||
return UseGrpc
|
||||
? HistorianGrpcStatusClient.GetConnectionStatusAsync(_options, cancellationToken)
|
||||
: Wcf.HistorianWcfStatusClient.GetConnectionStatusAsync(_options, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<HistorianStoreForwardStatus> GetStoreForwardStatusAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return Wcf.HistorianWcfStatusClient.GetStoreForwardStatusAsync(_options, cancellationToken);
|
||||
|
||||
// Over gRPC (2023 R2) we return a MEASURED idle-state: the client actually contacts the server
|
||||
// (GetHistorianConsoleStatus) and reports ErrorOccurred when unreachable. The active-SF buffer
|
||||
// magnitude lives behind the D2 storage-engine console wall and stays false. Non-gRPC transports
|
||||
// keep the synthesized all-false (no SF sidecar to probe). See R4.3 §9.7.
|
||||
return UseGrpc
|
||||
? HistorianGrpcStatusClient.GetStoreForwardStatusAsync(_options, cancellationToken)
|
||||
: Wcf.HistorianWcfStatusClient.GetStoreForwardStatusAsync(_options, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<string?> GetSystemParameterAsync(string name, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
return Wcf.HistorianWcfStatusClient.GetSystemParameterAsync(_options, name, cancellationToken);
|
||||
return UseGrpc
|
||||
? HistorianGrpcStatusClient.GetSystemParameterAsync(_options, name, cancellationToken)
|
||||
: Wcf.HistorianWcfStatusClient.GetSystemParameterAsync(_options, name, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<string?> GetServerTimeZoneAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// 2023 R2 gRPC returns the real server time-zone name; the 2020 WCF
|
||||
// GetSystemTimeZoneName is a client-side stub (empty value), so there is no evidence-backed
|
||||
// value to return on that transport — fail closed rather than hand back an empty string.
|
||||
if (!UseGrpc)
|
||||
{
|
||||
throw new ProtocolEvidenceMissingException("GetSystemTimeZoneName (2020 WCF stub — gRPC/2023R2 only)");
|
||||
}
|
||||
|
||||
return HistorianGrpcStatusClient.GetSystemTimeZoneNameAsync(_options, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<string?> GetRuntimeParameterAsync(string name, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
return UseGrpc
|
||||
? HistorianGrpcStatusClient.GetRuntimeParameterAsync(_options, name, cancellationToken)
|
||||
: Wcf.HistorianWcfStatusClient.GetRuntimeParameterAsync(_options, name, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Models.HistorianTagExtendedProperty>> GetTagExtendedPropertiesAsync(string tag, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
|
||||
return UseGrpc
|
||||
? Grpc.HistorianGrpcTagClient.GetTagExtendedPropertiesAsync(_options, tag, cancellationToken)
|
||||
: Wcf.HistorianWcfTagExtendedPropertyClient.GetTagExtendedPropertiesAsync(_options, tag, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<HistorianSqlResult> ExecuteSqlCommandAsync(string command, HistorianSqlExecuteOption option, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(command);
|
||||
return UseGrpc
|
||||
? Grpc.HistorianGrpcSqlClient.ExecuteSqlCommandAsync(_options, command, option, cancellationToken)
|
||||
: Wcf.HistorianWcfSqlClient.ExecuteSqlCommandAsync(_options, command, option, cancellationToken);
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<T> Missing<T>(
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace AVEVA.Historian.Client.Redundancy;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown by a <see cref="HistorianRedundantClient"/> read when every member failed the operation.
|
||||
/// The per-member failures are aggregated in <see cref="Exception.InnerException"/> (an
|
||||
/// <see cref="AggregateException"/>).
|
||||
/// </summary>
|
||||
public sealed class HistorianAllMembersFailedException : Exception
|
||||
{
|
||||
public HistorianAllMembersFailedException(string operation, IReadOnlyList<Exception> failures)
|
||||
: base($"All historian members failed the '{operation}' operation.", new AggregateException(failures))
|
||||
{
|
||||
Operation = operation;
|
||||
}
|
||||
|
||||
/// <summary>The orchestrated operation that failed across all members.</summary>
|
||||
public string Operation { get; }
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using AVEVA.Historian.Client.Models;
|
||||
|
||||
namespace AVEVA.Historian.Client.Redundancy;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IHistorianMember"/> adapter over a <see cref="HistorianClient"/>. For durable
|
||||
/// redundant writes, pass a member whose write methods enqueue to an R4.1
|
||||
/// <c>HistorianStoreForwardWriter</c> instead — then a member that is down buffers its writes and
|
||||
/// replays them on recovery (the pragmatic client-side equivalent of native ReSyncTags).
|
||||
/// </summary>
|
||||
public sealed class HistorianClientMember : IHistorianMember
|
||||
{
|
||||
private readonly HistorianClient _client;
|
||||
|
||||
public HistorianClientMember(string name, HistorianClient client)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
Name = name;
|
||||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public Task<bool> ProbeAsync(CancellationToken cancellationToken) => _client.ProbeAsync(cancellationToken);
|
||||
|
||||
public IAsyncEnumerable<HistorianSample> ReadRawAsync(string tag, DateTime startUtc, DateTime endUtc, int maxValues, CancellationToken cancellationToken) =>
|
||||
_client.ReadRawAsync(tag, startUtc, endUtc, maxValues, cancellationToken);
|
||||
|
||||
public IAsyncEnumerable<HistorianAggregateSample> ReadAggregateAsync(string tag, DateTime startUtc, DateTime endUtc, RetrievalMode mode, TimeSpan interval, CancellationToken cancellationToken) =>
|
||||
_client.ReadAggregateAsync(tag, startUtc, endUtc, mode, interval, cancellationToken);
|
||||
|
||||
public Task<IReadOnlyList<HistorianSample>> ReadAtTimeAsync(string tag, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken) =>
|
||||
_client.ReadAtTimeAsync(tag, timestampsUtc, cancellationToken);
|
||||
|
||||
public IAsyncEnumerable<HistorianEvent> ReadEventsAsync(DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken) =>
|
||||
_client.ReadEventsAsync(startUtc, endUtc, cancellationToken);
|
||||
|
||||
public IAsyncEnumerable<string> BrowseTagNamesAsync(string filter, CancellationToken cancellationToken) =>
|
||||
_client.BrowseTagNamesAsync(filter, cancellationToken);
|
||||
|
||||
public Task<HistorianTagMetadata?> GetTagMetadataAsync(string tag, CancellationToken cancellationToken) =>
|
||||
_client.GetTagMetadataAsync(tag, cancellationToken);
|
||||
|
||||
public Task<bool> AddHistoricalValuesAsync(string tag, IReadOnlyList<HistorianHistoricalValue> values, CancellationToken cancellationToken) =>
|
||||
_client.AddHistoricalValuesAsync(tag, values, cancellationToken);
|
||||
|
||||
public Task<bool> SendEventAsync(HistorianEvent historianEvent, CancellationToken cancellationToken) =>
|
||||
_client.SendEventAsync(historianEvent, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace AVEVA.Historian.Client.Redundancy;
|
||||
|
||||
/// <summary>A point-in-time health view of one cluster member.</summary>
|
||||
public sealed record HistorianMemberStatus
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>True when the member is currently in the healthy pool (preferred for routing).</summary>
|
||||
public required bool IsHealthy { get; init; }
|
||||
|
||||
/// <summary>Consecutive failed operations since the last success.</summary>
|
||||
public required int ConsecutiveFailures { get; init; }
|
||||
|
||||
/// <summary>The most recent operation error, if any.</summary>
|
||||
public string? LastError { get; init; }
|
||||
|
||||
/// <summary>When this member last completed an operation successfully (UTC).</summary>
|
||||
public DateTime? LastSuccessUtc { get; init; }
|
||||
|
||||
/// <summary>When this member was last probed/exercised (UTC).</summary>
|
||||
public DateTime? LastCheckUtc { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>A snapshot of the whole cluster: the active (preferred-healthy) member plus every member's health.</summary>
|
||||
public sealed record HistorianClusterStatus
|
||||
{
|
||||
public required IReadOnlyList<HistorianMemberStatus> Members { get; init; }
|
||||
|
||||
/// <summary>The name of the member reads currently prefer (first healthy in priority order), or null when all are down.</summary>
|
||||
public string? ActiveMember { get; init; }
|
||||
|
||||
/// <summary>True when at least one member is healthy.</summary>
|
||||
public bool AnyHealthy => Members.Any(m => m.IsHealthy);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace AVEVA.Historian.Client.Redundancy;
|
||||
|
||||
/// <summary>Tuning for <see cref="HistorianRedundantClient"/>.</summary>
|
||||
public sealed record HistorianRedundancyOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Consecutive failed operations before a member drops out of the healthy routing pool. Default 1
|
||||
/// (demote on first failure; the watchdog restores it). Reads still fail over on every failure
|
||||
/// regardless — this only governs which member is <em>tried first</em>.
|
||||
/// </summary>
|
||||
public int FailureThreshold { get; init; } = 1;
|
||||
|
||||
/// <summary>Which members a write is sent to. Default <see cref="HistorianWriteFanout.AllMembers"/>.</summary>
|
||||
public HistorianWriteFanout WriteFanout { get; init; } = HistorianWriteFanout.AllMembers;
|
||||
|
||||
/// <summary>What counts as an overall write success. Default <see cref="HistorianWriteAcknowledgement.All"/>.</summary>
|
||||
public HistorianWriteAcknowledgement WriteAcknowledgement { get; init; } = HistorianWriteAcknowledgement.All;
|
||||
|
||||
/// <summary>
|
||||
/// When true, <see cref="HistorianRedundantClient.StartAsync"/> runs a watchdog loop that probes
|
||||
/// members on <see cref="WatchdogInterval"/> to restore health after recovery. Default true.
|
||||
/// </summary>
|
||||
public bool RunWatchdog { get; init; } = true;
|
||||
|
||||
/// <summary>How often the watchdog probes members. Default 15s.</summary>
|
||||
public TimeSpan WatchdogInterval { get; init; } = TimeSpan.FromSeconds(15);
|
||||
}
|
||||
|
||||
/// <summary>Which members a redundant write targets.</summary>
|
||||
public enum HistorianWriteFanout
|
||||
{
|
||||
/// <summary>Write to every member (client-side replication). The default redundancy posture.</summary>
|
||||
AllMembers = 0,
|
||||
|
||||
/// <summary>Write only to the preferred healthy member (rely on server-side replication).</summary>
|
||||
PreferredOnly = 1,
|
||||
}
|
||||
|
||||
/// <summary>What makes a fan-out write "succeed" overall.</summary>
|
||||
public enum HistorianWriteAcknowledgement
|
||||
{
|
||||
/// <summary>Every targeted member must accept the write.</summary>
|
||||
All = 0,
|
||||
|
||||
/// <summary>At least one targeted member must accept the write.</summary>
|
||||
Any = 1,
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using AVEVA.Historian.Client.Models;
|
||||
|
||||
namespace AVEVA.Historian.Client.Redundancy;
|
||||
|
||||
/// <summary>
|
||||
/// R4.4 client-side multi-historian redundancy: orchestrates N single-historian
|
||||
/// <see cref="IHistorianMember"/>s as one logical client. Reads fail over to the next member when one
|
||||
/// is down; writes fan out per the configured <see cref="HistorianWriteFanout"/> /
|
||||
/// <see cref="HistorianWriteAcknowledgement"/> policy; a watchdog restores members to the healthy
|
||||
/// pool after they recover.
|
||||
/// <para>
|
||||
/// Member order is priority order — the first is the preferred primary. This is pure client-side
|
||||
/// orchestration (no server-side redundancy protocol). For durable writes to a member that is down,
|
||||
/// back that member's writes with an R4.1 <c>HistorianStoreForwardWriter</c> so they buffer and
|
||||
/// replay on recovery.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class HistorianRedundantClient : IAsyncDisposable
|
||||
{
|
||||
private readonly IReadOnlyList<MemberState> _members;
|
||||
private readonly HistorianRedundancyOptions _options;
|
||||
|
||||
private CancellationTokenSource? _watchdogCts;
|
||||
private Task? _watchdogTask;
|
||||
|
||||
public HistorianRedundantClient(IReadOnlyList<IHistorianMember> members, HistorianRedundancyOptions? options = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(members);
|
||||
if (members.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one member is required.", nameof(members));
|
||||
}
|
||||
|
||||
_options = options ?? new HistorianRedundancyOptions();
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(_options.FailureThreshold);
|
||||
_members = members.Select(m => new MemberState(m, _options.FailureThreshold)).ToList();
|
||||
}
|
||||
|
||||
// ---- reads (failover) ----------------------------------------------------------------
|
||||
|
||||
/// <summary>True when any member is reachable.</summary>
|
||||
public async Task<bool> ProbeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
foreach (MemberState member in OrderedCandidates())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
if (await member.Member.ProbeAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
member.MarkSuccess(DateTime.UtcNow);
|
||||
return true;
|
||||
}
|
||||
|
||||
member.MarkFailure("Probe returned false.", DateTime.UtcNow);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
member.MarkFailure(ex.Message, DateTime.UtcNow);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<HistorianSample> ReadRawAsync(string tag, DateTime startUtc, DateTime endUtc, int maxValues, CancellationToken cancellationToken = default) =>
|
||||
StreamWithFailoverAsync(nameof(ReadRawAsync), (m, c) => m.ReadRawAsync(tag, startUtc, endUtc, maxValues, c), cancellationToken);
|
||||
|
||||
public IAsyncEnumerable<HistorianAggregateSample> ReadAggregateAsync(string tag, DateTime startUtc, DateTime endUtc, RetrievalMode mode, TimeSpan interval, CancellationToken cancellationToken = default) =>
|
||||
StreamWithFailoverAsync(nameof(ReadAggregateAsync), (m, c) => m.ReadAggregateAsync(tag, startUtc, endUtc, mode, interval, c), cancellationToken);
|
||||
|
||||
public IAsyncEnumerable<HistorianEvent> ReadEventsAsync(DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) =>
|
||||
StreamWithFailoverAsync(nameof(ReadEventsAsync), (m, c) => m.ReadEventsAsync(startUtc, endUtc, c), cancellationToken);
|
||||
|
||||
public IAsyncEnumerable<string> BrowseTagNamesAsync(string filter = "*", CancellationToken cancellationToken = default) =>
|
||||
StreamWithFailoverAsync(nameof(BrowseTagNamesAsync), (m, c) => m.BrowseTagNamesAsync(filter, c), cancellationToken);
|
||||
|
||||
public Task<IReadOnlyList<HistorianSample>> ReadAtTimeAsync(string tag, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken = default) =>
|
||||
ExecuteWithFailoverAsync(nameof(ReadAtTimeAsync), (m, c) => m.ReadAtTimeAsync(tag, timestampsUtc, c), cancellationToken);
|
||||
|
||||
public Task<HistorianTagMetadata?> GetTagMetadataAsync(string tag, CancellationToken cancellationToken = default) =>
|
||||
ExecuteWithFailoverAsync(nameof(GetTagMetadataAsync), (m, c) => m.GetTagMetadataAsync(tag, c), cancellationToken);
|
||||
|
||||
// ---- writes (fan-out) ----------------------------------------------------------------
|
||||
|
||||
public Task<HistorianRedundantWriteResult> AddHistoricalValuesAsync(string tag, IReadOnlyList<HistorianHistoricalValue> values, CancellationToken cancellationToken = default) =>
|
||||
FanOutWriteAsync((m, c) => m.AddHistoricalValuesAsync(tag, values, c), cancellationToken);
|
||||
|
||||
public Task<HistorianRedundantWriteResult> SendEventAsync(HistorianEvent historianEvent, CancellationToken cancellationToken = default) =>
|
||||
FanOutWriteAsync((m, c) => m.SendEventAsync(historianEvent, c), cancellationToken);
|
||||
|
||||
// ---- status --------------------------------------------------------------------------
|
||||
|
||||
/// <summary>A snapshot of every member's health and the currently preferred (active) member.</summary>
|
||||
public HistorianClusterStatus GetStatus()
|
||||
{
|
||||
List<HistorianMemberStatus> members = _members.Select(m => m.Snapshot()).ToList();
|
||||
string? active = _members.FirstOrDefault(m => m.IsHealthy)?.Member.Name;
|
||||
return new HistorianClusterStatus { Members = members, ActiveMember = active };
|
||||
}
|
||||
|
||||
// ---- watchdog ------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Starts the watchdog loop (no-op when <see cref="HistorianRedundancyOptions.RunWatchdog"/> is
|
||||
/// false, or already started). The loop probes members every
|
||||
/// <see cref="HistorianRedundancyOptions.WatchdogInterval"/> to restore health after recovery.
|
||||
/// </summary>
|
||||
public Task StartAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_options.RunWatchdog || _watchdogTask is not null)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
_watchdogCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
_watchdogTask = Task.Run(() => RunWatchdogAsync(_watchdogCts.Token), CancellationToken.None);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Stops the watchdog loop (if running).</summary>
|
||||
public async Task StopAsync()
|
||||
{
|
||||
if (_watchdogCts is null || _watchdogTask is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _watchdogCts.CancelAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await _watchdogTask.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
finally
|
||||
{
|
||||
_watchdogCts.Dispose();
|
||||
_watchdogCts = null;
|
||||
_watchdogTask = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Probes every member once now, updating health. Returns the resulting cluster status.</summary>
|
||||
public async Task<HistorianClusterStatus> CheckHealthAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
foreach (MemberState member in _members)
|
||||
{
|
||||
await ProbeMemberAsync(member, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return GetStatus();
|
||||
}
|
||||
|
||||
private async Task RunWatchdogAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var timer = new PeriodicTimer(_options.WatchdogInterval);
|
||||
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
foreach (MemberState member in _members)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Only spend probes on members that need recovering.
|
||||
if (!member.IsHealthy)
|
||||
{
|
||||
await ProbeMemberAsync(member, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ProbeMemberAsync(MemberState member, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (await member.Member.ProbeAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
member.MarkSuccess(DateTime.UtcNow);
|
||||
}
|
||||
else
|
||||
{
|
||||
member.MarkFailure("Probe returned false.", DateTime.UtcNow);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
member.MarkFailure(ex.Message, DateTime.UtcNow);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- orchestration core --------------------------------------------------------------
|
||||
|
||||
private async Task<T> ExecuteWithFailoverAsync<T>(string operation, Func<IHistorianMember, CancellationToken, Task<T>> op, CancellationToken cancellationToken)
|
||||
{
|
||||
var failures = new List<Exception>();
|
||||
foreach (MemberState member in OrderedCandidates())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
T result = await op(member.Member, cancellationToken).ConfigureAwait(false);
|
||||
member.MarkSuccess(DateTime.UtcNow);
|
||||
return result;
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
member.MarkFailure(ex.Message, DateTime.UtcNow);
|
||||
failures.Add(ex);
|
||||
}
|
||||
}
|
||||
|
||||
throw new HistorianAllMembersFailedException(operation, failures);
|
||||
}
|
||||
|
||||
private async IAsyncEnumerable<T> StreamWithFailoverAsync<T>(
|
||||
string operation,
|
||||
Func<IHistorianMember, CancellationToken, IAsyncEnumerable<T>> op,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
var failures = new List<Exception>();
|
||||
IReadOnlyList<MemberState> candidates = OrderedCandidates();
|
||||
|
||||
for (int i = 0; i < candidates.Count; i++)
|
||||
{
|
||||
MemberState member = candidates[i];
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
IAsyncEnumerator<T> enumerator = op(member.Member, cancellationToken).GetAsyncEnumerator(cancellationToken);
|
||||
bool failedBeforeYield = false;
|
||||
try
|
||||
{
|
||||
bool yieldedAny = false;
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!await enumerator.MoveNextAsync().ConfigureAwait(false))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
member.MarkFailure(ex.Message, DateTime.UtcNow);
|
||||
failures.Add(ex);
|
||||
|
||||
// Failover is only safe before any row has been observed; a mid-stream failure
|
||||
// would risk duplicated or skipped rows, so propagate it instead.
|
||||
if (yieldedAny)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
|
||||
failedBeforeYield = true;
|
||||
break;
|
||||
}
|
||||
|
||||
yieldedAny = true;
|
||||
yield return enumerator.Current;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await enumerator.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (failedBeforeYield)
|
||||
{
|
||||
continue; // try the next member
|
||||
}
|
||||
|
||||
member.MarkSuccess(DateTime.UtcNow);
|
||||
yield break;
|
||||
}
|
||||
|
||||
throw new HistorianAllMembersFailedException(operation, failures);
|
||||
}
|
||||
|
||||
private async Task<HistorianRedundantWriteResult> FanOutWriteAsync(Func<IHistorianMember, CancellationToken, Task<bool>> op, CancellationToken cancellationToken)
|
||||
{
|
||||
IReadOnlyList<MemberState> targets = _options.WriteFanout == HistorianWriteFanout.PreferredOnly
|
||||
? OrderedCandidates().Take(1).ToList()
|
||||
: _members;
|
||||
|
||||
HistorianMemberWriteOutcome[] outcomes = await Task.WhenAll(targets.Select(async member =>
|
||||
{
|
||||
try
|
||||
{
|
||||
bool accepted = await op(member.Member, cancellationToken).ConfigureAwait(false);
|
||||
if (accepted)
|
||||
{
|
||||
member.MarkSuccess(DateTime.UtcNow);
|
||||
return new HistorianMemberWriteOutcome { Member = member.Member.Name, Accepted = true };
|
||||
}
|
||||
|
||||
member.MarkFailure("Member did not accept the write.", DateTime.UtcNow);
|
||||
return new HistorianMemberWriteOutcome { Member = member.Member.Name, Accepted = false, Error = "Member did not accept the write." };
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
member.MarkFailure(ex.Message, DateTime.UtcNow);
|
||||
return new HistorianMemberWriteOutcome { Member = member.Member.Name, Accepted = false, Error = ex.Message };
|
||||
}
|
||||
})).ConfigureAwait(false);
|
||||
|
||||
bool succeeded = _options.WriteAcknowledgement == HistorianWriteAcknowledgement.Any
|
||||
? outcomes.Any(o => o.Accepted)
|
||||
: outcomes.All(o => o.Accepted);
|
||||
|
||||
return new HistorianRedundantWriteResult { Outcomes = outcomes, Succeeded = succeeded };
|
||||
}
|
||||
|
||||
/// <summary>Members in priority order, healthy first, so reads prefer a known-good member but still
|
||||
/// fall back to a currently-unhealthy one as a last resort.</summary>
|
||||
private IReadOnlyList<MemberState> OrderedCandidates()
|
||||
{
|
||||
var healthy = new List<MemberState>(_members.Count);
|
||||
var unhealthy = new List<MemberState>();
|
||||
foreach (MemberState member in _members)
|
||||
{
|
||||
(member.IsHealthy ? healthy : unhealthy).Add(member);
|
||||
}
|
||||
|
||||
healthy.AddRange(unhealthy);
|
||||
return healthy;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await StopAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace AVEVA.Historian.Client.Redundancy;
|
||||
|
||||
/// <summary>The per-member outcome of a fan-out write to one cluster member.</summary>
|
||||
public sealed record HistorianMemberWriteOutcome
|
||||
{
|
||||
public required string Member { get; init; }
|
||||
|
||||
/// <summary>True when this member accepted the write.</summary>
|
||||
public required bool Accepted { get; init; }
|
||||
|
||||
/// <summary>The delivery error, if this member did not accept the write.</summary>
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>The aggregate result of a redundant (fan-out) write across the targeted members.</summary>
|
||||
public sealed record HistorianRedundantWriteResult
|
||||
{
|
||||
public required IReadOnlyList<HistorianMemberWriteOutcome> Outcomes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the write succeeded overall under the configured
|
||||
/// <see cref="HistorianWriteAcknowledgement"/> policy.
|
||||
/// </summary>
|
||||
public required bool Succeeded { get; init; }
|
||||
|
||||
/// <summary>The members that accepted the write.</summary>
|
||||
public IEnumerable<HistorianMemberWriteOutcome> Accepted => Outcomes.Where(o => o.Accepted);
|
||||
|
||||
/// <summary>The members that rejected or failed the write.</summary>
|
||||
public IEnumerable<HistorianMemberWriteOutcome> Failed => Outcomes.Where(o => !o.Accepted);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using AVEVA.Historian.Client.Models;
|
||||
|
||||
namespace AVEVA.Historian.Client.Redundancy;
|
||||
|
||||
/// <summary>
|
||||
/// One historian the <see cref="HistorianRedundantClient"/> orchestrates. Exposes the read/write
|
||||
/// subset the cluster coordinates (failover reads, fan-out writes). Abstracted from
|
||||
/// <see cref="HistorianClient"/> so redundancy logic is unit-testable without a server; the default
|
||||
/// adapter is <see cref="HistorianClientMember"/>.
|
||||
/// </summary>
|
||||
public interface IHistorianMember
|
||||
{
|
||||
/// <summary>A stable, human-readable name for this member (used in status + diagnostics).</summary>
|
||||
string Name { get; }
|
||||
|
||||
Task<bool> ProbeAsync(CancellationToken cancellationToken);
|
||||
|
||||
IAsyncEnumerable<HistorianSample> ReadRawAsync(string tag, DateTime startUtc, DateTime endUtc, int maxValues, CancellationToken cancellationToken);
|
||||
|
||||
IAsyncEnumerable<HistorianAggregateSample> ReadAggregateAsync(string tag, DateTime startUtc, DateTime endUtc, RetrievalMode mode, TimeSpan interval, CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<HistorianSample>> ReadAtTimeAsync(string tag, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken);
|
||||
|
||||
IAsyncEnumerable<HistorianEvent> ReadEventsAsync(DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken);
|
||||
|
||||
IAsyncEnumerable<string> BrowseTagNamesAsync(string filter, CancellationToken cancellationToken);
|
||||
|
||||
Task<HistorianTagMetadata?> GetTagMetadataAsync(string tag, CancellationToken cancellationToken);
|
||||
|
||||
Task<bool> AddHistoricalValuesAsync(string tag, IReadOnlyList<HistorianHistoricalValue> values, CancellationToken cancellationToken);
|
||||
|
||||
Task<bool> SendEventAsync(HistorianEvent historianEvent, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
namespace AVEVA.Historian.Client.Redundancy;
|
||||
|
||||
/// <summary>
|
||||
/// Mutable per-member health used by <see cref="HistorianRedundantClient"/> to route reads and
|
||||
/// fan out writes. Thread-safe: ops update it from multiple call sites and the watchdog loop.
|
||||
/// </summary>
|
||||
internal sealed class MemberState
|
||||
{
|
||||
private readonly Lock _lock = new();
|
||||
private readonly int _failureThreshold;
|
||||
|
||||
private bool _isHealthy = true;
|
||||
private int _consecutiveFailures;
|
||||
private string? _lastError;
|
||||
private DateTime? _lastSuccessUtc;
|
||||
private DateTime? _lastCheckUtc;
|
||||
|
||||
public MemberState(IHistorianMember member, int failureThreshold)
|
||||
{
|
||||
Member = member;
|
||||
_failureThreshold = failureThreshold;
|
||||
}
|
||||
|
||||
public IHistorianMember Member { get; }
|
||||
|
||||
public bool IsHealthy
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _isHealthy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void MarkSuccess(DateTime utc)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_consecutiveFailures = 0;
|
||||
_isHealthy = true;
|
||||
_lastError = null;
|
||||
_lastSuccessUtc = utc;
|
||||
_lastCheckUtc = utc;
|
||||
}
|
||||
}
|
||||
|
||||
public void MarkFailure(string? error, DateTime utc)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_consecutiveFailures++;
|
||||
_lastError = error;
|
||||
_lastCheckUtc = utc;
|
||||
if (_consecutiveFailures >= _failureThreshold)
|
||||
{
|
||||
_isHealthy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public HistorianMemberStatus Snapshot()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return new HistorianMemberStatus
|
||||
{
|
||||
Name = Member.Name,
|
||||
IsHealthy = _isHealthy,
|
||||
ConsecutiveFailures = _consecutiveFailures,
|
||||
LastError = _lastError,
|
||||
LastSuccessUtc = _lastSuccessUtc,
|
||||
LastCheckUtc = _lastCheckUtc,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AVEVA.Historian.Client.StoreForward;
|
||||
|
||||
/// <summary>
|
||||
/// File-backed <see cref="IHistorianOutboxStore"/>: each buffered write is one JSON file in
|
||||
/// <c>directory</c>, named <c>{sequence:D20}-{id:N}.json</c> so a lexical filename sort yields FIFO
|
||||
/// order. Writes are atomic (temp file + move) so a crash mid-write never leaves a half-written
|
||||
/// entry. Buffers survive process restarts — the sequence counter resumes past the highest file
|
||||
/// already on disk. A file that fails to parse is quarantined (renamed <c>.corrupt</c>) rather than
|
||||
/// wedging the drain.
|
||||
/// </summary>
|
||||
public sealed class FileHistorianOutboxStore : IHistorianOutboxStore
|
||||
{
|
||||
private const string EntryExtension = ".json";
|
||||
private const string TempExtension = ".tmp";
|
||||
private const string CorruptExtension = ".corrupt";
|
||||
|
||||
private readonly string _directory;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private long _sequence;
|
||||
|
||||
public FileHistorianOutboxStore(string directory)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(directory);
|
||||
_directory = directory;
|
||||
Directory.CreateDirectory(_directory);
|
||||
_sequence = HighestSequenceOnDisk();
|
||||
}
|
||||
|
||||
public async Task<HistorianOutboxEntry> EnqueueAsync(HistorianOutboxEntry entry, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
long sequence = ++_sequence;
|
||||
HistorianOutboxEntry stored = entry with { Sequence = sequence };
|
||||
await WriteEntryAsync(stored, cancellationToken).ConfigureAwait(false);
|
||||
return stored;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<HistorianOutboxEntry>> PeekBatchAsync(int maxCount, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxCount);
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var batch = new List<HistorianOutboxEntry>(Math.Min(maxCount, 64));
|
||||
foreach (string path in EnumerateEntryFilesInOrder())
|
||||
{
|
||||
if (batch.Count >= maxCount)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
HistorianOutboxEntry? entry = await TryReadEntryAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
if (entry is not null)
|
||||
{
|
||||
batch.Add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return batch;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(HistorianOutboxEntry entry, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
// Only rewrite if the entry still exists (it may have been forwarded + removed already).
|
||||
if (File.Exists(EntryPath(entry)))
|
||||
{
|
||||
await WriteEntryAsync(entry, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RemoveAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
string suffix = "-" + id.ToString("N") + EntryExtension;
|
||||
foreach (string path in Directory.EnumerateFiles(_directory, "*" + EntryExtension))
|
||||
{
|
||||
if (Path.GetFileName(path).EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
TryDelete(path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<int> CountAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
return Directory.EnumerateFiles(_directory, "*" + EntryExtension).Count();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WriteEntryAsync(HistorianOutboxEntry entry, CancellationToken cancellationToken)
|
||||
{
|
||||
string finalPath = EntryPath(entry);
|
||||
string tempPath = finalPath + TempExtension;
|
||||
byte[] bytes = JsonSerializer.SerializeToUtf8Bytes(entry, OutboxJson.Options);
|
||||
await File.WriteAllBytesAsync(tempPath, bytes, cancellationToken).ConfigureAwait(false);
|
||||
File.Move(tempPath, finalPath, overwrite: true);
|
||||
}
|
||||
|
||||
private async Task<HistorianOutboxEntry?> TryReadEntryAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
byte[] bytes = await File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
HistorianOutboxEntry? entry = JsonSerializer.Deserialize<HistorianOutboxEntry>(bytes, OutboxJson.Options);
|
||||
if (entry is not null)
|
||||
{
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// fall through to quarantine
|
||||
}
|
||||
|
||||
Quarantine(path);
|
||||
return null;
|
||||
}
|
||||
|
||||
private void Quarantine(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Move(path, path + CorruptExtension, overwrite: true);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<string> EnumerateEntryFilesInOrder() =>
|
||||
Directory.EnumerateFiles(_directory, "*" + EntryExtension)
|
||||
.OrderBy(Path.GetFileName, StringComparer.Ordinal);
|
||||
|
||||
private string EntryPath(HistorianOutboxEntry entry) =>
|
||||
Path.Combine(_directory, $"{entry.Sequence:D20}-{entry.Id:N}{EntryExtension}");
|
||||
|
||||
private long HighestSequenceOnDisk()
|
||||
{
|
||||
long highest = 0;
|
||||
foreach (string path in Directory.EnumerateFiles(_directory, "*" + EntryExtension))
|
||||
{
|
||||
string name = Path.GetFileName(path);
|
||||
int dash = name.IndexOf('-');
|
||||
if (dash > 0 && long.TryParse(name.AsSpan(0, dash), out long seq) && seq > highest)
|
||||
{
|
||||
highest = seq;
|
||||
}
|
||||
}
|
||||
|
||||
return highest;
|
||||
}
|
||||
|
||||
private static void TryDelete(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using AVEVA.Historian.Client.Models;
|
||||
|
||||
namespace AVEVA.Historian.Client.StoreForward;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IHistorianWriteSink"/> that forwards buffered writes through a
|
||||
/// <see cref="HistorianClient"/>. Historical values replay via
|
||||
/// <see cref="HistorianClient.AddHistoricalValuesAsync"/> and events via
|
||||
/// <see cref="HistorianClient.SendEventAsync"/>.
|
||||
/// </summary>
|
||||
public sealed class HistorianClientWriteSink : IHistorianWriteSink
|
||||
{
|
||||
private readonly HistorianClient _client;
|
||||
|
||||
public HistorianClientWriteSink(HistorianClient client)
|
||||
{
|
||||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
}
|
||||
|
||||
public Task<bool> SendHistoricalValuesAsync(string tag, IReadOnlyList<HistorianHistoricalValue> values, CancellationToken cancellationToken) =>
|
||||
_client.AddHistoricalValuesAsync(tag, values, cancellationToken);
|
||||
|
||||
public Task<bool> SendEventAsync(HistorianEvent historianEvent, CancellationToken cancellationToken) =>
|
||||
_client.SendEventAsync(historianEvent, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using AVEVA.Historian.Client.Models;
|
||||
|
||||
namespace AVEVA.Historian.Client.StoreForward;
|
||||
|
||||
/// <summary>
|
||||
/// A single buffered write held in the store-and-forward outbox. One envelope carries either a
|
||||
/// batch of historical values (<see cref="HistorianOutboxEntryKind.HistoricalValues"/>) or one
|
||||
/// event (<see cref="HistorianOutboxEntryKind.Event"/>), discriminated by <see cref="Kind"/>.
|
||||
/// <para>
|
||||
/// <see cref="Id"/> is assigned by the producer; <see cref="Sequence"/> is assigned by the store on
|
||||
/// enqueue and defines FIFO drain order. <see cref="AttemptCount"/> / <see cref="LastError"/> track
|
||||
/// delivery retries and are updated in place as the forwarder works the queue.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed record HistorianOutboxEntry
|
||||
{
|
||||
/// <summary>Producer-assigned unique id (stable across persistence + retries).</summary>
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
/// <summary>Store-assigned monotonic sequence; defines FIFO drain order. 0 until enqueued.</summary>
|
||||
public long Sequence { get; init; }
|
||||
|
||||
/// <summary>When the write was first buffered (UTC).</summary>
|
||||
public required DateTime EnqueuedUtc { get; init; }
|
||||
|
||||
/// <summary>Which payload this envelope carries.</summary>
|
||||
public required HistorianOutboxEntryKind Kind { get; init; }
|
||||
|
||||
/// <summary>Number of failed delivery attempts so far.</summary>
|
||||
public int AttemptCount { get; init; }
|
||||
|
||||
/// <summary>The most recent delivery error, if any.</summary>
|
||||
public string? LastError { get; init; }
|
||||
|
||||
/// <summary>Target tag for <see cref="HistorianOutboxEntryKind.HistoricalValues"/>; otherwise null.</summary>
|
||||
public string? Tag { get; init; }
|
||||
|
||||
/// <summary>The values for <see cref="HistorianOutboxEntryKind.HistoricalValues"/>; otherwise null.</summary>
|
||||
public IReadOnlyList<HistorianHistoricalValue>? Values { get; init; }
|
||||
|
||||
/// <summary>The event for <see cref="HistorianOutboxEntryKind.Event"/>; otherwise null.</summary>
|
||||
public HistorianEvent? Event { get; init; }
|
||||
|
||||
/// <summary>Builds a buffered historical-values entry (sequence assigned later by the store).</summary>
|
||||
public static HistorianOutboxEntry ForHistoricalValues(string tag, IReadOnlyList<HistorianHistoricalValue> values, DateTime enqueuedUtc) =>
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
EnqueuedUtc = enqueuedUtc,
|
||||
Kind = HistorianOutboxEntryKind.HistoricalValues,
|
||||
Tag = tag,
|
||||
Values = values,
|
||||
};
|
||||
|
||||
/// <summary>Builds a buffered event entry (sequence assigned later by the store).</summary>
|
||||
public static HistorianOutboxEntry ForEvent(HistorianEvent historianEvent, DateTime enqueuedUtc) =>
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
EnqueuedUtc = enqueuedUtc,
|
||||
Kind = HistorianOutboxEntryKind.Event,
|
||||
Event = historianEvent,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace AVEVA.Historian.Client.StoreForward;
|
||||
|
||||
/// <summary>
|
||||
/// Discriminates the kind of buffered write an <see cref="HistorianOutboxEntry"/> carries.
|
||||
/// </summary>
|
||||
public enum HistorianOutboxEntryKind
|
||||
{
|
||||
/// <summary>Historical (backfill) values for a single tag — replays via
|
||||
/// <c>HistorianClient.AddHistoricalValuesAsync</c>.</summary>
|
||||
HistoricalValues = 1,
|
||||
|
||||
/// <summary>A single historian event — replays via <c>HistorianClient.SendEventAsync</c>.</summary>
|
||||
Event = 2,
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace AVEVA.Historian.Client.StoreForward;
|
||||
|
||||
/// <summary>The outcome of one <see cref="HistorianStoreForwardWriter.FlushAsync"/> pass.</summary>
|
||||
public sealed record HistorianStoreForwardFlushResult
|
||||
{
|
||||
/// <summary>Entries successfully forwarded to the historian in this pass.</summary>
|
||||
public required int Forwarded { get; init; }
|
||||
|
||||
/// <summary>Entries still buffered after this pass.</summary>
|
||||
public required int Remaining { get; init; }
|
||||
|
||||
/// <summary>True when the outbox is fully drained (<see cref="Remaining"/> == 0).</summary>
|
||||
public bool Drained => Remaining == 0;
|
||||
|
||||
/// <summary>The delivery error that halted the pass, if any.</summary>
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
namespace AVEVA.Historian.Client.StoreForward;
|
||||
|
||||
/// <summary>
|
||||
/// Tuning for <see cref="HistorianStoreForwardWriter"/>.
|
||||
/// </summary>
|
||||
public sealed record HistorianStoreForwardOptions
|
||||
{
|
||||
/// <summary>How many entries to pull and attempt per drain pass. Default 64.</summary>
|
||||
public int DrainBatchSize { get; init; } = 64;
|
||||
|
||||
/// <summary>
|
||||
/// How often the background loop retries a non-empty, failing outbox. Default 10s. Only used when
|
||||
/// <see cref="RunBackgroundDrain"/> is enabled.
|
||||
/// </summary>
|
||||
public TimeSpan RetryInterval { get; init; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>
|
||||
/// When true, <see cref="HistorianStoreForwardWriter.StartAsync"/> spins a background loop that
|
||||
/// drains the outbox on every enqueue and retries on <see cref="RetryInterval"/>. When false the
|
||||
/// caller drives delivery explicitly with <see cref="HistorianStoreForwardWriter.FlushAsync"/>.
|
||||
/// Default true.
|
||||
/// </summary>
|
||||
public bool RunBackgroundDrain { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum entries to retain. When exceeded on enqueue the configured
|
||||
/// <see cref="OverflowPolicy"/> applies. 0 (default) means unbounded.
|
||||
/// </summary>
|
||||
public int MaxQueuedEntries { get; init; }
|
||||
|
||||
/// <summary>What to do when <see cref="MaxQueuedEntries"/> is reached. Default
|
||||
/// <see cref="HistorianOutboxOverflowPolicy.DropOldest"/>.</summary>
|
||||
public HistorianOutboxOverflowPolicy OverflowPolicy { get; init; } = HistorianOutboxOverflowPolicy.DropOldest;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum delivery attempts before an entry is dropped (dead-lettered) to stop a poison message
|
||||
/// from blocking the FIFO queue forever. 0 (default) means retry indefinitely — the safe
|
||||
/// store-forward default (never lose data), at the cost of head-of-line blocking on a permanently
|
||||
/// rejected entry.
|
||||
/// </summary>
|
||||
public int MaxDeliveryAttempts { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Policy applied when the outbox reaches <see cref="HistorianStoreForwardOptions.MaxQueuedEntries"/>.</summary>
|
||||
public enum HistorianOutboxOverflowPolicy
|
||||
{
|
||||
/// <summary>Evict the oldest buffered entry to make room for the new one.</summary>
|
||||
DropOldest = 0,
|
||||
|
||||
/// <summary>Reject the new enqueue with an <see cref="InvalidOperationException"/>.</summary>
|
||||
Reject = 1,
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace AVEVA.Historian.Client.StoreForward;
|
||||
|
||||
/// <summary>
|
||||
/// A point-in-time view of the store-forward outbox. The client-side analog of the server's
|
||||
/// <c>HistorianStoreForwardStatus</c>: <see cref="Pending"/>/<see cref="Storing"/> mirror its
|
||||
/// "buffer has data / actively buffering" semantics, and <see cref="ErrorOccurred"/>/<see cref="Error"/>
|
||||
/// surface the last delivery failure.
|
||||
/// </summary>
|
||||
public sealed record HistorianStoreForwardStatusSnapshot
|
||||
{
|
||||
/// <summary>Entries currently buffered and not yet forwarded.</summary>
|
||||
public required int PendingCount { get; init; }
|
||||
|
||||
/// <summary>True when any entry is buffered (the forwarder has undelivered data).</summary>
|
||||
public bool Pending => PendingCount > 0;
|
||||
|
||||
/// <summary>
|
||||
/// True when the writer is buffering because delivery is not succeeding — i.e. there is pending
|
||||
/// data and the most recent drain attempt failed. False while the queue drains cleanly.
|
||||
/// </summary>
|
||||
public required bool Storing { get; init; }
|
||||
|
||||
/// <summary>True when the most recent delivery attempt failed.</summary>
|
||||
public required bool ErrorOccurred { get; init; }
|
||||
|
||||
/// <summary>The most recent delivery error message, if any.</summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>When the last entry was successfully forwarded (UTC), or null.</summary>
|
||||
public DateTime? LastForwardedUtc { get; init; }
|
||||
|
||||
/// <summary>When the last drain attempt ran (UTC), or null.</summary>
|
||||
public DateTime? LastAttemptUtc { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
using AVEVA.Historian.Client.Models;
|
||||
|
||||
namespace AVEVA.Historian.Client.StoreForward;
|
||||
|
||||
/// <summary>
|
||||
/// A pragmatic client-side store-and-forward layer over the historian write surface (R4.1). Producers
|
||||
/// enqueue historical values and events; the writer persists them to an
|
||||
/// <see cref="IHistorianOutboxStore"/> and forwards them to the historian via an
|
||||
/// <see cref="IHistorianWriteSink"/>, retrying on failure so a transient disconnect never drops data.
|
||||
/// <para>
|
||||
/// This is deliberately <em>not</em> a bit-faithful reimplementation of AVEVA's native SF cache
|
||||
/// (see <c>docs/plans/hcal-roadmap.md</c> R4.1) — it is a durable outbox + replay loop. Drain is
|
||||
/// FIFO; a failing entry blocks the head of the queue (and is retried) unless
|
||||
/// <see cref="HistorianStoreForwardOptions.MaxDeliveryAttempts"/> dead-letters it.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class HistorianStoreForwardWriter : IAsyncDisposable
|
||||
{
|
||||
private readonly IHistorianWriteSink _sink;
|
||||
private readonly IHistorianOutboxStore _store;
|
||||
private readonly HistorianStoreForwardOptions _options;
|
||||
|
||||
private readonly SemaphoreSlim _flushGate = new(1, 1);
|
||||
private readonly SemaphoreSlim _enqueueGate = new(1, 1);
|
||||
private readonly SemaphoreSlim _wake = new(0, 1);
|
||||
private readonly Lock _statusLock = new();
|
||||
|
||||
private CancellationTokenSource? _loopCts;
|
||||
private Task? _loopTask;
|
||||
|
||||
private string? _lastError;
|
||||
private bool _errorOccurred;
|
||||
private DateTime? _lastForwardedUtc;
|
||||
private DateTime? _lastAttemptUtc;
|
||||
|
||||
public HistorianStoreForwardWriter(IHistorianWriteSink sink, IHistorianOutboxStore store, HistorianStoreForwardOptions? options = null)
|
||||
{
|
||||
_sink = sink ?? throw new ArgumentNullException(nameof(sink));
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_options = options ?? new HistorianStoreForwardOptions();
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(_options.DrainBatchSize);
|
||||
}
|
||||
|
||||
/// <summary>Convenience constructor that forwards through a <see cref="HistorianClient"/>.</summary>
|
||||
public HistorianStoreForwardWriter(HistorianClient client, IHistorianOutboxStore store, HistorianStoreForwardOptions? options = null)
|
||||
: this(new HistorianClientWriteSink(client), store, options)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Buffers a batch of historical values for <paramref name="tag"/> and (if running) wakes the
|
||||
/// drain loop. Returns the buffered entry's id.
|
||||
/// </summary>
|
||||
public async Task<Guid> EnqueueHistoricalValuesAsync(string tag, IReadOnlyList<HistorianHistoricalValue> values, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
|
||||
ArgumentNullException.ThrowIfNull(values);
|
||||
if (values.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one value is required.", nameof(values));
|
||||
}
|
||||
|
||||
return await EnqueueAsync(HistorianOutboxEntry.ForHistoricalValues(tag, values, DateTime.UtcNow), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Buffers a single event and (if running) wakes the drain loop. Returns the buffered entry's id.</summary>
|
||||
public async Task<Guid> EnqueueEventAsync(HistorianEvent historianEvent, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(historianEvent);
|
||||
return await EnqueueAsync(HistorianOutboxEntry.ForEvent(historianEvent, DateTime.UtcNow), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<Guid> EnqueueAsync(HistorianOutboxEntry entry, CancellationToken cancellationToken)
|
||||
{
|
||||
await _enqueueGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await ApplyOverflowPolicyAsync(cancellationToken).ConfigureAwait(false);
|
||||
HistorianOutboxEntry stored = await _store.EnqueueAsync(entry, cancellationToken).ConfigureAwait(false);
|
||||
WakeLoop();
|
||||
return stored.Id;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_enqueueGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ApplyOverflowPolicyAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_options.MaxQueuedEntries <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int count = await _store.CountAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (count < _options.MaxQueuedEntries)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_options.OverflowPolicy == HistorianOutboxOverflowPolicy.Reject)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Store-forward outbox is full ({_options.MaxQueuedEntries} entries) and the overflow policy is Reject.");
|
||||
}
|
||||
|
||||
// DropOldest: evict from the head until there is room for the new entry.
|
||||
while (count >= _options.MaxQueuedEntries)
|
||||
{
|
||||
IReadOnlyList<HistorianOutboxEntry> oldest = await _store.PeekBatchAsync(1, cancellationToken).ConfigureAwait(false);
|
||||
if (oldest.Count == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
await _store.RemoveAsync(oldest[0].Id, cancellationToken).ConfigureAwait(false);
|
||||
count--;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to forward buffered entries to the historian in FIFO order, stopping at the first
|
||||
/// entry that cannot be delivered (it stays buffered for the next pass). Safe to call concurrently
|
||||
/// with the background loop — passes are serialized. Returns a summary of the pass.
|
||||
/// </summary>
|
||||
public async Task<HistorianStoreForwardFlushResult> FlushAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _flushGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
int forwarded = 0;
|
||||
string? haltError = null;
|
||||
SetLastAttempt(DateTime.UtcNow);
|
||||
|
||||
bool halted = false;
|
||||
while (!halted)
|
||||
{
|
||||
IReadOnlyList<HistorianOutboxEntry> batch = await _store.PeekBatchAsync(_options.DrainBatchSize, cancellationToken).ConfigureAwait(false);
|
||||
if (batch.Count == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
foreach (HistorianOutboxEntry entry in batch)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
(bool delivered, string? error) = await TryDeliverAsync(entry, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (delivered)
|
||||
{
|
||||
await _store.RemoveAsync(entry.Id, cancellationToken).ConfigureAwait(false);
|
||||
forwarded++;
|
||||
RecordSuccess(DateTime.UtcNow);
|
||||
continue;
|
||||
}
|
||||
|
||||
int attempts = entry.AttemptCount + 1;
|
||||
RecordFailure(error);
|
||||
haltError = error;
|
||||
|
||||
if (_options.MaxDeliveryAttempts > 0 && attempts >= _options.MaxDeliveryAttempts)
|
||||
{
|
||||
// Dead-letter the poison entry so the FIFO queue can keep moving.
|
||||
await _store.RemoveAsync(entry.Id, cancellationToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
await _store.UpdateAsync(entry with { AttemptCount = attempts, LastError = error }, cancellationToken).ConfigureAwait(false);
|
||||
halted = true; // head-of-line: stop this pass, retry later
|
||||
break;
|
||||
}
|
||||
|
||||
if (batch.Count < _options.DrainBatchSize)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
int remaining = await _store.CountAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (remaining == 0)
|
||||
{
|
||||
ClearError();
|
||||
}
|
||||
|
||||
return new HistorianStoreForwardFlushResult
|
||||
{
|
||||
Forwarded = forwarded,
|
||||
Remaining = remaining,
|
||||
Error = haltError,
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
_flushGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(bool Delivered, string? Error)> TryDeliverAsync(HistorianOutboxEntry entry, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
bool ok = entry.Kind switch
|
||||
{
|
||||
HistorianOutboxEntryKind.HistoricalValues =>
|
||||
await _sink.SendHistoricalValuesAsync(entry.Tag!, entry.Values!, cancellationToken).ConfigureAwait(false),
|
||||
HistorianOutboxEntryKind.Event =>
|
||||
await _sink.SendEventAsync(entry.Event!, cancellationToken).ConfigureAwait(false),
|
||||
_ => throw new InvalidOperationException($"Unknown outbox entry kind '{entry.Kind}'."),
|
||||
};
|
||||
|
||||
return ok
|
||||
? (true, null)
|
||||
: (false, "Historian did not accept the write (the sink returned false).");
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A point-in-time snapshot of the outbox state.</summary>
|
||||
public async Task<HistorianStoreForwardStatusSnapshot> GetStatusAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
int pending = await _store.CountAsync(cancellationToken).ConfigureAwait(false);
|
||||
lock (_statusLock)
|
||||
{
|
||||
return new HistorianStoreForwardStatusSnapshot
|
||||
{
|
||||
PendingCount = pending,
|
||||
Storing = pending > 0 && _errorOccurred,
|
||||
ErrorOccurred = _errorOccurred,
|
||||
Error = _lastError,
|
||||
LastForwardedUtc = _lastForwardedUtc,
|
||||
LastAttemptUtc = _lastAttemptUtc,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The number of entries currently buffered.</summary>
|
||||
public Task<int> GetPendingCountAsync(CancellationToken cancellationToken = default) => _store.CountAsync(cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Starts the background drain loop (no-op when <see cref="HistorianStoreForwardOptions.RunBackgroundDrain"/>
|
||||
/// is false, or already started). The loop drains on every enqueue and retries a failing,
|
||||
/// non-empty outbox every <see cref="HistorianStoreForwardOptions.RetryInterval"/>.
|
||||
/// </summary>
|
||||
public Task StartAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_options.RunBackgroundDrain || _loopTask is not null)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
_loopCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
_loopTask = Task.Run(() => RunLoopAsync(_loopCts.Token), CancellationToken.None);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Stops the background drain loop (if running). Buffered entries are left intact.</summary>
|
||||
public async Task StopAsync()
|
||||
{
|
||||
if (_loopCts is null || _loopTask is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _loopCts.CancelAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await _loopTask.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loopCts.Dispose();
|
||||
_loopCts = null;
|
||||
_loopTask = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Delivery failures are already recorded in status; keep the loop alive.
|
||||
}
|
||||
|
||||
int pending;
|
||||
try
|
||||
{
|
||||
pending = await _store.CountAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch
|
||||
{
|
||||
pending = 1;
|
||||
}
|
||||
|
||||
// Sleep until the next enqueue wakes us; when entries are stuck, cap the wait at the retry
|
||||
// interval so a recovered server gets retried without a new enqueue.
|
||||
TimeSpan wait = pending > 0 ? _options.RetryInterval : Timeout.InfiniteTimeSpan;
|
||||
try
|
||||
{
|
||||
await _wake.WaitAsync(wait, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void WakeLoop()
|
||||
{
|
||||
try
|
||||
{
|
||||
_wake.Release();
|
||||
}
|
||||
catch (SemaphoreFullException)
|
||||
{
|
||||
// already signalled
|
||||
}
|
||||
}
|
||||
|
||||
private void SetLastAttempt(DateTime utc)
|
||||
{
|
||||
lock (_statusLock)
|
||||
{
|
||||
_lastAttemptUtc = utc;
|
||||
}
|
||||
}
|
||||
|
||||
private void RecordSuccess(DateTime utc)
|
||||
{
|
||||
lock (_statusLock)
|
||||
{
|
||||
_lastForwardedUtc = utc;
|
||||
_errorOccurred = false;
|
||||
_lastError = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void RecordFailure(string? error)
|
||||
{
|
||||
lock (_statusLock)
|
||||
{
|
||||
_errorOccurred = true;
|
||||
_lastError = error;
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearError()
|
||||
{
|
||||
lock (_statusLock)
|
||||
{
|
||||
_errorOccurred = false;
|
||||
_lastError = null;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await StopAsync().ConfigureAwait(false);
|
||||
_flushGate.Dispose();
|
||||
_enqueueGate.Dispose();
|
||||
_wake.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace AVEVA.Historian.Client.StoreForward;
|
||||
|
||||
/// <summary>
|
||||
/// Durable (or in-memory) backing store for the store-and-forward outbox. Implementations must be
|
||||
/// safe for concurrent use by a single forwarder loop plus producer enqueues. Drain order is FIFO by
|
||||
/// <see cref="HistorianOutboxEntry.Sequence"/>.
|
||||
/// </summary>
|
||||
public interface IHistorianOutboxStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Persists <paramref name="entry"/>, assigning it a fresh monotonic
|
||||
/// <see cref="HistorianOutboxEntry.Sequence"/>. Returns the stored entry (with sequence set).
|
||||
/// </summary>
|
||||
Task<HistorianOutboxEntry> EnqueueAsync(HistorianOutboxEntry entry, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns up to <paramref name="maxCount"/> oldest entries in FIFO order without removing them.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<HistorianOutboxEntry>> PeekBatchAsync(int maxCount, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Overwrites the persisted copy of <paramref name="entry"/> (e.g. updated retry metadata).</summary>
|
||||
Task UpdateAsync(HistorianOutboxEntry entry, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Removes the entry with <paramref name="id"/> after it has been forwarded.</summary>
|
||||
Task RemoveAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>The number of entries currently buffered.</summary>
|
||||
Task<int> CountAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using AVEVA.Historian.Client.Models;
|
||||
|
||||
namespace AVEVA.Historian.Client.StoreForward;
|
||||
|
||||
/// <summary>
|
||||
/// The actual delivery target the forwarder replays buffered writes through. Abstracted from
|
||||
/// <see cref="HistorianClient"/> so the store-forward logic can be unit-tested without a server, and
|
||||
/// so callers can plug a custom delivery path.
|
||||
/// <para>
|
||||
/// Contract: return <c>true</c> when the historian accepted the write; return <c>false</c> or throw
|
||||
/// when it did not. The forwarder treats both a <c>false</c> return and a thrown exception as "not
|
||||
/// delivered" and keeps the entry buffered for retry — so a transient disconnect (which throws) and
|
||||
/// a soft rejection (which returns false) are both safe.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface IHistorianWriteSink
|
||||
{
|
||||
/// <summary>Delivers a batch of historical values for <paramref name="tag"/>.</summary>
|
||||
Task<bool> SendHistoricalValuesAsync(string tag, IReadOnlyList<HistorianHistoricalValue> values, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Delivers a single event.</summary>
|
||||
Task<bool> SendEventAsync(HistorianEvent historianEvent, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace AVEVA.Historian.Client.StoreForward;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory <see cref="IHistorianOutboxStore"/> — buffers are lost on process exit. Useful for
|
||||
/// tests and for callers that only need in-flight resilience (transient disconnects) rather than
|
||||
/// crash-durability. Use <see cref="FileHistorianOutboxStore"/> for durability across restarts.
|
||||
/// </summary>
|
||||
public sealed class InMemoryHistorianOutboxStore : IHistorianOutboxStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<long, HistorianOutboxEntry> _entries = new();
|
||||
private long _sequence;
|
||||
|
||||
public Task<HistorianOutboxEntry> EnqueueAsync(HistorianOutboxEntry entry, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
long sequence = Interlocked.Increment(ref _sequence);
|
||||
HistorianOutboxEntry stored = entry with { Sequence = sequence };
|
||||
_entries[sequence] = stored;
|
||||
return Task.FromResult(stored);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<HistorianOutboxEntry>> PeekBatchAsync(int maxCount, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxCount);
|
||||
IReadOnlyList<HistorianOutboxEntry> batch = _entries.Values
|
||||
.OrderBy(e => e.Sequence)
|
||||
.Take(maxCount)
|
||||
.ToList();
|
||||
return Task.FromResult(batch);
|
||||
}
|
||||
|
||||
public Task UpdateAsync(HistorianOutboxEntry entry, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
// Only refresh if still present (it may have been removed concurrently after forwarding).
|
||||
if (_entries.ContainsKey(entry.Sequence))
|
||||
{
|
||||
_entries[entry.Sequence] = entry;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task RemoveAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
foreach (KeyValuePair<long, HistorianOutboxEntry> kvp in _entries)
|
||||
{
|
||||
if (kvp.Value.Id == id)
|
||||
{
|
||||
_entries.TryRemove(kvp.Key, out _);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<int> CountAsync(CancellationToken cancellationToken = default) => Task.FromResult(_entries.Count);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AVEVA.Historian.Client.StoreForward;
|
||||
|
||||
/// <summary>
|
||||
/// Shared JSON settings for persisting <see cref="HistorianOutboxEntry"/> envelopes. The custom
|
||||
/// <see cref="ObjectConverter"/> normalizes <c>HistorianEvent.Properties</c> values (typed
|
||||
/// <see cref="object"/>) back to plain CLR scalars on read instead of leaving raw
|
||||
/// <see cref="JsonElement"/>s, so a rehydrated event matches what the producer enqueued.
|
||||
/// </summary>
|
||||
internal static class OutboxJson
|
||||
{
|
||||
public static readonly JsonSerializerOptions Options = CreateOptions();
|
||||
|
||||
private static JsonSerializerOptions CreateOptions()
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
options.Converters.Add(new ObjectConverter());
|
||||
return options;
|
||||
}
|
||||
|
||||
private sealed class ObjectConverter : JsonConverter<object?>
|
||||
{
|
||||
public override object? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
switch (reader.TokenType)
|
||||
{
|
||||
case JsonTokenType.Null:
|
||||
return null;
|
||||
case JsonTokenType.String:
|
||||
return reader.GetString();
|
||||
case JsonTokenType.True:
|
||||
return true;
|
||||
case JsonTokenType.False:
|
||||
return false;
|
||||
case JsonTokenType.Number:
|
||||
return reader.TryGetInt64(out long l) ? l : reader.GetDouble();
|
||||
default:
|
||||
using (JsonDocument doc = JsonDocument.ParseValue(ref reader))
|
||||
{
|
||||
return doc.RootElement.GetRawText();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, object? value, JsonSerializerOptions options)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
writer.WriteNullValue();
|
||||
return;
|
||||
}
|
||||
|
||||
// Serialize by runtime type so scalars use their own converters (no re-entry into this one).
|
||||
JsonSerializer.Serialize(writer, value, value.GetType(), options);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,14 @@ internal interface IStatusServiceContract2 : IStatusServiceContract
|
||||
[MessageParameter(Name = "pResponseBuff")] out byte[] responseBuffer,
|
||||
out byte[] errorBuffer);
|
||||
|
||||
[OperationContract(Name = "GETRP")]
|
||||
[return: MarshalAs(UnmanagedType.U1)]
|
||||
bool GetRuntimeParameter(
|
||||
string handle,
|
||||
[MessageParameter(Name = "pRequestBuff")] byte[] requestBuffer,
|
||||
[MessageParameter(Name = "pResponseBuff")] out byte[] responseBuffer,
|
||||
out byte[] errorBuffer);
|
||||
|
||||
[OperationContract(Name = "PNGS")]
|
||||
[return: MarshalAs(UnmanagedType.U1)]
|
||||
bool PingServer(string handle, string pipeName, uint timeout, ref byte[] errorBuffer);
|
||||
|
||||
@@ -44,6 +44,40 @@ internal static class HistorianAddTagsProtocol
|
||||
/// </remarks>
|
||||
public static readonly Guid CommonArchestraEventTypeId = new("5f59ae42-3bb6-4760-91a5-ab0be01f9f02");
|
||||
|
||||
/// <summary>
|
||||
/// The CM_EVENT event-type GUID used by the 2023 R2 <b>gRPC</b> EnsureTags (captured ending
|
||||
/// <c>…e0 1f 2f 27</c>) — distinct from the 2020 WCF capture's <see cref="CommonArchestraEventTypeId"/>
|
||||
/// (<c>…9f02</c>).
|
||||
/// </summary>
|
||||
public static readonly Guid CommonArchestraEventTypeIdGrpc = new("5f59ae42-3bb6-4760-91a5-ab0be01f2f27");
|
||||
|
||||
/// <summary>
|
||||
/// Builds the native 2023 R2 <b>gRPC</b> CM_EVENT <c>EnsureTags.tagInfos</c> buffer (86 bytes,
|
||||
/// captured byte-for-byte). Differs from the 2020 WCF <see cref="SerializeCmEventCTagMetadata"/>:
|
||||
/// it is wrapped in an 8-byte EnsureTags header (<c>4E 67 03 00 01 00 00 00</c>), uses the
|
||||
/// <see cref="CommonArchestraEventTypeIdGrpc"/> event-type GUID, and has no trailing bytes after it.
|
||||
/// Used by the gRPC event registration so the server actually establishes CM_EVENT and the event
|
||||
/// query returns rows.
|
||||
/// </summary>
|
||||
public static byte[] SerializeCmEventEnsureTagsGrpc(DateTime createdUtc)
|
||||
{
|
||||
using MemoryStream stream = new();
|
||||
using BinaryWriter writer = new(stream, Encoding.Unicode, leaveOpen: true);
|
||||
|
||||
writer.Write(new byte[] { 0x4E, 0x67, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00 }); // EnsureTags header (count 1)
|
||||
writer.Write((byte)3);
|
||||
writer.Write((ushort)0x0086);
|
||||
writer.Write((byte)5);
|
||||
writer.Write(CmEventTagId.ToByteArray());
|
||||
WriteCompressedHistorianString(writer, "CM_EVENT");
|
||||
WriteCompressedHistorianString(writer, "AnE Event");
|
||||
writer.Write(new byte[] { 0x02, 0x02, 0x01, 0x00, 0x00, 0x00, 0x01 });
|
||||
writer.Write(0u);
|
||||
writer.Write(createdUtc.ToUniversalTime().ToFileTimeUtc());
|
||||
writer.Write(CommonArchestraEventTypeIdGrpc.ToByteArray());
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
public static byte[] SerializeCmEventCTagMetadata(DateTime createdUtc)
|
||||
{
|
||||
using MemoryStream stream = new();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using AVEVA.Historian.Client.Models;
|
||||
|
||||
namespace AVEVA.Historian.Client.Wcf;
|
||||
|
||||
@@ -7,40 +8,119 @@ internal static class HistorianEventQueryProtocol
|
||||
{
|
||||
public const ushort QueryRequestTypeEvent = 3;
|
||||
|
||||
public static IReadOnlyList<HistorianEventQueryAttempt> CreateStartEventQueryAttempts(DateTime startUtc, DateTime endUtc, uint eventCount)
|
||||
/// <summary>
|
||||
/// Builds the <c>StartEventQuery</c> <c>pRequestBuff</c>. <paramref name="version"/> selects the
|
||||
/// envelope revision: <b>5</b> (default) is the native 2020 WCF format used by the WCF event
|
||||
/// orchestrator; <b>6</b> is the 2023 R2 gRPC format. The two envelopes are byte-identical except
|
||||
/// the leading version word and a 5-byte trailing zero pad — captured 2026-06-22 from the stock
|
||||
/// 2023 R2 client (see <c>docs/reverse-engineering/grpc-event-query-capture.md</c>). The 2023 R2
|
||||
/// server returns rows only for v6; v5 is accepted (StartEventQuery succeeds) but matches no rows.
|
||||
/// The filter block in the middle is unchanged across versions.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<HistorianEventQueryAttempt> CreateStartEventQueryAttempts(
|
||||
DateTime startUtc, DateTime endUtc, uint eventCount, HistorianEventFilter? filter = null, ushort version = 5)
|
||||
{
|
||||
List<HistorianEventQueryAttempt> attempts = [];
|
||||
attempts.Add(CreateNativeEmptyFilterAttempt(startUtc, endUtc, eventCount));
|
||||
attempts.Add(CreateNativeFilterAttempt(startUtc, endUtc, eventCount, filter, version));
|
||||
|
||||
return attempts;
|
||||
}
|
||||
|
||||
private static HistorianEventQueryAttempt CreateNativeEmptyFilterAttempt(DateTime startUtc, DateTime endUtc, uint eventCount)
|
||||
private static HistorianEventQueryAttempt CreateNativeFilterAttempt(
|
||||
DateTime startUtc, DateTime endUtc, uint eventCount, HistorianEventFilter? filter, ushort version)
|
||||
{
|
||||
using MemoryStream stream = new();
|
||||
using BinaryWriter writer = new(stream, Encoding.Unicode, leaveOpen: true);
|
||||
|
||||
writer.Write((ushort)5);
|
||||
writer.Write(version);
|
||||
writer.Write(startUtc.ToFileTimeUtc());
|
||||
writer.Write(endUtc.ToFileTimeUtc());
|
||||
writer.Write(eventCount);
|
||||
writer.Write(0u);
|
||||
writer.Write((ushort)0);
|
||||
writer.Write((ushort)1);
|
||||
WriteNativeEmptyFilterBlock(writer);
|
||||
if (filter is null)
|
||||
{
|
||||
WriteNativeEmptyFilterBlock(writer);
|
||||
}
|
||||
else
|
||||
{
|
||||
WriteFilterBlock(writer, filter);
|
||||
}
|
||||
writer.Write(65_536u);
|
||||
WriteHistorianString(writer, "UTC");
|
||||
WriteMetadataNamespace(writer);
|
||||
writer.Write(0u);
|
||||
|
||||
// Version 6 (2023 R2 gRPC) appends a 5-byte trailing zero pad after the v5 terminal — the only
|
||||
// envelope delta from v5 besides the version word. Captured live: the v6 buffer is the v5 buffer
|
||||
// (byte 0 = 6) plus these 5 bytes, and is the form the 2023 R2 server returns event rows for.
|
||||
if (version >= 6)
|
||||
{
|
||||
writer.Write(0u);
|
||||
writer.Write((byte)0);
|
||||
}
|
||||
|
||||
byte[] request = stream.ToArray();
|
||||
return new HistorianEventQueryAttempt(
|
||||
"native-empty-filter-version5",
|
||||
5,
|
||||
filter is null ? $"native-empty-filter-version{version}" : $"native-filter-version{version}",
|
||||
version,
|
||||
request,
|
||||
Convert.ToHexString(SHA256.HashData(request)).ToLowerInvariant());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filter block carrying one predicate (property / comparison / value), decoded byte-for-byte
|
||||
/// from instrument-wcf-writemessage captures of <c>EventQuery.AddEventFilter</c> (Equal vs
|
||||
/// Contains diffed to isolate the operator field). Layout:
|
||||
/// <code>
|
||||
/// UInt16 0 // header word (also written by the empty-filter block)
|
||||
/// UInt32 filterCount = 1
|
||||
/// UInt32 conditionCount = 1
|
||||
/// UInt32 propertyNameCharCount
|
||||
/// propertyName (UTF-16LE)
|
||||
/// UInt32 1 // constant (per-condition marker; observed = 1)
|
||||
/// UInt16 comparison // HistorianComparisionType ordinal
|
||||
/// UInt32 1 // constant (value marker; observed = 1)
|
||||
/// value: 0x09 LEN 0x00 LEN×ASCII // compact-ASCII string (same as event/CTagMetadata)
|
||||
/// Byte 0 // block terminator
|
||||
/// </code>
|
||||
/// Single-predicate only — multi-filter (OR) / multi-condition (AND) framing is not yet
|
||||
/// fully captured. String values only; the value is always emitted (even for Exists).
|
||||
/// </summary>
|
||||
private static void WriteFilterBlock(BinaryWriter writer, HistorianEventFilter filter)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(filter.PropertyName);
|
||||
ArgumentNullException.ThrowIfNull(filter.Value);
|
||||
|
||||
writer.Write((ushort)0);
|
||||
writer.Write(1u); // filterCount
|
||||
writer.Write(1u); // conditionCount
|
||||
writer.Write((uint)filter.PropertyName.Length); // char count
|
||||
writer.Write(Encoding.Unicode.GetBytes(filter.PropertyName));
|
||||
writer.Write(1u); // constant marker
|
||||
writer.Write((ushort)filter.Comparison);
|
||||
writer.Write(1u); // constant marker
|
||||
WriteCompactAsciiString(writer, filter.Value);
|
||||
writer.Write((byte)0); // block terminator
|
||||
}
|
||||
|
||||
/// <summary>Compact ASCII string: <c>0x09 LEN 0x00 LEN×ASCII bytes</c>.</summary>
|
||||
private static void WriteCompactAsciiString(BinaryWriter writer, string value)
|
||||
{
|
||||
byte[] ascii = Encoding.ASCII.GetBytes(value);
|
||||
if (ascii.Length > byte.MaxValue)
|
||||
{
|
||||
throw new ProtocolEvidenceMissingException(
|
||||
$"Event filter value '{value}' exceeds the single-byte length captured for the compact-string encoding.");
|
||||
}
|
||||
|
||||
writer.Write((byte)0x09);
|
||||
writer.Write((byte)ascii.Length);
|
||||
writer.Write((byte)0);
|
||||
writer.Write(ascii);
|
||||
}
|
||||
|
||||
private static HistorianEventQueryAttempt CreateAttempt(
|
||||
string shape,
|
||||
ushort version,
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace AVEVA.Historian.Client.Wcf;
|
||||
|
||||
/// <summary>
|
||||
/// Captured byte buffers for the native CM_EVENT registration sequence that both the WCF and gRPC
|
||||
/// event orchestrators replay before <c>StartEventQuery</c>. Extracted to a single source of truth so
|
||||
/// the two transports cannot drift on these reverse-engineered constants. The bytes are captured
|
||||
/// byte-for-byte from a successful native event read via the instrument-wcf-{write,read}message
|
||||
/// IL-rewrite tool (see <see cref="HistorianWcfEventOrchestrator"/> remarks for record references).
|
||||
/// </summary>
|
||||
internal static class HistorianEventRegistrationProtocol
|
||||
{
|
||||
/// <summary>
|
||||
/// Documented native CM_EVENT default tag id used by aahClientManaged.dll
|
||||
/// CreateDefaultEventTag → ConvertEventTagToTagMetadata. Registering this tag (RegisterTags2 /
|
||||
/// HistoryService.RegisterTags) before StartEventQuery subscribes the session to CM_EVENT
|
||||
/// events; without it, GetNextEventQueryResultBuffer returns native error type=4 code=85 (0x55).
|
||||
/// </summary>
|
||||
public static readonly Guid CmEventTagId = new("353b8145-5df0-4d46-a253-871aef49b321");
|
||||
|
||||
/// <summary>
|
||||
/// The 6 system-parameter names the native client queries (records 11-16) between UpdC3 and
|
||||
/// RTag2. They appear informational, but are replayed to put the server-side session into the
|
||||
/// state EnsT2 expects.
|
||||
/// </summary>
|
||||
public static readonly string[] StatusParametersBeforeRegister =
|
||||
[
|
||||
"AllowOriginals",
|
||||
"HistorianPartner",
|
||||
"HistorianVersion",
|
||||
"MaxCyclicStorageTimeout",
|
||||
"RealTimeWindow",
|
||||
"FutureTimeThreshold",
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Native GETHI pRequestBuff layout for a parameter-name query: 8-byte header
|
||||
/// (UInt16 0x6753 + UInt16 0x0002 + UInt32 nameLength) + UTF-16 LE chars (no trailing null byte —
|
||||
/// observed truncated by 1 byte vs full UTF-16 in the captured native bytes). Layout taken from
|
||||
/// writemessage-capture-event-latest.ndjson record 8.
|
||||
/// </summary>
|
||||
public static byte[] BuildGetHistorianInfoRequest(string parameterName)
|
||||
{
|
||||
byte[] nameBytes = Encoding.Unicode.GetBytes(parameterName);
|
||||
// Native truncates the trailing high byte of the last UTF-16 char.
|
||||
int payloadLength = nameBytes.Length > 0 ? nameBytes.Length - 1 : 0;
|
||||
byte[] buffer = new byte[8 + payloadLength];
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(0, 2), 0x6753);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(2, 2), 0x0002);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(4, 4), (uint)parameterName.Length);
|
||||
Buffer.BlockCopy(nameBytes, 0, buffer, 8, payloadLength);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 81-byte UpdC3 clientStatus blob captured from a native event read (record 10 of
|
||||
/// writemessage-capture-event-latest.ndjson). Layout: 0x02 0x01 + 76 zero bytes +
|
||||
/// uint32(0x0000001E). The trailing 30 is likely an interval / timeout in seconds; all other
|
||||
/// observed fields are zero for a fresh session.
|
||||
/// </summary>
|
||||
public static byte[] BuildUpdateClientStatusBlob()
|
||||
{
|
||||
byte[] blob = new byte[81];
|
||||
blob[0] = 0x02;
|
||||
blob[1] = 0x01;
|
||||
blob[77] = 0x1E;
|
||||
return blob;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 24-byte RTag2 pInBuff captured from a native event read (record 17). Layout: 8-byte header
|
||||
/// (0x50 0x67 0x02 0x00 + uint32 element count = 1) + 16-byte tag id GUID.
|
||||
/// </summary>
|
||||
public static byte[] BuildRegisterCmEventInputBuffer()
|
||||
{
|
||||
byte[] buffer = new byte[24];
|
||||
buffer[0] = 0x50;
|
||||
buffer[1] = 0x67;
|
||||
buffer[2] = 0x02;
|
||||
buffer[3] = 0x00;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(4, 4), 1u);
|
||||
CmEventTagId.ToByteArray().CopyTo(buffer.AsSpan(8, 16));
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes a native 5-byte error/terminal buffer: byte0 = type, bytes 1-4 = LE uint32 code.
|
||||
/// </summary>
|
||||
public static string DescribeNativeError(byte[] errorBuffer)
|
||||
{
|
||||
if (errorBuffer.Length < 5)
|
||||
{
|
||||
return "<short>";
|
||||
}
|
||||
|
||||
byte type = errorBuffer[0];
|
||||
uint code = BinaryPrimitives.ReadUInt32LittleEndian(errorBuffer.AsSpan(1, 4));
|
||||
return $"type={type} code={code} (0x{code:X})";
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,10 @@ namespace AVEVA.Historian.Client.Wcf;
|
||||
/// native event read (instrument-wcf-readmessage record 24, two rows for Alarm.Set + Alarm.Clear):
|
||||
///
|
||||
/// <code>
|
||||
/// UInt16 version = 9
|
||||
/// UInt16 version = 9 (WCF) | 11 (2023 R2 gRPC)
|
||||
/// UInt32 rowCount
|
||||
/// UInt32 headerField = 0x1E // ONE buffer-level field (NOT a per-row marker)
|
||||
/// rowCount × Row {
|
||||
/// UInt32 rowMarker = 0x1E
|
||||
/// UInt16 rowFormat = 7
|
||||
/// Int64 eventTimeUtcFiletime
|
||||
/// UInt16 × 8 // purpose unclear (slot offsets?)
|
||||
@@ -42,10 +42,23 @@ namespace AVEVA.Historian.Client.Wcf;
|
||||
internal static class HistorianEventRowProtocol
|
||||
{
|
||||
public const ushort EventRowProtocolVersion = 9;
|
||||
public const uint RowMarker = 0x0000001Eu;
|
||||
public const ushort RowFormatV9 = 7;
|
||||
private const int HeaderSize = 6;
|
||||
private const int RowFixedHeaderSize = 4 + 2 + 8 + 16;
|
||||
|
||||
/// <summary>
|
||||
/// 2023 R2 gRPC returns the event-row buffer with container version <c>11</c> instead of the
|
||||
/// 2020 WCF <c>9</c>. The row layout is otherwise <b>byte-identical</b> (verified against a captured
|
||||
/// stock-client read: header <c>0B00 <rowCount> 1E000000</c> then markerless rows, 50
|
||||
/// Alarm.Set/Alarm.Clear rows decoded clean to end-of-buffer; the WCF v9 capture has the same
|
||||
/// <c>0900 <rowCount> 1E000000</c> header). Accept both, exactly as the interface-version gate
|
||||
/// accepts History 11 and 12.
|
||||
/// </summary>
|
||||
public const ushort EventRowProtocolVersionGrpc = 11;
|
||||
|
||||
/// <summary>Constant buffer-level field following <c>rowCount</c> (observed <c>0x1E</c>). NOT a
|
||||
/// per-row marker — it appears exactly once, before the first row.</summary>
|
||||
public const uint BufferHeaderField = 0x0000001Eu;
|
||||
public const ushort RowFormat = 7;
|
||||
private const int BufferHeaderSize = 2 + 4 + 4; // version + rowCount + headerField
|
||||
private const int RowFixedHeaderSize = 2 + 8 + 16; // rowFormat + filetime + 8×UInt16 slots
|
||||
|
||||
private const byte ValueTypeBool = 0x02;
|
||||
private const byte ValueTypeGuid = 0x10;
|
||||
@@ -55,25 +68,26 @@ internal static class HistorianEventRowProtocol
|
||||
|
||||
public static IReadOnlyList<HistorianEvent> Parse(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
if (buffer.Length < HeaderSize)
|
||||
if (buffer.Length < 6)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
ushort version = BinaryPrimitives.ReadUInt16LittleEndian(buffer[..2]);
|
||||
if (version != EventRowProtocolVersion)
|
||||
if (version != EventRowProtocolVersion && version != EventRowProtocolVersionGrpc)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
uint rowCount = BinaryPrimitives.ReadUInt32LittleEndian(buffer.Slice(2, 4));
|
||||
if (rowCount == 0)
|
||||
if (rowCount == 0 || buffer.Length < BufferHeaderSize)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
List<HistorianEvent> events = new(checked((int)rowCount));
|
||||
int cursor = HeaderSize;
|
||||
// Skip the single buffer-level header field (0x1E); rows follow with NO per-row marker.
|
||||
int cursor = BufferHeaderSize;
|
||||
for (uint rowIndex = 0; rowIndex < rowCount; rowIndex++)
|
||||
{
|
||||
if (!TryReadRow(buffer, ref cursor, out HistorianEvent? row))
|
||||
@@ -95,19 +109,13 @@ internal static class HistorianEventRowProtocol
|
||||
return false;
|
||||
}
|
||||
|
||||
uint marker = BinaryPrimitives.ReadUInt32LittleEndian(buffer.Slice(cursor, 4));
|
||||
if (marker != RowMarker)
|
||||
ushort format = BinaryPrimitives.ReadUInt16LittleEndian(buffer.Slice(cursor, 2));
|
||||
if (format != RowFormat)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ushort format = BinaryPrimitives.ReadUInt16LittleEndian(buffer.Slice(cursor + 4, 2));
|
||||
if (format != RowFormatV9)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
long filetime = BinaryPrimitives.ReadInt64LittleEndian(buffer.Slice(cursor + 6, 8));
|
||||
long filetime = BinaryPrimitives.ReadInt64LittleEndian(buffer.Slice(cursor + 2, 8));
|
||||
DateTime eventTimeUtc = DateTime.FromFileTimeUtc(filetime);
|
||||
int afterFixedHeader = cursor + RowFixedHeaderSize;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user