Compare commits
56 Commits
9a25fa4ef7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f0a1b04b34 | |||
| 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 |
@@ -18,9 +18,10 @@ 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):
|
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`
|
- `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 all five analog types `EnsureTagAsync` does — **Float, Double, Int2, Int4, UInt4** (all captured live + golden-tested + write/read-back validated). The 4-byte value descriptor is constant (`C0 10 01 00`); the value is `u32(0) + native-width value` (float32 / double64 / int16 / int32 / uint32) selected by the tag's declared type. Other tag types throw `ProtocolEvidenceMissingException`. 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".
|
- `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` (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.)
|
`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.)
|
||||||
|
|
||||||
|
|||||||
@@ -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.**
|
||||||
@@ -333,6 +333,170 @@ unfixable by, client payload matching.** Closing it needs server-side insight or
|
|||||||
hard wall — pure managed, golden-tested, auth live-verified. Orchestrator stays on the no-row throw;
|
hard wall — pure managed, golden-tested, auth live-verified. Orchestrator stays on the no-row throw;
|
||||||
gated test unchanged.
|
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
|
**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
|
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
|
orchestrator stays on v6 (set `eventConnection: true` to re-arm once the token construction lands). The
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# AVEVA Historian Managed Driver Handoff
|
# AVEVA Historian Managed Driver Handoff
|
||||||
|
|
||||||
Last updated: 2026-06-22 (roadmap exhausted — no actionable pure-code tasks remain)
|
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
|
> **Current status supersedes the historical blocker narrative below.** The
|
||||||
> sections from "Active Blocker" onward are a preserved reverse-engineering
|
> sections from "Active Blocker" onward are a preserved reverse-engineering
|
||||||
@@ -38,52 +38,157 @@ reuses the proven 2020 WCF byte serializers/parsers unchanged inside protobuf
|
|||||||
|
|
||||||
**Everything still open is gated — none is a pure-code task:**
|
**Everything still open is gated — none is a pure-code task:**
|
||||||
|
|
||||||
1. **gRPC event ROW retrieval** (`ReadEventsAsync` #2) — **CAPTURED + DIAGNOSED
|
1. **gRPC event ROW retrieval** (`ReadEventsAsync` #2) — **AUTH-SOLVED / PARSE-VERIFIED /
|
||||||
2026-06-22 (merged `8ad160b`); remaining gate is now a scoped RE+impl effort,
|
RETRIEVAL-SERVER-GATED (every client-side angle exhausted 2026-06-23, merged `6faf8a5`).**
|
||||||
not a capture.** The `capture-event` harness scenario drove the stock 2023 R2
|
The v8 OpenConnection crypto wall is fully cracked + live-verified: the event connection
|
||||||
client over an Event-type gRPC connection and captured it reading **50 events**
|
authenticates via **`HistoryService.ExchangeKey` (P-256 ECDH) → client key =
|
||||||
(the live server is event-bearing — SQL ground truth via the INSQL linked
|
`SHA256(shared secret)` → credential token = `RC4(password-UTF16LE, key=MD5(client key))`**
|
||||||
server: `Runtime.dbo.Events` = 19,356 rows/30d, 90,944/365d). Two gaps vs the
|
(the native `HistorianCrypto.NRC4_V2.aahCryptV2` MD5-keyed RC4 scheme). RE'd via Frida CNG
|
||||||
SDK: **(a)** the working `StartEventQuery` request is **version 6** (byte 0 =
|
hooks + dnlib IL extraction + an offline cracker; implemented pure-managed, golden-tested,
|
||||||
`06` + a 5-byte trailing pad), the SDK sent v5 — **SHIPPED**
|
auth live-PASSES. The `StartEventQuery` v6 request and the Event-type v8 `OpenConnection`
|
||||||
(`HistorianEventQueryProtocol` `version` param, default 5 = WCF; the gRPC
|
(`ConnectionType=Event`) are shipped. **BUT** the query still returns **rowCount-0** while
|
||||||
orchestrator passes 6; golden-tested). **(b) the real gate:** rows flow only on
|
the native returns 50 for a byte-identical request — and **all four next-session angles are
|
||||||
an **Event-type connection**. The native `OpenConnection.openParameters` is
|
now tested and ruled out** (`grpc-event-query-capture.md`):
|
||||||
format **v8** with a `ConnectionType` byte (Event `01` / Process `02`) right
|
- **transport** — the stock client is *also* gRPC-Web/HTTP-1.1 (decompiled); plain native
|
||||||
after `ClientType`; the SDK's **v6** Open2 buffer has no such field (it writes
|
HTTP/2 (`CreateHttp2`) returns the same 0 rows;
|
||||||
`ClientType` then `ConnectionMode` back-to-back), so the read-capable v6
|
- **client metadata/cert** — decompiled + TLS-tee captured: gzip-only metadata, no-op
|
||||||
connection cannot be marked as Event. `ConnectionMode` is not the lever (2020
|
interceptor, **no TLS client cert** on either side;
|
||||||
WCF events work at `0x402`). Making event rows flow needs the SDK to emit the
|
- **connection topology** — the native splits services across 5 connections and queries on
|
||||||
native v8 `OpenConnection` with `ConnectionType=Event` (and likely the
|
a dedicated RetrievalService connection; replicating that (`HISTORIAN_GRPC_EVENT_SPLIT_CHANNEL=1`)
|
||||||
`ExchangeKey` cert auth path) — a substantial follow-on. Full evidence +
|
still returns 0 rows → the server correlates by **session handle, not connection**;
|
||||||
byte-level diagnosis: `docs/reverse-engineering/grpc-event-query-capture.md`.
|
- **data store** — via SOCKS→SQL: the event store is global/unscoped (no per-connection
|
||||||
Gated test still pins the no-row throw; bounded ≤30s.
|
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
|
2. **R4.3 active-SF magnitude** — needs an **SF-active server** (D2 storage-engine
|
||||||
console handle).
|
console handle).
|
||||||
3. **SendEvent over gRPC** — **capture-gated**: no distinct RPC, framing uncaptured.
|
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`;
|
4. **ExecuteSqlCommand over gRPC** — **server-walled** (`CSrvDbConnection`;
|
||||||
RegisterTags prime doesn't help). Use WCF for SQL.
|
RegisterTags prime doesn't help). Use WCF for SQL.
|
||||||
5. **R4.2 revision EDITS** — storage-engine-pipe-only on BOTH transports (the D2 wall).
|
5. **R4.2 revision EDITS** — storage-engine-pipe-only on BOTH transports (the D2 wall).
|
||||||
6. **ReadBlocks** (`StartBlockRetrievalQuery`) — never captured on either transport.
|
6. **ReadBlocks** (`StartBlockRetrievalQuery`) — never captured on either transport.
|
||||||
7. **DeleteTagExtendedProperties** — server-blocked on BOTH transports. The gRPC
|
7. **DeleteTagExtendedProperties** — server-blocked; **confirmed walled by capturing the NATIVE
|
||||||
multiplexed-channel hypothesis was **PROBED + DISPROVEN 2026-06-22** (merge
|
client 2026-06-23.** The agent's "use `deleteFromServer=true`" angle is moot: the native
|
||||||
`c88260c`): GetTgByNm + GetTepByNm primes succeed on one shared write-enabled
|
`HistorianAccess.DeleteTagExtendedPropertiesByName(...,deleteFromServer:true)`, driven with the
|
||||||
gRPC channel, yet DelTep is still rejected (native code=1) and the property
|
cross-session sync trick that gets it past the client-side err-229 sync gate
|
||||||
survives — the working set is native in-process registration state, not the
|
(`Capture-DeleteTagExtendedProperties.ps1`), returns **`Success=true` / ErrorCode=Success** —
|
||||||
wire session. Pinned by gated negative test
|
yet across repeated fresh sessions the property is **re-fetchable and re-deletable every time**,
|
||||||
`DeleteTagExtendedProperties_OverGrpc_ProbeMultiplexedChannel`.
|
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,
|
8. **Deferred-by-design** items (`write-commands` D1–D3, non-analog tag create,
|
||||||
etc.) — bounded out until an explicit customer/user demand signal.
|
etc.) — bounded out until an explicit customer/user demand signal.
|
||||||
|
|
||||||
To move any remaining item you need a **scoped RE+impl effort** (the v8
|
To move any remaining item you need a **server-side / connection-level angle**
|
||||||
Event-type `OpenConnection` — item 1, already captured + diagnosed), a **fresh
|
(item 1 — v8 event auth is solved; row retrieval is connection-gated, see the
|
||||||
native capture** (SendEvent gRPC framing — item 3), a **different server**
|
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.
|
(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
|
Live-server gRPC probe recipe: set
|
||||||
`HISTORIAN_GRPC_HOST`/`_PORT 32565`/`_TLS true`/`_DNSID` + domain creds (strip
|
`HISTORIAN_GRPC_HOST`/`_PORT 32565`/`_TLS true`/`_DNSID` + domain creds (strip
|
||||||
quotes — `reference_wonder_sql_vd03_credentials`) and run the gated
|
quotes — `reference_wonder_sql_vd03_credentials`) and run the gated
|
||||||
`HistorianGrpcIntegrationTests`.
|
`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
|
## Project Direction
|
||||||
|
|
||||||
The project goal is still a fully managed .NET 10 C# AVEVA Historian client.
|
The project goal is still a fully managed .NET 10 C# AVEVA Historian client.
|
||||||
@@ -155,12 +260,14 @@ dotnet build .\Histsdk.slnx --no-restore
|
|||||||
dotnet test .\Histsdk.slnx --no-build --logger "console;verbosity=minimal"
|
dotnet test .\Histsdk.slnx --no-build --logger "console;verbosity=minimal"
|
||||||
```
|
```
|
||||||
|
|
||||||
Current known-good result (2026-06-22):
|
Current known-good result (2026-06-23):
|
||||||
|
|
||||||
- Build succeeds (0 warnings / 0 errors).
|
- Build succeeds (0 warnings / 0 errors).
|
||||||
- Offline tests pass: 321/321 (live gRPC/integration tests skip cleanly without
|
- Offline tests pass: 328/328 (live gRPC/integration tests skip cleanly without
|
||||||
their env vars). Gated live tests add to this when `HISTORIAN_*` /
|
their env vars). Gated live tests add to this when `HISTORIAN_*` /
|
||||||
`HISTORIAN_GRPC_*` are set.
|
`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
|
The workspace is a Git working tree (origin: gitea.dohertylan.com). Use
|
||||||
normal git workflow for change tracking; the prior "no working tree, use
|
normal git workflow for change tracking; the prior "no working tree, use
|
||||||
@@ -1038,27 +1145,35 @@ record 5 of `instrumented-wcf-readmessage/readmessage-capture-event-latest.ndjso
|
|||||||
|
|
||||||
### Event-row parser
|
### Event-row parser
|
||||||
|
|
||||||
`Wcf/HistorianEventRowProtocol.Parse(ReadOnlySpan<byte>)` parses the
|
> **CORRECTED 2026-06-23 (merged `6faf8a5`).** The skeleton below mis-read the `0x1E`
|
||||||
version-9 row buffer:
|
> 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
|
```text
|
||||||
UInt16 version = 9
|
UInt16 version = 9 (WCF) | 11 (gRPC)
|
||||||
UInt32 rowCount
|
UInt32 rowCount
|
||||||
N rows, each:
|
UInt32 headerField = 0x1E // ONE buffer-level field, NOT a per-row marker
|
||||||
UInt32 rowMarker = 0x1E
|
N rows, each (MARKERLESS):
|
||||||
UInt16 rowFormat = 7
|
UInt16 rowFormat = 7
|
||||||
Int64 filetimeUtc (event time)
|
Int64 filetimeUtc (event time)
|
||||||
UInt16 × 8 fieldOffsets (opaque — purpose not fully decoded)
|
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
|
The parser reads the 10-byte buffer header (skipping the `0x1E` field once), then walks
|
||||||
in the property bag) for each row, and seeks forward to the next row by
|
each markerless row by length: `rowFormat(2) + filetime(8) + 8×UInt16 slots + compact-ASCII
|
||||||
scanning for the next `1E 00 00 00 07 00` marker. Property-bag value
|
type + propertyCount + propertyCount × (name + value)`. Value encoding **is** implemented
|
||||||
encoding is partially decoded (compact ASCII `09 LEN 00 …`, UTF-16 strings
|
(compact ASCII `09 LEN 00 …`, Boolean `0x02`, GUID `0x10`, FILETIME `0x18`, Int32 `0x31`,
|
||||||
`43 UInt32 LEN × UInt16`, integers with markers in the `0x88–0x8B` range,
|
UTF-16 `0x43`; unknown markers preserve raw bytes). Verified against the provided client's
|
||||||
8-byte FILETIMEs) but **value parsing is intentionally not implemented yet**
|
real buffer: `Parse_RealStockClientCapture_DecodesAllEvents` decodes all 50 events (25
|
||||||
— it requires more reverse-engineering and would need sanitized fixtures.
|
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,
|
5 unit tests in `HistorianEventRowProtocolTests.cs` cover empty buffer,
|
||||||
zero-row, wrong-version, two-row synthetic, and missing-marker. Test count
|
zero-row, wrong-version, two-row synthetic, and missing-marker. Test count
|
||||||
@@ -1156,10 +1271,12 @@ Typemarker dispatch:
|
|||||||
Unknown markers preserve the raw `length` value bytes as a `byte[]` in
|
Unknown markers preserve the raw `length` value bytes as a `byte[]` in
|
||||||
the property dictionary.
|
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
|
```text
|
||||||
UInt32 rowMarker = 0x1E
|
buffer header: UInt16 version (9|11) + UInt32 rowCount + UInt32 headerField (0x1E)
|
||||||
|
each row (markerless):
|
||||||
UInt16 rowFormat = 7
|
UInt16 rowFormat = 7
|
||||||
Int64 eventTimeUtcFiletime
|
Int64 eventTimeUtcFiletime
|
||||||
UInt16 × 8 // purpose unclear
|
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,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).
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Net.Http;
|
||||||
using System.Net.Security;
|
using System.Net.Security;
|
||||||
using System.Security.Cryptography.X509Certificates;
|
using System.Security.Cryptography.X509Certificates;
|
||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
@@ -63,6 +64,50 @@ internal static class HistorianGrpcChannelFactory
|
|||||||
|
|
||||||
GrpcChannel channel = GrpcChannel.ForAddress(address, channelOptions);
|
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
|
// The stock client always advertises gzip request encoding; honour the option so
|
||||||
// bandwidth-limited links can disable it.
|
// bandwidth-limited links can disable it.
|
||||||
var metadata = new Metadata();
|
var metadata = new Metadata();
|
||||||
@@ -71,7 +116,7 @@ internal static class HistorianGrpcChannelFactory
|
|||||||
metadata.Add("grpc-internal-encoding-request", "gzip");
|
metadata.Add("grpc-internal-encoding-request", "gzip");
|
||||||
}
|
}
|
||||||
|
|
||||||
return new HistorianGrpcConnection(channel, metadata);
|
return metadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool AcceptAnyCertificate(
|
private static bool AcceptAnyCertificate(
|
||||||
|
|||||||
@@ -34,13 +34,16 @@ namespace AVEVA.Historian.Client.Grpc;
|
|||||||
/// (the read path proved the front-door session is sufficient over gRPC).
|
/// (the read path proved the front-door session is sufficient over gRPC).
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Live status (2026-06-22):</b> the chain runs end-to-end and <c>StartEventQuery</c> succeeds, but
|
/// <b>Live status — server-gated (settled 2026-06-25):</b> the chain runs end-to-end and
|
||||||
/// <c>GetNextEventQueryResultBuffer</c> <b>long-polls</b> when the query has no rows — it blocks to the
|
/// <c>StartEventQuery</c> succeeds, but <c>GetNextEventQueryResultBuffer</c> <b>long-polls</b> to the
|
||||||
/// call deadline instead of returning the synchronous 5-byte code-85 terminal the 2020 WCF op returns.
|
/// no-data terminal (instead of the synchronous 5-byte code-85 terminal the 2020 WCF op returns); a
|
||||||
/// A poll-deadline expiry is therefore treated as the no-data terminal (see the loop). The idle dev box
|
/// poll-deadline expiry is treated as that terminal (see the loop). This is <b>not</b> an empty-box
|
||||||
/// holds no events, so <b>row-level retrieval is not yet live-verified</b>; verifying parsed rows over
|
/// artifact: the live 2023 R2 server holds tens of thousands of events yet scopes <b>0 rows</b> to a
|
||||||
/// gRPC awaits an event-bearing 2023 R2 server. This is tooled + completes cleanly, NOT proven to
|
/// managed connection. Every client-controllable layer was byte-matched to the stock client that returns
|
||||||
/// return rows.
|
/// 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>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class HistorianGrpcEventOrchestrator
|
internal sealed class HistorianGrpcEventOrchestrator
|
||||||
@@ -58,6 +61,9 @@ internal sealed class HistorianGrpcEventOrchestrator
|
|||||||
/// <summary>Diagnostic: type+code description of the most recent error/terminal buffer.</summary>
|
/// <summary>Diagnostic: type+code description of the most recent error/terminal buffer.</summary>
|
||||||
public string LastErrorBufferDescription { get; private set; } = string.Empty;
|
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>
|
/// <summary>Diagnostic: hex of the most recent result buffer (first 48 bytes).</summary>
|
||||||
public string LastResultBufferHex { get; private set; } = string.Empty;
|
public string LastResultBufferHex { get; private set; } = string.Empty;
|
||||||
|
|
||||||
@@ -98,9 +104,12 @@ internal sealed class HistorianGrpcEventOrchestrator
|
|||||||
{
|
{
|
||||||
throw new ProtocolEvidenceMissingException(
|
throw new ProtocolEvidenceMissingException(
|
||||||
$"ReadEvents over gRPC did not return rows within {OverallBudget.TotalSeconds:0}s: StartEventQuery " +
|
$"ReadEvents over gRPC did not return rows within {OverallBudget.TotalSeconds:0}s: StartEventQuery " +
|
||||||
"succeeds but the CM_EVENT registration replay stalls and GetNextEventQueryResultBuffer long-polls " +
|
"succeeds but GetNextEventQueryResultBuffer long-polls to the no-data terminal. Event-row retrieval is " +
|
||||||
"(no synchronous code-85 terminal over gRPC). Row-level retrieval is not yet verified over gRPC " +
|
"auth-solved but SERVER-GATED on 2023 R2 over both transports — the server scopes 0 rows to a managed " +
|
||||||
"(the dev box holds no events) — use the WCF transport for event reads.");
|
"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)
|
foreach (HistorianEvent evt in events)
|
||||||
@@ -143,7 +152,16 @@ internal sealed class HistorianGrpcEventOrchestrator
|
|||||||
|
|
||||||
private List<HistorianEvent> RunEventChain(DateTime startUtc, DateTime endUtc, HistorianEventFilter? filter, CancellationToken cancellationToken)
|
private List<HistorianEvent> RunEventChain(DateTime startUtc, DateTime endUtc, HistorianEventFilter? filter, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
|
// 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
|
// 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
|
// 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)).
|
// OpenConnection with ConnectionType=Event and the credential token RC4(password, MD5(clientKey)).
|
||||||
@@ -151,22 +169,27 @@ internal sealed class HistorianGrpcEventOrchestrator
|
|||||||
|
|
||||||
RegisterCmEventTag(connection, session, cancellationToken);
|
RegisterCmEventTag(connection, session, cancellationToken);
|
||||||
|
|
||||||
List<HistorianEvent> events = RunEventQuery(connection, session, startUtc, endUtc, filter, 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
|
// 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
|
// 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"
|
// 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
|
// 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 unverified state instead. An event-bearing 2023 R2
|
// a possibly-false empty list and surface the gated state instead. Proven server-gated: the live
|
||||||
// server will return rows here and exercise the parse path; flip this once that is captured.
|
// 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)
|
if (events.Count == 0)
|
||||||
{
|
{
|
||||||
throw new ProtocolEvidenceMissingException(
|
throw new ProtocolEvidenceMissingException(
|
||||||
"ReadEvents over gRPC: the chain completes and StartEventQuery succeeds, but " +
|
"ReadEvents over gRPC: the chain completes and StartEventQuery succeeds, but " +
|
||||||
"GetNextEventQueryResultBuffer returns no rows (it long-polls to the no-data terminal " +
|
"GetNextEventQueryResultBuffer returns no rows (it long-polls to the no-data terminal " +
|
||||||
$"after the CM_EVENT registration replay; last={LastErrorBufferDescription}). Row-level " +
|
$"after the CM_EVENT registration replay; last={LastErrorBufferDescription}). Event-row retrieval is " +
|
||||||
"retrieval is not yet verified over gRPC (the dev box holds no events) — use the WCF " +
|
"auth-solved but SERVER-GATED on 2023 R2 over both transports — the server scopes 0 rows to a managed " +
|
||||||
"transport for event reads until a capture against an event-bearing 2023 R2 server confirms it.");
|
"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;
|
return events;
|
||||||
@@ -261,7 +284,15 @@ internal sealed class HistorianGrpcEventOrchestrator
|
|||||||
/// <summary>Diagnostic: outcomes of the key CM_EVENT registration RPCs.</summary>
|
/// <summary>Diagnostic: outcomes of the key CM_EVENT registration RPCs.</summary>
|
||||||
public string RegistrationDiag { get; private set; } = string.Empty;
|
public string RegistrationDiag { get; private set; } = string.Empty;
|
||||||
|
|
||||||
private List<HistorianEvent> RunEventQuery(
|
// 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,
|
HistorianGrpcConnection connection,
|
||||||
HistorianGrpcHandshake.Session session,
|
HistorianGrpcHandshake.Session session,
|
||||||
DateTime startUtc,
|
DateTime startUtc,
|
||||||
@@ -269,9 +300,21 @@ internal sealed class HistorianGrpcEventOrchestrator
|
|||||||
HistorianEventFilter? filter,
|
HistorianEventFilter? filter,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel);
|
// 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(
|
GrpcRetrieval.GetRetrievalInterfaceVersionResponse retrievalVersion = retrievalClient.GetRetrievalInterfaceVersion(
|
||||||
new GrpcRetrieval.GetRetrievalInterfaceVersionRequest(), connection.Metadata, Deadline(), cancellationToken);
|
new GrpcRetrieval.GetRetrievalInterfaceVersionRequest(), rconn.Metadata, Deadline(), cancellationToken);
|
||||||
HistorianServerVersionGate.Validate(HistorianServiceInterface.Retrieval, retrievalVersion.UiVersion, _options);
|
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
|
// Version 6 envelope: the stock 2023 R2 client sends v6 (the WCF path's v5 request is accepted
|
||||||
@@ -294,7 +337,7 @@ internal sealed class HistorianGrpcEventOrchestrator
|
|||||||
UiQueryRequestType = HistorianEventQueryProtocol.QueryRequestTypeEvent,
|
UiQueryRequestType = HistorianEventQueryProtocol.QueryRequestTypeEvent,
|
||||||
BtRequest = ByteString.CopyFrom(requestBuffer)
|
BtRequest = ByteString.CopyFrom(requestBuffer)
|
||||||
},
|
},
|
||||||
connection.Metadata,
|
rconn.Metadata,
|
||||||
Deadline(),
|
Deadline(),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
@@ -319,7 +362,7 @@ internal sealed class HistorianGrpcEventOrchestrator
|
|||||||
{
|
{
|
||||||
nextResponse = retrievalClient.GetNextEventQueryResultBuffer(
|
nextResponse = retrievalClient.GetNextEventQueryResultBuffer(
|
||||||
new GrpcRetrieval.GetNextEventQueryResultBufferRequest { UiHandle = session.ClientHandle, UiQueryHandle = queryHandle },
|
new GrpcRetrieval.GetNextEventQueryResultBufferRequest { UiHandle = session.ClientHandle, UiQueryHandle = queryHandle },
|
||||||
connection.Metadata,
|
rconn.Metadata,
|
||||||
EventPollDeadline(),
|
EventPollDeadline(),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
}
|
}
|
||||||
@@ -372,7 +415,12 @@ internal sealed class HistorianGrpcEventOrchestrator
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
EndEventQuerySafely(retrievalClient, connection, session.ClientHandle, queryHandle);
|
EndEventQuerySafely(retrievalClient, rconn, session.ClientHandle, queryHandle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (splitChannel) { rconn.Dispose(); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}; "; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,6 +47,24 @@ internal sealed class HistorianGrpcHistoricalWriteOrchestrator
|
|||||||
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(
|
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(
|
||||||
connection, _options, cancellationToken,
|
connection, _options, cancellationToken,
|
||||||
connectionMode: HistorianWcfAuthChainHelper.NativeIntegratedWriteEnabledConnectionMode);
|
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;
|
string handle = session.StringHandle;
|
||||||
DateTime Deadline() => DateTime.UtcNow.Add(_options.RequestTimeout);
|
DateTime Deadline() => DateTime.UtcNow.Add(_options.RequestTimeout);
|
||||||
|
|
||||||
|
|||||||
@@ -102,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)
|
private List<HistorianSample> RunRawChain(string tag, DateTime startUtc, DateTime endUtc, int maxValues, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
|
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
|
||||||
uint clientHandle = OpenAuthenticatedConnection(connection, cancellationToken);
|
uint clientHandle = OpenAuthenticatedConnection(connection, cancellationToken);
|
||||||
HistorianDataQueryRequest request = HistorianWcfReadOrchestrator.BuildDataQueryRequest(tag, startUtc, endUtc, maxValues);
|
return RunRawQueryOnSession(connection, clientHandle, tag, startUtc, endUtc, maxValues, cancellationToken);
|
||||||
return RunQuery(connection, clientHandle, request, 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(
|
private List<HistorianAggregateSample> RunAggregateChain(
|
||||||
@@ -115,23 +148,29 @@ internal sealed class HistorianGrpcReadOrchestrator
|
|||||||
{
|
{
|
||||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
|
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
|
||||||
uint clientHandle = OpenAuthenticatedConnection(connection, cancellationToken);
|
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)
|
if (timestampsUtc.Count == 0)
|
||||||
{
|
{
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
|
|
||||||
uint clientHandle = OpenAuthenticatedConnection(connection, cancellationToken);
|
|
||||||
|
|
||||||
List<HistorianSample> results = new(timestampsUtc.Count);
|
List<HistorianSample> results = new(timestampsUtc.Count);
|
||||||
foreach (DateTime ts in timestampsUtc)
|
foreach (DateTime ts in timestampsUtc)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
DateTime tsUtc = ts.ToUniversalTime();
|
DateTime tsUtc = ts.ToUniversalTime();
|
||||||
List<HistorianAggregateSample> aggregates = RunAggregateQuery(
|
List<HistorianAggregateSample> aggregates = RunAggregateQuery(
|
||||||
connection,
|
connection,
|
||||||
@@ -141,7 +180,7 @@ internal sealed class HistorianGrpcReadOrchestrator
|
|||||||
tsUtc + TimeSpan.FromTicks(1),
|
tsUtc + TimeSpan.FromTicks(1),
|
||||||
RetrievalMode.Interpolated,
|
RetrievalMode.Interpolated,
|
||||||
TimeSpan.FromTicks(2),
|
TimeSpan.FromTicks(2),
|
||||||
cancellationToken);
|
ct);
|
||||||
|
|
||||||
if (aggregates.Count == 0)
|
if (aggregates.Count == 0)
|
||||||
{
|
{
|
||||||
@@ -163,6 +202,18 @@ internal sealed class HistorianGrpcReadOrchestrator
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<HistorianSample> RunAtTimeChain(string tag, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (timestampsUtc.Count == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
private uint OpenAuthenticatedConnection(HistorianGrpcConnection connection, CancellationToken cancellationToken)
|
||||||
=> HistorianGrpcHandshake.OpenAuthenticatedConnection(connection, _options, cancellationToken);
|
=> HistorianGrpcHandshake.OpenAuthenticatedConnection(connection, _options, cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,18 @@ namespace AVEVA.Historian.Client.Grpc;
|
|||||||
/// write-enabled session and calls <c>AddNonStreamValuesBegin</c>, surfacing whatever the server
|
/// 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>
|
/// returns. It writes NO data — if Begin succeeds it immediately calls <c>AddNonStreamValuesEnd</c>
|
||||||
/// with <c>bCommit=false</c> to discard the transaction.
|
/// 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>
|
/// </summary>
|
||||||
internal sealed class HistorianGrpcRevisionProbe
|
internal sealed class HistorianGrpcRevisionProbe
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -28,7 +28,19 @@ internal static class HistorianGrpcStatusClient
|
|||||||
{
|
{
|
||||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
||||||
uint clientHandle = HistorianGrpcHandshake.OpenAuthenticatedConnection(connection, options, cancellationToken);
|
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);
|
var statusClient = new GrpcStatus.StatusService.StatusServiceClient(connection.Channel);
|
||||||
GrpcStatus.GetSystemParameterResponse response = statusClient.GetSystemParameter(
|
GrpcStatus.GetSystemParameterResponse response = statusClient.GetSystemParameter(
|
||||||
new GrpcStatus.GetSystemParameterRequest { UiHandle = clientHandle, StrParameterName = parameterName },
|
new GrpcStatus.GetSystemParameterRequest { UiHandle = clientHandle, StrParameterName = parameterName },
|
||||||
@@ -82,23 +94,7 @@ internal static class HistorianGrpcStatusClient
|
|||||||
{
|
{
|
||||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
||||||
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, cancellationToken);
|
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, cancellationToken);
|
||||||
|
return GetStoreForwardStatusOnSession(connection, session.StringHandle, options, NotStoring, cancellationToken);
|
||||||
var statusClient = new GrpcStatus.StatusService.StatusServiceClient(connection.Channel);
|
|
||||||
GrpcStatus.GetHistorianConsoleStatusResponse response = statusClient.GetHistorianConsoleStatus(
|
|
||||||
new GrpcStatus.GetHistorianConsoleStatusRequest { StrHandle = session.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(errorOccurred: false, error: null);
|
|
||||||
}
|
|
||||||
|
|
||||||
byte[] err = response.Status?.BtError?.ToByteArray() ?? [];
|
|
||||||
string detail = err.Length == 0 ? "GetHistorianConsoleStatus returned failure." : Convert.ToHexString(err);
|
|
||||||
return NotStoring(errorOccurred: true, error: $"GetHistorianConsoleStatus failed: {detail}");
|
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
@@ -111,6 +107,36 @@ internal static class HistorianGrpcStatusClient
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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>
|
/// <summary>
|
||||||
/// Returns a <em>measured</em> connection status over the 2023 R2 gRPC transport (plan #5). Mirrors
|
/// 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
|
/// <see cref="Wcf.HistorianWcfStatusClient"/>'s synthesize-from-handshake approach: it opens an
|
||||||
@@ -135,11 +161,7 @@ internal static class HistorianGrpcStatusClient
|
|||||||
// A successful OpenConnection yields a non-empty storage-session GUID — proof the server and
|
// 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.
|
// its storage session are reachable, the gRPC analog of the WCF handshake probe.
|
||||||
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, cancellationToken);
|
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, cancellationToken);
|
||||||
connected = session.StorageSessionId != Guid.Empty;
|
(connected, error) = EvaluateConnectionStatusOnSession(connection, session);
|
||||||
if (!connected)
|
|
||||||
{
|
|
||||||
error = "OpenConnection returned an empty storage-session handle.";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
@@ -162,6 +184,21 @@ internal static class HistorianGrpcStatusClient
|
|||||||
ConnectionKind: HistorianConnectionKind.Process);
|
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>
|
/// <summary>
|
||||||
/// Reads the Historian server's system time-zone name (roadmap item R1.3,
|
/// 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>StatusService.GetSystemTimeZoneName</c>). Unlike the 2020 WCF surface — where the native
|
||||||
|
|||||||
@@ -39,6 +39,27 @@ internal static class HistorianGrpcTagClient
|
|||||||
private static HistorianTagMetadata? GetTagMetadata(HistorianClientOptions options, string tag, CancellationToken cancellationToken)
|
private static HistorianTagMetadata? GetTagMetadata(HistorianClientOptions options, string tag, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
byte[] tagInfos = GetTagInfosRaw(options, [tag], 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)
|
if (tagInfos.Length < 4)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
@@ -69,7 +90,19 @@ internal static class HistorianGrpcTagClient
|
|||||||
{
|
{
|
||||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
||||||
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, cancellationToken);
|
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);
|
var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel);
|
||||||
byte[] requestBuffer = BuildTagNamesBuffer(tags);
|
byte[] requestBuffer = BuildTagNamesBuffer(tags);
|
||||||
GrpcRetrieval.GetTagInfosFromNameResponse response = retrievalClient.GetTagInfosFromName(
|
GrpcRetrieval.GetTagInfosFromNameResponse response = retrievalClient.GetTagInfosFromName(
|
||||||
@@ -112,6 +145,8 @@ internal static class HistorianGrpcTagClient
|
|||||||
return Task.Run(() => GetTagExtendedProperties(options, tag, cancellationToken), cancellationToken);
|
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>
|
/// <summary>
|
||||||
/// Issues a single page-0 <c>GetTagExtendedPropertiesFromName</c> call and returns the raw native
|
/// 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
|
/// <c>btTeps</c> response buffer (empty when the server reports no rows / non-success). Internal so
|
||||||
@@ -222,6 +257,20 @@ internal static class HistorianGrpcTagClient
|
|||||||
{
|
{
|
||||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
||||||
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, cancellationToken);
|
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);
|
var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel);
|
||||||
DateTime Deadline() => DateTime.UtcNow.Add(options.RequestTimeout);
|
DateTime Deadline() => DateTime.UtcNow.Add(options.RequestTimeout);
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,18 @@ internal sealed class HistorianGrpcTagWriteOrchestrator
|
|||||||
{
|
{
|
||||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
|
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
|
||||||
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, _options, cancellationToken, WriteEnabledConnectionMode);
|
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(
|
byte[] payload = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
|
||||||
tagName: definition.TagName,
|
tagName: definition.TagName,
|
||||||
description: definition.Description,
|
description: definition.Description,
|
||||||
@@ -97,7 +108,18 @@ internal sealed class HistorianGrpcTagWriteOrchestrator
|
|||||||
{
|
{
|
||||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
|
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
|
||||||
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, _options, cancellationToken, WriteEnabledConnectionMode);
|
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.
|
// DeleteTags takes the transient uint client handle (not the string handle), per the WCF wire capture.
|
||||||
byte[] tagNames = HistorianTagWriteProtocol.SerializeDeleteTagNames([tagName]);
|
byte[] tagNames = HistorianTagWriteProtocol.SerializeDeleteTagNames([tagName]);
|
||||||
var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel);
|
var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel);
|
||||||
@@ -131,7 +153,20 @@ internal sealed class HistorianGrpcTagWriteOrchestrator
|
|||||||
{
|
{
|
||||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
|
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
|
||||||
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, _options, cancellationToken, WriteEnabledConnectionMode);
|
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);
|
byte[] inBuff = HistorianTagExtendedPropertyProtocol.SerializeAddRequest(tagName, properties);
|
||||||
var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel);
|
var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel);
|
||||||
GrpcHistory.AddTagExtendedPropertiesResponse response = historyClient.AddTagExtendedProperties(
|
GrpcHistory.AddTagExtendedPropertiesResponse response = historyClient.AddTagExtendedProperties(
|
||||||
@@ -275,7 +310,18 @@ internal sealed class HistorianGrpcTagWriteOrchestrator
|
|||||||
{
|
{
|
||||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
|
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
|
||||||
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, _options, cancellationToken, WriteEnabledConnectionMode);
|
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);
|
byte[] jobBuffer = HistorianTagRenameProtocol.SerializeRenameJob(pairs);
|
||||||
var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel);
|
var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel);
|
||||||
GrpcHistory.StartJobResponse response = historyClient.StartJob(
|
GrpcHistory.StartJobResponse response = historyClient.StartJob(
|
||||||
|
|||||||
@@ -114,18 +114,24 @@ public sealed class HistorianClient : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sends a single <see cref="HistorianEvent"/> to the Historian's built-in CM_EVENT tag
|
/// Sends a single <see cref="HistorianEvent"/> to the Historian's built-in CM_EVENT tag.
|
||||||
/// over the WCF event pipeline (Open2 event mode → CM_EVENT registration → AddS2). The
|
/// Over WCF this runs Open2 event mode → CM_EVENT registration → AddS2; over the 2023 R2
|
||||||
/// event is appended to the historian's event history and is readable back via
|
/// <see cref="HistorianTransport.RemoteGrpc"/> transport it runs the captured-equivalent
|
||||||
/// <see cref="ReadEventsAsync"/> / the <c>v_AlarmEventHistory2</c> view. Only original
|
/// v8 Event OpenConnection → CM_EVENT registration → <c>HistoryService.AddStreamValues</c>
|
||||||
/// events (<see cref="HistorianEvent.RevisionVersion"/> = 0) with string-valued properties
|
/// with the same "OS" event buffer (live-captured 2026-06-23 — the send rides the same RPC
|
||||||
/// are supported; other property value types and revision/update/delete events throw
|
/// 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.
|
/// <see cref="ProtocolEvidenceMissingException"/> until their wire encoding is captured.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Task<bool> SendEventAsync(HistorianEvent historianEvent, CancellationToken cancellationToken = default)
|
public Task<bool> SendEventAsync(HistorianEvent historianEvent, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(historianEvent);
|
ArgumentNullException.ThrowIfNull(historianEvent);
|
||||||
return new HistorianWcfEventOrchestrator(_options).SendEventAsync(historianEvent, cancellationToken);
|
return _options.Transport == HistorianTransport.RemoteGrpc
|
||||||
|
? new Grpc.HistorianGrpcEventWriteOrchestrator(_options).SendEventAsync(historianEvent, cancellationToken)
|
||||||
|
: new HistorianWcfEventOrchestrator(_options).SendEventAsync(historianEvent, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -341,6 +347,73 @@ public sealed class HistorianClient : IAsyncDisposable
|
|||||||
: new HistorianWcfTagWriteOrchestrator(_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()
|
public ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
return ValueTask.CompletedTask;
|
return ValueTask.CompletedTask;
|
||||||
|
|||||||
@@ -53,6 +53,25 @@ public sealed class HistorianClientOptions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string? ServerDnsIdentity { get; init; }
|
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>
|
/// <summary>
|
||||||
/// For <see cref="HistorianTransport.RemoteGrpc"/>: when true the channel uses TLS
|
/// 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
|
/// (<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,8 +29,9 @@ internal enum HistorianServiceInterface
|
|||||||
/// <item>Retrieval (<c>Retr</c>) interface version = 4</item>
|
/// <item>Retrieval (<c>Retr</c>) interface version = 4</item>
|
||||||
/// <item>Transaction (<c>Trx</c>) interface version = 2</item>
|
/// <item>Transaction (<c>Trx</c>) interface version = 2</item>
|
||||||
/// </list>
|
/// </list>
|
||||||
/// The Status (<c>Stat</c>) service's <c>GetInterfaceVersion</c> returns 0 (not a real
|
/// The Status (<c>Stat</c>) service's <c>GetInterfaceVersion</c> is not a real version (0 on
|
||||||
/// version), so the Status interface is validated for reachability only, never value.
|
/// 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 reports History interface version 12 even though it carries the
|
/// 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
|
/// same proven 2020 native buffers. That value is captured and accepted (see
|
||||||
@@ -48,14 +49,19 @@ internal static class HistorianServerVersionGate
|
|||||||
/// The 2023 R2 gRPC HistoryService reports interface version 12. It is buffer-compatible with
|
/// 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
|
/// 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
|
/// 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 reported 4, matching
|
/// (2026-06-21). So both 11 and 12 are accepted for History.
|
||||||
/// the 2020 value, so it needs no widening.)
|
///
|
||||||
|
/// 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>
|
/// </summary>
|
||||||
public const uint HistoryInterfaceVersionGrpc2023R2 = 12;
|
public const uint HistoryInterfaceVersionGrpc2023R2 = 12;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// True when the service interface reports a meaningful version that should be matched.
|
/// 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>
|
/// </summary>
|
||||||
public static bool IsValueGated(HistorianServiceInterface service) => service switch
|
public static bool IsValueGated(HistorianServiceInterface service) => service switch
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -10,10 +10,10 @@ namespace AVEVA.Historian.Client.Wcf;
|
|||||||
/// native event read (instrument-wcf-readmessage record 24, two rows for Alarm.Set + Alarm.Clear):
|
/// native event read (instrument-wcf-readmessage record 24, two rows for Alarm.Set + Alarm.Clear):
|
||||||
///
|
///
|
||||||
/// <code>
|
/// <code>
|
||||||
/// UInt16 version = 9
|
/// UInt16 version = 9 (WCF) | 11 (2023 R2 gRPC)
|
||||||
/// UInt32 rowCount
|
/// UInt32 rowCount
|
||||||
|
/// UInt32 headerField = 0x1E // ONE buffer-level field (NOT a per-row marker)
|
||||||
/// rowCount × Row {
|
/// rowCount × Row {
|
||||||
/// UInt32 rowMarker = 0x1E
|
|
||||||
/// UInt16 rowFormat = 7
|
/// UInt16 rowFormat = 7
|
||||||
/// Int64 eventTimeUtcFiletime
|
/// Int64 eventTimeUtcFiletime
|
||||||
/// UInt16 × 8 // purpose unclear (slot offsets?)
|
/// UInt16 × 8 // purpose unclear (slot offsets?)
|
||||||
@@ -42,10 +42,23 @@ namespace AVEVA.Historian.Client.Wcf;
|
|||||||
internal static class HistorianEventRowProtocol
|
internal static class HistorianEventRowProtocol
|
||||||
{
|
{
|
||||||
public const ushort EventRowProtocolVersion = 9;
|
public const ushort EventRowProtocolVersion = 9;
|
||||||
public const uint RowMarker = 0x0000001Eu;
|
|
||||||
public const ushort RowFormatV9 = 7;
|
/// <summary>
|
||||||
private const int HeaderSize = 6;
|
/// 2023 R2 gRPC returns the event-row buffer with container version <c>11</c> instead of the
|
||||||
private const int RowFixedHeaderSize = 4 + 2 + 8 + 16;
|
/// 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 ValueTypeBool = 0x02;
|
||||||
private const byte ValueTypeGuid = 0x10;
|
private const byte ValueTypeGuid = 0x10;
|
||||||
@@ -55,25 +68,26 @@ internal static class HistorianEventRowProtocol
|
|||||||
|
|
||||||
public static IReadOnlyList<HistorianEvent> Parse(ReadOnlySpan<byte> buffer)
|
public static IReadOnlyList<HistorianEvent> Parse(ReadOnlySpan<byte> buffer)
|
||||||
{
|
{
|
||||||
if (buffer.Length < HeaderSize)
|
if (buffer.Length < 6)
|
||||||
{
|
{
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
ushort version = BinaryPrimitives.ReadUInt16LittleEndian(buffer[..2]);
|
ushort version = BinaryPrimitives.ReadUInt16LittleEndian(buffer[..2]);
|
||||||
if (version != EventRowProtocolVersion)
|
if (version != EventRowProtocolVersion && version != EventRowProtocolVersionGrpc)
|
||||||
{
|
{
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
uint rowCount = BinaryPrimitives.ReadUInt32LittleEndian(buffer.Slice(2, 4));
|
uint rowCount = BinaryPrimitives.ReadUInt32LittleEndian(buffer.Slice(2, 4));
|
||||||
if (rowCount == 0)
|
if (rowCount == 0 || buffer.Length < BufferHeaderSize)
|
||||||
{
|
{
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
List<HistorianEvent> events = new(checked((int)rowCount));
|
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++)
|
for (uint rowIndex = 0; rowIndex < rowCount; rowIndex++)
|
||||||
{
|
{
|
||||||
if (!TryReadRow(buffer, ref cursor, out HistorianEvent? row))
|
if (!TryReadRow(buffer, ref cursor, out HistorianEvent? row))
|
||||||
@@ -95,19 +109,13 @@ internal static class HistorianEventRowProtocol
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
uint marker = BinaryPrimitives.ReadUInt32LittleEndian(buffer.Slice(cursor, 4));
|
ushort format = BinaryPrimitives.ReadUInt16LittleEndian(buffer.Slice(cursor, 2));
|
||||||
if (marker != RowMarker)
|
if (format != RowFormat)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
ushort format = BinaryPrimitives.ReadUInt16LittleEndian(buffer.Slice(cursor + 4, 2));
|
long filetime = BinaryPrimitives.ReadInt64LittleEndian(buffer.Slice(cursor + 2, 8));
|
||||||
if (format != RowFormatV9)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
long filetime = BinaryPrimitives.ReadInt64LittleEndian(buffer.Slice(cursor + 6, 8));
|
|
||||||
DateTime eventTimeUtc = DateTime.FromFileTimeUtc(filetime);
|
DateTime eventTimeUtc = DateTime.FromFileTimeUtc(filetime);
|
||||||
int afterFixedHeader = cursor + RowFixedHeaderSize;
|
int afterFixedHeader = cursor + RowFixedHeaderSize;
|
||||||
|
|
||||||
|
|||||||
@@ -29,9 +29,12 @@ namespace AVEVA.Historian.Client.Wcf;
|
|||||||
/// +0x2A value bytes, native width by tag type:
|
/// +0x2A value bytes, native width by tag type:
|
||||||
/// Float → Float32(4) · Double → Float64(8) · Int2 → Int16(2)
|
/// Float → Float32(4) · Double → Float64(8) · Int2 → Int16(2)
|
||||||
/// Int4 → Int32(4) · UInt4 → UInt32(4)
|
/// Int4 → Int32(4) · UInt4 → UInt32(4)
|
||||||
|
/// Int8 → Int64(8) · UInt8 → UInt64(8)
|
||||||
/// </code>
|
/// </code>
|
||||||
///
|
///
|
||||||
/// Captured for the five analog types <c>EnsureTagAsync</c> supports (Float/Double/Int2/Int4/UInt4).
|
/// Live-captured for Float/Double/Int2/Int4/UInt4; Int8/UInt8 mirror the captured
|
||||||
|
/// Double value layout (8 LE bytes) and are live-proven (2026-06-25). UInt1 is re-gated:
|
||||||
|
/// the historian accepts EnsureTags(UInt1) but stores a degenerate tag — writes would fail.
|
||||||
/// Other tag types have no captured value encoding and are rejected.
|
/// Other tag types have no captured value encoding and are rejected.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal static class HistorianHistoricalWriteProtocol
|
internal static class HistorianHistoricalWriteProtocol
|
||||||
@@ -52,7 +55,10 @@ internal static class HistorianHistoricalWriteProtocol
|
|||||||
/// <paramref name="tagGuid"/> is the per-tag GUID (from the gRPC tag-info read),
|
/// <paramref name="tagGuid"/> is the per-tag GUID (from the gRPC tag-info read),
|
||||||
/// <paramref name="dataType"/> is the tag's declared analog type (selects the value width), and
|
/// <paramref name="dataType"/> is the tag's declared analog type (selects the value width), and
|
||||||
/// <paramref name="receivedTimeUtc"/> is the storage/received timestamp the orchestrator stamps.
|
/// <paramref name="receivedTimeUtc"/> is the storage/received timestamp the orchestrator stamps.
|
||||||
/// Throws <see cref="ProtocolEvidenceMissingException"/> for tag types without a captured encoding.
|
/// Throws <see cref="ProtocolEvidenceMissingException"/> for tag types without a captured encoding
|
||||||
|
/// (including UInt1, which is re-gated — the historian creates a degenerate UInt1 analog tag).
|
||||||
|
/// Int8/UInt8 carry exact magnitude only up to 2^53 — the value API is <see langword="double"/>;
|
||||||
|
/// full 64-bit range is a separate follow-on.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static byte[] SerializeAddStreamValuesBuffer(
|
public static byte[] SerializeAddStreamValuesBuffer(
|
||||||
Guid tagGuid,
|
Guid tagGuid,
|
||||||
@@ -117,10 +123,22 @@ internal static class HistorianHistoricalWriteProtocol
|
|||||||
BinaryPrimitives.WriteUInt32LittleEndian(b, checked((uint)value));
|
BinaryPrimitives.WriteUInt32LittleEndian(b, checked((uint)value));
|
||||||
return b;
|
return b;
|
||||||
}
|
}
|
||||||
|
case HistorianDataType.Int8:
|
||||||
|
{
|
||||||
|
byte[] b = new byte[8];
|
||||||
|
BinaryPrimitives.WriteInt64LittleEndian(b, checked((long)value));
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
case HistorianDataType.UInt8:
|
||||||
|
{
|
||||||
|
byte[] b = new byte[8];
|
||||||
|
BinaryPrimitives.WriteUInt64LittleEndian(b, checked((ulong)value));
|
||||||
|
return b;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
throw new ProtocolEvidenceMissingException(
|
throw new ProtocolEvidenceMissingException(
|
||||||
$"AddHistoricalValuesAsync has no captured value encoding for tag data type '{dataType}'. " +
|
$"AddHistoricalValuesAsync has no captured value encoding for tag data type '{dataType}'. " +
|
||||||
"Captured types: Float, Double, Int2, Int4, UInt4.");
|
"Captured types: Float, Double, Int2, Int4, UInt4, Int8, UInt8.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,9 +58,13 @@ internal static class HistorianTagWriteProtocol
|
|||||||
];
|
];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Native CDataType wire codes per data type — captured 2026-05-04 by probing
|
/// Native CDataType wire codes per data type — captured by probing every type via
|
||||||
/// every type via instrument-wcf-writemessage. Matches the codes already documented
|
/// instrument-wcf-writemessage. Matches the codes already documented in
|
||||||
/// in <see cref="HistorianWcfTagClient"/> MapDataType for the read path.
|
/// <see cref="HistorianWcfTagClient"/> MapDataType for the read path; Int8/UInt8
|
||||||
|
/// reuse the same read-side codes (0x19/0x39).
|
||||||
|
/// UInt1 is excluded: live evidence shows the historian accepts EnsureTags(UInt1)
|
||||||
|
/// with success but stores a degenerate tag (null type descriptor) that subsequent
|
||||||
|
/// GetTagInfo cannot parse — not client-fixable on the analog path.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static byte GetAnalogDataTypeCode(Models.HistorianDataType dataType) => dataType switch
|
public static byte GetAnalogDataTypeCode(Models.HistorianDataType dataType) => dataType switch
|
||||||
{
|
{
|
||||||
@@ -70,8 +74,10 @@ internal static class HistorianTagWriteProtocol
|
|||||||
Models.HistorianDataType.UInt4 => 0x11,
|
Models.HistorianDataType.UInt4 => 0x11,
|
||||||
Models.HistorianDataType.Int2 => 0x29,
|
Models.HistorianDataType.Int2 => 0x29,
|
||||||
Models.HistorianDataType.Int4 => 0x31,
|
Models.HistorianDataType.Int4 => 0x31,
|
||||||
|
Models.HistorianDataType.Int8 => 0x19,
|
||||||
|
Models.HistorianDataType.UInt8 => 0x39,
|
||||||
_ => throw new ProtocolEvidenceMissingException(
|
_ => throw new ProtocolEvidenceMissingException(
|
||||||
$"EnsureTagAsync data type {dataType} has no captured CTagMetadata wire code; supported: Float, Double, UInt2, UInt4, Int2, Int4."),
|
$"EnsureTagAsync data type {dataType} has no captured CTagMetadata wire code; supported: Float, Double, UInt2, UInt4, Int2, Int4, Int8, UInt8."),
|
||||||
};
|
};
|
||||||
|
|
||||||
private static readonly byte[] AnalogPadding16 = new byte[16];
|
private static readonly byte[] AnalogPadding16 = new byte[16];
|
||||||
@@ -118,8 +124,10 @@ internal static class HistorianTagWriteProtocol
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Serializes a CTagMetadata payload for an analog tag. Live-verified for Float,
|
/// Serializes a CTagMetadata payload for an analog tag. Live-verified for Float,
|
||||||
/// Double, Int2, Int4, UInt4 — see <see cref="GetAnalogDataTypeCode"/> for the
|
/// Double, Int2, Int4, UInt4; Int8/UInt8 supported via the read-side type codes
|
||||||
/// type-code mapping. Output matches the byte-for-byte capture for the same inputs.
|
/// (pending live round-trip); UInt1 re-gated (historian creates a degenerate tag) —
|
||||||
|
/// see <see cref="GetAnalogDataTypeCode"/> for the type-code mapping. Output matches
|
||||||
|
/// the byte-for-byte capture for the same inputs.
|
||||||
/// When MinEU/MaxEU/MinRaw/MaxRaw are all defaults (0/100/0/100) emits the compact
|
/// When MinEU/MaxEU/MinRaw/MaxRaw are all defaults (0/100/0/100) emits the compact
|
||||||
/// `1A 03` scaling marker; otherwise emits `1F` + 4 doubles in order.
|
/// `1A 03` scaling marker; otherwise emits `1F` + 4 doubles in order.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ internal static class HistorianWcfAuthChainHelper
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
IHistoryServiceContract2 historyChannel = historyFactory.CreateChannel();
|
IHistoryServiceContract2 historyChannel = HistorianWcfClientCredentialsHelper.CreateChannel(historyFactory, options);
|
||||||
ICommunicationObject historyChannelCo = (ICommunicationObject)historyChannel;
|
ICommunicationObject historyChannelCo = (ICommunicationObject)historyChannel;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -30,6 +30,24 @@ internal static class HistorianWcfClientCredentialsHelper
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a channel from <paramref name="factory"/>, honoring
|
||||||
|
/// <see cref="HistorianClientOptions.ConnectViaAddress"/> when set: the channel <b>connects</b> to
|
||||||
|
/// the Via address while still addressing the SOAP message <c>To</c> the factory's logical endpoint.
|
||||||
|
/// This lets a port-forward whose local port differs from the server's real service port satisfy the
|
||||||
|
/// server-side WCF AddressFilter (which validates the To header). Use this in place of
|
||||||
|
/// <c>factory.CreateChannel()</c> at every WCF event/read channel-creation site.
|
||||||
|
/// </summary>
|
||||||
|
public static TChannel CreateChannel<TChannel>(ChannelFactory<TChannel> factory, HistorianClientOptions options)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(factory);
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
|
||||||
|
return string.IsNullOrWhiteSpace(options.ConnectViaAddress)
|
||||||
|
? factory.CreateChannel()
|
||||||
|
: factory.CreateChannel(factory.Endpoint.Address, new Uri(options.ConnectViaAddress));
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class AcceptAnyCertificateValidator : X509CertificateValidator
|
private sealed class AcceptAnyCertificateValidator : X509CertificateValidator
|
||||||
{
|
{
|
||||||
public static readonly AcceptAnyCertificateValidator Instance = new();
|
public static readonly AcceptAnyCertificateValidator Instance = new();
|
||||||
|
|||||||
@@ -8,10 +8,16 @@ using AVEVA.Historian.Client.Wcf.Contracts;
|
|||||||
namespace AVEVA.Historian.Client.Wcf;
|
namespace AVEVA.Historian.Client.Wcf;
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Mirrors HistorianWcfReadOrchestrator but targets IRetrievalServiceContract4 for the event flow.
|
/// Mirrors HistorianWcfReadOrchestrator but targets IRetrievalServiceContract4 for the event flow. The
|
||||||
/// Event row buffer layout is undecoded as of this pass — when StartEventQuery succeeds, this
|
/// event row-buffer layout is decoded (<see cref="HistorianEventRowProtocol"/>; verified against real
|
||||||
/// orchestrator returns an empty enumeration but logs the row-buffer length via the
|
/// captured rows). A <b>2023 R2</b> historian <i>does</i> serve this transport via the <b>certificate</b>
|
||||||
/// <see cref="LastResultBufferLength"/> diagnostic so a follow-up capture can decode the wire shape.
|
/// (TLS) endpoint (the cert transport + <c>NegotiateAuthentication</c> auth work cross-platform; the
|
||||||
|
/// integrated/Windows transport does not tunnel). With the <c>0x501</c> event connection mode CM_EVENT
|
||||||
|
/// registration succeeds — but <c>StartEventQuery</c> still returns a 0-row buffer and long-polls: event
|
||||||
|
/// rows are <b>server-gated</b> per connection on 2023 R2, the same wall as the gRPC path, and not
|
||||||
|
/// client-fixable (see <c>docs/reverse-engineering/wcf-event-read-spike-results.md</c> and
|
||||||
|
/// <c>grpc-event-query-capture.md</c>). The native return codes 76/85 noted below were 2020-historian
|
||||||
|
/// observations.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal sealed class HistorianWcfEventOrchestrator
|
internal sealed class HistorianWcfEventOrchestrator
|
||||||
{
|
{
|
||||||
@@ -143,7 +149,7 @@ internal sealed class HistorianWcfEventOrchestrator
|
|||||||
EndpointAddress transactionEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Transaction);
|
EndpointAddress transactionEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Transaction);
|
||||||
uint clientHandle = HistorianWcfAuthChainHelper.OpenAuthenticatedConnection(
|
uint clientHandle = HistorianWcfAuthChainHelper.OpenAuthenticatedConnection(
|
||||||
_options, histBinding, histEndpoint, contextKey, cancellationToken,
|
_options, histBinding, histEndpoint, contextKey, cancellationToken,
|
||||||
connectionMode: HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode,
|
connectionMode: _options.EventReadConnectionModeOverride ?? HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode,
|
||||||
additionalSetup: (historyChannel, context) =>
|
additionalSetup: (historyChannel, context) =>
|
||||||
AddCmEventTagViaAddT(historyChannel, context, auxBinding, statusEndpoint, transactionEndpoint, retrBinding, retrEndpoint));
|
AddCmEventTagViaAddT(historyChannel, context, auxBinding, statusEndpoint, transactionEndpoint, retrBinding, retrEndpoint));
|
||||||
return RunEventQuery(retrBinding, retrEndpoint, clientHandle, startUtc, endUtc, filter, cancellationToken);
|
return RunEventQuery(retrBinding, retrEndpoint, clientHandle, startUtc, endUtc, filter, cancellationToken);
|
||||||
@@ -163,7 +169,7 @@ internal sealed class HistorianWcfEventOrchestrator
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
IRetrievalServiceContract4 channel = factory.CreateChannel();
|
IRetrievalServiceContract4 channel = HistorianWcfClientCredentialsHelper.CreateChannel(factory, _options);
|
||||||
ICommunicationObject channelCo = (ICommunicationObject)channel;
|
ICommunicationObject channelCo = (ICommunicationObject)channel;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using AVEVA.Historian.Client.Grpc;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AVEVA.Historian.Client.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reflection guard for the event-on-session seams the B0b reuse spike drives (pending.md A1
|
||||||
|
/// broadening, Stage B0). Mirrors <see cref="TagClientOnSessionSeamTests"/>: the seam runs ONLY the
|
||||||
|
/// op against an externally-supplied (connection, session), so the spike can run MULTIPLE event ops
|
||||||
|
/// on one already-opened + registered v8 Event session to measure reuse.
|
||||||
|
/// </summary>
|
||||||
|
public class EventOnSessionSeamTests
|
||||||
|
{
|
||||||
|
private static MethodInfo RequireMethod(Type owner, string name)
|
||||||
|
{
|
||||||
|
MethodInfo? m = owner.GetMethod(
|
||||||
|
name, BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public | BindingFlags.Static);
|
||||||
|
Assert.NotNull(m);
|
||||||
|
return m!;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SendEventOnSession_ExposesSeam_WithConnectionAndSessionFirst()
|
||||||
|
{
|
||||||
|
MethodInfo m = RequireMethod(typeof(HistorianGrpcEventWriteOrchestrator), "SendEventOnSession");
|
||||||
|
ParameterInfo[] ps = m.GetParameters();
|
||||||
|
Assert.Equal("HistorianGrpcConnection", ps[0].ParameterType.Name);
|
||||||
|
Assert.Equal("Session", ps[1].ParameterType.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OpenAndRegisterEventSession_ExposesRegisterOnceSeam()
|
||||||
|
{
|
||||||
|
// The spike registers CM_EVENT ONCE via this helper, then issues many SendEventOnSession ops.
|
||||||
|
MethodInfo m = RequireMethod(typeof(HistorianGrpcEventWriteOrchestrator), "OpenAndRegisterEventSession");
|
||||||
|
ParameterInfo[] ps = m.GetParameters();
|
||||||
|
Assert.Equal("HistorianGrpcConnection", ps[0].ParameterType.Name);
|
||||||
|
Assert.Equal("CancellationToken", ps[1].ParameterType.Name);
|
||||||
|
Assert.Equal("Session", m.ReturnType.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RunEventQueryOnSession_ExposesSeam_WithConnectionAndSessionFirst()
|
||||||
|
{
|
||||||
|
MethodInfo m = RequireMethod(typeof(HistorianGrpcEventOrchestrator), "RunEventQueryOnSession");
|
||||||
|
ParameterInfo[] ps = m.GetParameters();
|
||||||
|
Assert.Equal("HistorianGrpcConnection", ps[0].ParameterType.Name);
|
||||||
|
Assert.Equal("Session", ps[1].ParameterType.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using Grpc.Core;
|
||||||
|
using AVEVA.Historian.Client.Grpc;
|
||||||
|
using AVEVA.Historian.Client.Models;
|
||||||
|
using Xunit;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
|
||||||
|
namespace AVEVA.Historian.Client.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SPIKE (pending.md A1 broadening, Stage B0b): can ONE v8 Event session be REUSED across many event
|
||||||
|
/// ops without re-handshaking — the precondition for broadening handshake amortization to the event
|
||||||
|
/// path? Env-gated exactly like <see cref="HandshakeReuseSpikeTests"/> (silent early-return skip
|
||||||
|
/// without HISTORIAN_GRPC_HOST + HISTORIAN_USER + HISTORIAN_PASSWORD + HISTORIAN_EVENT_SANDBOX_TAG).
|
||||||
|
///
|
||||||
|
/// This is the B0b HARNESS only — it is RUN LIVE by a human over VPN in B0c. It SKIPS cleanly offline
|
||||||
|
/// (no historian contacted, no event sent). It drives the B0a internal seams directly:
|
||||||
|
/// <see cref="HistorianGrpcEventWriteOrchestrator.OpenAndRegisterEventSession"/> (open v8 Event session
|
||||||
|
/// + RegisterCmEventTag ONCE) and <see cref="HistorianGrpcEventWriteOrchestrator.SendEventOnSession"/>
|
||||||
|
/// (send-only, on the externally-supplied warm session).
|
||||||
|
///
|
||||||
|
/// Spike questions (priority order), mapped to the test methods below:
|
||||||
|
/// (1) Does a v8 Event session survive REUSE? — <see cref="ReusedEventSession_SendsTwice_SecondSkipsHandshake"/>
|
||||||
|
/// (PRIMARY GREEN/RED signal: two sends on one session both succeed; the 2nd skips ECDH+register).
|
||||||
|
/// (2) Does REGISTER-ONCE work? — <see cref="ReusedEventSession_RegisterOnce_ThenSendMany"/>
|
||||||
|
/// (OpenAndRegister once, then SendEventOnSession N× — no per-send re-registration).
|
||||||
|
/// (3) ONE-KIND best-effort — <see cref="ReusedEventSession_ServesReadAfterSend_BestEffort"/>
|
||||||
|
/// (can the same session also serve a ReadEvents after a send? LOGGED, never asserted — reads are gated C2).
|
||||||
|
/// (4) IDLE expiry best-effort — <see cref="ReusedEventSession_IdleSweep_BestEffort"/>
|
||||||
|
/// (how long can the session sit idle before a send breaks? LOGGED, never asserted).
|
||||||
|
///
|
||||||
|
/// SAFETY: every send targets the env var HISTORIAN_EVENT_SANDBOX_TAG ONLY (carried as the event
|
||||||
|
/// SourceName/Type so the appended events are unambiguously attributable to the sandbox identity, never
|
||||||
|
/// a production tag). Success is ASSERTED for (1)/(2); latency is LOGGED only (no flaky perf gates).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EventSessionReuseSpikeTests
|
||||||
|
{
|
||||||
|
private const int SendMany = 3;
|
||||||
|
private readonly ITestOutputHelper _output;
|
||||||
|
|
||||||
|
public EventSessionReuseSpikeTests(ITestOutputHelper output) => _output = output;
|
||||||
|
|
||||||
|
// (1) REUSE VALIDITY — PRIMARY signal. Open+register ONE v8 Event session, then SendEventOnSession
|
||||||
|
// TWICE on it with NO re-handshake/re-register between sends. If the server rejects reusing a v8
|
||||||
|
// Event session, send #2 fails (false / throws) -> RED finding. Both succeed -> GREEN (event-session
|
||||||
|
// reuse is sound, the precondition for event amortization). Latency LOGGED so B0c sees the win
|
||||||
|
// (open+register cost vs the two reused sends).
|
||||||
|
[Fact]
|
||||||
|
public void ReusedEventSession_SendsTwice_SecondSkipsHandshake()
|
||||||
|
{
|
||||||
|
if (!TryGetEnv(out string host, out string sandboxTag)) return;
|
||||||
|
HistorianClientOptions options = BuildOptions(host);
|
||||||
|
var orchestrator = new HistorianGrpcEventWriteOrchestrator(options);
|
||||||
|
|
||||||
|
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
||||||
|
|
||||||
|
var swOpen = Stopwatch.StartNew();
|
||||||
|
HistorianGrpcHandshake.Session session = orchestrator.OpenAndRegisterEventSession(connection, CancellationToken.None);
|
||||||
|
swOpen.Stop();
|
||||||
|
_output.WriteLine($"open+register (ECDH handshake + RegisterCmEventTag) = {swOpen.ElapsedMilliseconds} ms");
|
||||||
|
_output.WriteLine($"registration diag: {orchestrator.RegistrationDiag}");
|
||||||
|
|
||||||
|
for (int i = 0; i < 2; i++)
|
||||||
|
{
|
||||||
|
HistorianEvent evt = BuildSandboxEvent(sandboxTag, attempt: i);
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
bool ok = orchestrator.SendEventOnSession(connection, session, evt, CancellationToken.None);
|
||||||
|
sw.Stop();
|
||||||
|
_output.WriteLine($"reused-send[{i}] = {sw.ElapsedMilliseconds} ms, ok={ok}, lastErr='{orchestrator.LastSendErrorDescription}'");
|
||||||
|
Assert.True(ok, $"reused v8 Event session send[{i}] should be accepted (AddStreamValues BSuccess).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// (2) REGISTER-ONCE. Open+register ONCE, then SendEventOnSession N× — proving RegisterCmEventTag does
|
||||||
|
// NOT need re-running per send (the seam's whole point). All sends must succeed.
|
||||||
|
[Fact]
|
||||||
|
public void ReusedEventSession_RegisterOnce_ThenSendMany()
|
||||||
|
{
|
||||||
|
if (!TryGetEnv(out string host, out string sandboxTag)) return;
|
||||||
|
HistorianClientOptions options = BuildOptions(host);
|
||||||
|
var orchestrator = new HistorianGrpcEventWriteOrchestrator(options);
|
||||||
|
|
||||||
|
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
||||||
|
HistorianGrpcHandshake.Session session = orchestrator.OpenAndRegisterEventSession(connection, CancellationToken.None);
|
||||||
|
|
||||||
|
for (int i = 0; i < SendMany; i++)
|
||||||
|
{
|
||||||
|
HistorianEvent evt = BuildSandboxEvent(sandboxTag, attempt: i);
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
bool ok = orchestrator.SendEventOnSession(connection, session, evt, CancellationToken.None);
|
||||||
|
sw.Stop();
|
||||||
|
_output.WriteLine($"register-once send[{i}] = {sw.ElapsedMilliseconds} ms, ok={ok}");
|
||||||
|
Assert.True(ok, $"register-once send[{i}] should be accepted without per-send re-registration.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// (3) ONE-KIND PROBE (best-effort). After a send on the warm session, try a ReadEvents on the SAME
|
||||||
|
// session. Event reads are GATED (C2 — the gRPC server long-polls GetNext to the no-data terminal and
|
||||||
|
// row-level retrieval is not verified over gRPC), so the outcome (rows or exception) is LOGGED, never
|
||||||
|
// asserted: the test passes as long as the catch swallows any failure. Records the one-kind finding
|
||||||
|
// (can one Event session serve both send and read?) for B0c.
|
||||||
|
[Fact]
|
||||||
|
public void ReusedEventSession_ServesReadAfterSend_BestEffort()
|
||||||
|
{
|
||||||
|
if (!TryGetEnv(out string host, out string sandboxTag)) return;
|
||||||
|
HistorianClientOptions options = BuildOptions(host);
|
||||||
|
var writeOrch = new HistorianGrpcEventWriteOrchestrator(options);
|
||||||
|
|
||||||
|
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
||||||
|
HistorianGrpcHandshake.Session session = writeOrch.OpenAndRegisterEventSession(connection, CancellationToken.None);
|
||||||
|
|
||||||
|
bool sent = writeOrch.SendEventOnSession(connection, session, BuildSandboxEvent(sandboxTag, attempt: 0), CancellationToken.None);
|
||||||
|
_output.WriteLine($"seed-send before read-probe ok={sent}");
|
||||||
|
|
||||||
|
// HARD bound so this probe CANNOT hang on the known C2 event-read long-poll (GetNext blocks to the
|
||||||
|
// no-data terminal on an idle box). Two independent fuses: (1) a read-only options copy with a 5s
|
||||||
|
// RequestTimeout so each underlying GetNext RPC deadlines quickly (the read orchestrator caps its
|
||||||
|
// poll deadline at min(10s, RequestTimeout)); (2) an 8s CancellationToken passed as ct so the chain
|
||||||
|
// is cancelled even if a per-RPC deadline is not honored over a tunnel. Whichever fires first, the
|
||||||
|
// method returns in ~10s max even when the read never returns data.
|
||||||
|
HistorianClientOptions readOptions = BuildOptions(host, requestTimeoutOverride: TimeSpan.FromSeconds(5));
|
||||||
|
var readOrch = new HistorianGrpcEventOrchestrator(readOptions);
|
||||||
|
using var readCts = new CancellationTokenSource(TimeSpan.FromSeconds(8));
|
||||||
|
|
||||||
|
(DateTime startUtc, DateTime endUtc) = LastSevenDays();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
List<HistorianEvent> rows = readOrch.RunEventQueryOnSession(
|
||||||
|
connection, session, startUtc, endUtc, filter: null, readCts.Token);
|
||||||
|
_output.WriteLine($"read-after-send -> OK (rows={rows.Count}) => ONE-KIND (an Event session serves send AND read)");
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is OperationCanceledException
|
||||||
|
|| (ex is RpcException rpc && rpc.StatusCode == StatusCode.DeadlineExceeded))
|
||||||
|
{
|
||||||
|
// EXPECTED outcome: the read hit its bound (CTS timeout or per-RPC deadline) without returning —
|
||||||
|
// consistent with the C2 event-read gating (GetNext long-polls to the no-data terminal). This is
|
||||||
|
// the recorded one-kind finding, NOT a failure.
|
||||||
|
_output.WriteLine($"read-after-send did not return within the bound (consistent with C2 event-read gating): {ex.GetType().Name}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Any other rejection on the reused session is also the finding, not a failure.
|
||||||
|
_output.WriteLine($"read-after-send -> swallowed ({ex.GetType().Name}: {ex.Message}) => read gated/unverified over gRPC (expected)");
|
||||||
|
}
|
||||||
|
// No assertion: this method's job is to RECORD the one-kind outcome for B0c, not gate on it.
|
||||||
|
}
|
||||||
|
|
||||||
|
// (4) IDLE-EXPIRY SWEEP (best-effort, log-only). Send, sit idle for a gap, send again; LOG whether the
|
||||||
|
// 2nd send broke (and after how long). Bounds how long a warm Event session may sit idle before the
|
||||||
|
// server expires it — informs the keepalive cadence for an event-session pool. Default gap 25s;
|
||||||
|
// override via HISTORIAN_EVENT_IDLE_SECONDS. NEVER asserted (a break is the finding, not a failure).
|
||||||
|
[Fact]
|
||||||
|
[Trait("Category", "LiveSpike")]
|
||||||
|
public void ReusedEventSession_IdleSweep_BestEffort()
|
||||||
|
{
|
||||||
|
if (!TryGetEnv(out string host, out string sandboxTag)) return;
|
||||||
|
HistorianClientOptions options = BuildOptions(host);
|
||||||
|
var orchestrator = new HistorianGrpcEventWriteOrchestrator(options);
|
||||||
|
|
||||||
|
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
||||||
|
HistorianGrpcHandshake.Session session = orchestrator.OpenAndRegisterEventSession(connection, CancellationToken.None);
|
||||||
|
|
||||||
|
bool first = orchestrator.SendEventOnSession(connection, session, BuildSandboxEvent(sandboxTag, attempt: 0), CancellationToken.None);
|
||||||
|
_output.WriteLine($"idle-sweep first send ok={first}");
|
||||||
|
|
||||||
|
int idleSec = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_EVENT_IDLE_SECONDS"), out int parsed) && parsed > 0
|
||||||
|
? parsed
|
||||||
|
: 25;
|
||||||
|
_output.WriteLine($"idle-sweep: sleeping {idleSec}s before the second send...");
|
||||||
|
Thread.Sleep(TimeSpan.FromSeconds(idleSec));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
bool second = orchestrator.SendEventOnSession(connection, session, BuildSandboxEvent(sandboxTag, attempt: 1), CancellationToken.None);
|
||||||
|
_output.WriteLine($"idle {idleSec}s -> second send ok={second} (session {(second ? "SURVIVED" : "rejected")} the idle gap)");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_output.WriteLine($"idle {idleSec}s -> second send BROKE ({ex.GetType().Name}: {ex.Message}) — session expired while idle");
|
||||||
|
}
|
||||||
|
// No assertion: idle-expiry timing is a LOGGED finding for the keepalive cadence, not a gate.
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
// Build a send event that targets the sandbox identity ONLY. The CM_EVENT send buffer carries no
|
||||||
|
// per-tag routing field (it registers against the CM_EVENT system tag), so we stamp the sandbox tag
|
||||||
|
// NAME into SourceName + Type and a marker Property so the appended event is unambiguously
|
||||||
|
// attributable to the sandbox — never a production tag. A fresh Id/timestamps per attempt.
|
||||||
|
private static HistorianEvent BuildSandboxEvent(string sandboxTag, int attempt)
|
||||||
|
{
|
||||||
|
DateTime now = DateTime.UtcNow;
|
||||||
|
return new HistorianEvent(
|
||||||
|
Id: Guid.NewGuid(),
|
||||||
|
EventTimeUtc: now.AddSeconds(-attempt),
|
||||||
|
ReceivedTimeUtc: now,
|
||||||
|
Type: sandboxTag,
|
||||||
|
SourceName: sandboxTag,
|
||||||
|
Namespace: "HistGW.EventReuseSpike",
|
||||||
|
RevisionVersion: 0,
|
||||||
|
Properties: new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["SpikeAttempt"] = attempt.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||||
|
["SpikeMarker"] = "B0b-event-session-reuse",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetEnv(out string host, out string sandboxTag)
|
||||||
|
{
|
||||||
|
host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST") ?? "";
|
||||||
|
sandboxTag = Environment.GetEnvironmentVariable("HISTORIAN_EVENT_SANDBOX_TAG") ?? "";
|
||||||
|
return !string.IsNullOrWhiteSpace(host)
|
||||||
|
&& !string.IsNullOrWhiteSpace(sandboxTag)
|
||||||
|
&& !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER"))
|
||||||
|
&& !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_PASSWORD"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (DateTime StartUtc, DateTime EndUtc) LastSevenDays()
|
||||||
|
{
|
||||||
|
DateTime end = DateTime.UtcNow;
|
||||||
|
return (end - TimeSpan.FromDays(7), end);
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestTimeoutOverride: when set, forces RequestTimeout (used by the read-after-send probe to give
|
||||||
|
// each GetNext RPC a short deadline). null preserves the env-driven default for the send/idle methods.
|
||||||
|
private static HistorianClientOptions BuildOptions(string host, TimeSpan? requestTimeoutOverride = null)
|
||||||
|
{
|
||||||
|
string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER");
|
||||||
|
string? password = Environment.GetEnvironmentVariable("HISTORIAN_PASSWORD");
|
||||||
|
bool explicitCreds = !string.IsNullOrEmpty(user);
|
||||||
|
int port = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_PORT"), out int parsed)
|
||||||
|
? parsed
|
||||||
|
: HistorianClientOptions.DefaultGrpcPort;
|
||||||
|
bool tls = string.Equals(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_TLS"), "true", StringComparison.OrdinalIgnoreCase);
|
||||||
|
TimeSpan timeout = requestTimeoutOverride
|
||||||
|
?? (int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_TIMEOUT"), out int secs) && secs > 0
|
||||||
|
? TimeSpan.FromSeconds(secs)
|
||||||
|
: new HistorianClientOptions { Host = host }.RequestTimeout);
|
||||||
|
|
||||||
|
return new HistorianClientOptions
|
||||||
|
{
|
||||||
|
Host = host,
|
||||||
|
Port = port,
|
||||||
|
Transport = HistorianTransport.RemoteGrpc,
|
||||||
|
GrpcUseTls = tls,
|
||||||
|
AllowUntrustedServerCertificate = tls,
|
||||||
|
ServerDnsIdentity = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_DNSID"),
|
||||||
|
IntegratedSecurity = !explicitCreds,
|
||||||
|
UserName = user ?? string.Empty,
|
||||||
|
Password = password ?? string.Empty,
|
||||||
|
RequestTimeout = timeout,
|
||||||
|
Compression = true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using System;
|
||||||
|
using System.Buffers.Binary;
|
||||||
|
using AVEVA.Historian.Client.Wcf;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AVEVA.Historian.Client.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Golden-byte coverage for the 2023 R2 gRPC event-SEND registration buffers, pinned against a live
|
||||||
|
/// native capture (<c>capture-send-event</c> scenario, 2026-06-23). The send itself rides
|
||||||
|
/// <c>HistoryService.AddStreamValues</c> with the same "OS" buffer the WCF path uses
|
||||||
|
/// (<see cref="HistorianEventWriteProtocol"/>, already golden-tested in
|
||||||
|
/// <c>WcfEventWriteProtocolTests</c>); what is gRPC-specific is the CM_EVENT registration the event
|
||||||
|
/// connection performs first (RegisterTags + the 86-byte gRPC EnsureTags). These fixtures are the raw
|
||||||
|
/// bytes the native client sent on the wire — they carry no identity (CM_EVENT / "AnE Event" /
|
||||||
|
/// constant tag + event-type GUIDs / a registration FILETIME).
|
||||||
|
/// </summary>
|
||||||
|
public class GrpcEventSendProtocolTests
|
||||||
|
{
|
||||||
|
// GrpcHistoryClient.RegisterTags.tagInfos captured from the native event connection: the packet
|
||||||
|
// header 50 67 02 00 + count(1) + the 16-byte CM_EVENT tag GUID.
|
||||||
|
private const string CapturedRegisterTagsHex =
|
||||||
|
"506702000100000045813b35f05d464da253871aef49b321";
|
||||||
|
|
||||||
|
// GrpcHistoryClient.EnsureTags.tagInfos captured from the native event connection (86 bytes): the
|
||||||
|
// 8-byte EnsureTags header + CM_EVENT CTagMetadata + a registration FILETIME + the …e01f2f27
|
||||||
|
// event-type GUID.
|
||||||
|
private const string CapturedEnsureTagsHex =
|
||||||
|
"4e670300010000000386000545813b35f05d464da253871aef49b321090800434d5f4556454e54090900416e45204576656e7402020100000001000000004e18d6bd4503dd0142ae595fb63b604791a5ab0be01f2f27";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildRegisterCmEventInputBuffer_MatchesNativeGrpcCapture()
|
||||||
|
{
|
||||||
|
byte[] expected = Convert.FromHexString(CapturedRegisterTagsHex);
|
||||||
|
byte[] actual = HistorianEventRegistrationProtocol.BuildRegisterCmEventInputBuffer();
|
||||||
|
Assert.Equal(expected, actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SerializeCmEventEnsureTagsGrpc_MatchesNativeGrpcCapture()
|
||||||
|
{
|
||||||
|
byte[] expected = Convert.FromHexString(CapturedEnsureTagsHex);
|
||||||
|
Assert.Equal(86, expected.Length);
|
||||||
|
|
||||||
|
// The only run-varying field is the registration FILETIME (the 8 bytes immediately before the
|
||||||
|
// trailing 16-byte event-type GUID). Feed the captured time back so the comparison is exact.
|
||||||
|
long filetime = BinaryPrimitives.ReadInt64LittleEndian(expected.AsSpan(expected.Length - 24, 8));
|
||||||
|
DateTime createdUtc = DateTime.FromFileTimeUtc(filetime);
|
||||||
|
|
||||||
|
byte[] actual = HistorianAddTagsProtocol.SerializeCmEventEnsureTagsGrpc(createdUtc);
|
||||||
|
Assert.Equal(expected, actual);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
using AVEVA.Historian.Client.Grpc;
|
||||||
|
using GrpcHistory = ArchestrA.Grpc.Contract.History;
|
||||||
|
using GrpcRetrieval = ArchestrA.Grpc.Contract.Retrieval;
|
||||||
|
using GrpcStatus = ArchestrA.Grpc.Contract.Status;
|
||||||
|
using GrpcTransaction = ArchestrA.Grpc.Contract.Transaction;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
|
||||||
|
namespace AVEVA.Historian.Client.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Live evidence test (C3a): reads the four unauthenticated <c>GetInterfaceVersion</c> RPCs from a
|
||||||
|
/// real 2023 R2 Historian over gRPC and asserts the accepted version set. These RPCs run before any
|
||||||
|
/// credential exchange, so no HISTORIAN_USER / HISTORIAN_PASSWORD is required — only a reachable host.
|
||||||
|
///
|
||||||
|
/// Skips silently when <c>HISTORIAN_GRPC_HOST</c> is absent (offline / CI). The captured integers
|
||||||
|
/// are recorded in <c>docs/reverse-engineering/grpc-interface-versions.md</c> and close the C3a
|
||||||
|
/// "2023 R2 gRPC server-version integers not yet captured" gap.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GrpcInterfaceVersionEvidenceTests
|
||||||
|
{
|
||||||
|
private readonly ITestOutputHelper _output;
|
||||||
|
|
||||||
|
public GrpcInterfaceVersionEvidenceTests(ITestOutputHelper output) => _output = output;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GrpcInterfaceVersions_LiveServer_MatchAcceptedSet()
|
||||||
|
{
|
||||||
|
string host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST") ?? "";
|
||||||
|
if (string.IsNullOrWhiteSpace(host))
|
||||||
|
{
|
||||||
|
_output.WriteLine("SKIP: HISTORIAN_GRPC_HOST is not set — no live historian available.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
HistorianClientOptions options = BuildOptions(host);
|
||||||
|
|
||||||
|
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
||||||
|
DateTime deadline = DateTime.UtcNow.Add(TimeSpan.FromSeconds(10));
|
||||||
|
|
||||||
|
GrpcHistory.GetInterfaceVersionResponse history =
|
||||||
|
new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel)
|
||||||
|
.GetInterfaceVersion(new GrpcHistory.GetInterfaceVersionRequest(), connection.Metadata, deadline, default);
|
||||||
|
|
||||||
|
GrpcRetrieval.GetRetrievalInterfaceVersionResponse retrieval =
|
||||||
|
new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel)
|
||||||
|
.GetRetrievalInterfaceVersion(new GrpcRetrieval.GetRetrievalInterfaceVersionRequest(), connection.Metadata, deadline, default);
|
||||||
|
|
||||||
|
GrpcStatus.GetStatusInterfaceVersionResponse status =
|
||||||
|
new GrpcStatus.StatusService.StatusServiceClient(connection.Channel)
|
||||||
|
.GetStatusInterfaceVersion(new GrpcStatus.GetStatusInterfaceVersionRequest(), connection.Metadata, deadline, default);
|
||||||
|
|
||||||
|
GrpcTransaction.GetTransactionInterfaceVersionResponse transaction =
|
||||||
|
new GrpcTransaction.TransactionService.TransactionServiceClient(connection.Channel)
|
||||||
|
.GetTransactionInterfaceVersion(new GrpcTransaction.GetTransactionInterfaceVersionRequest(), connection.Metadata, deadline, default);
|
||||||
|
|
||||||
|
_output.WriteLine($"History UiVersion={history.UiVersion} UiError={history.UiError}");
|
||||||
|
_output.WriteLine($"Retrieval UiVersion={retrieval.UiVersion} UiError={retrieval.UiError}");
|
||||||
|
_output.WriteLine($"Status UiVersion={status.UiVersion} UiError={status.UiError}");
|
||||||
|
// Note: Transaction response fields are named Error/Version (not UiError/UiVersion) per the proto.
|
||||||
|
_output.WriteLine($"Transaction Version={transaction.Version} Error={transaction.Error}");
|
||||||
|
|
||||||
|
// History: accepted set is {11 (2020 WCF), 12 (2023 R2 gRPC)}.
|
||||||
|
Assert.Equal(0u, history.UiError);
|
||||||
|
Assert.Contains(history.UiVersion, new uint[] { 11u, 12u });
|
||||||
|
|
||||||
|
// Retrieval: 4 on both 2020 WCF and 2023 R2 gRPC.
|
||||||
|
Assert.Equal(0u, retrieval.UiError);
|
||||||
|
Assert.Equal(4u, retrieval.UiVersion);
|
||||||
|
|
||||||
|
// Transaction: 2 (confirmed by live gRPC capture — see grpc-interface-versions.md).
|
||||||
|
// NOTE: the Transaction response proto uses Error/Version (not UiError/UiVersion).
|
||||||
|
Assert.Equal(0u, transaction.Error);
|
||||||
|
Assert.Equal(2u, transaction.Version);
|
||||||
|
|
||||||
|
// Status: reachability-only — assert UiError==0 only; UiVersion is not value-gated
|
||||||
|
// (observed 4 on 2023 R2 gRPC, 0 on 2020 WCF).
|
||||||
|
Assert.Equal(0u, status.UiError);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HistorianClientOptions BuildOptions(string host)
|
||||||
|
{
|
||||||
|
string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER");
|
||||||
|
string? password = Environment.GetEnvironmentVariable("HISTORIAN_PASSWORD");
|
||||||
|
bool explicitCreds = !string.IsNullOrEmpty(user);
|
||||||
|
int port = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_PORT"), out int parsed)
|
||||||
|
? parsed
|
||||||
|
: HistorianClientOptions.DefaultGrpcPort;
|
||||||
|
bool tls = string.Equals(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_TLS"), "true", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
return new HistorianClientOptions
|
||||||
|
{
|
||||||
|
Host = host,
|
||||||
|
Port = port,
|
||||||
|
Transport = HistorianTransport.RemoteGrpc,
|
||||||
|
GrpcUseTls = tls,
|
||||||
|
AllowUntrustedServerCertificate = tls,
|
||||||
|
ServerDnsIdentity = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_DNSID"),
|
||||||
|
IntegratedSecurity = !explicitCreds,
|
||||||
|
UserName = user ?? string.Empty,
|
||||||
|
Password = password ?? string.Empty,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using AVEVA.Historian.Client.Grpc;
|
||||||
|
using AVEVA.Historian.Client.Models;
|
||||||
|
using AVEVA.Historian.Client.Wcf;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
|
||||||
|
namespace AVEVA.Historian.Client.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SPIKE (pending.md A1): does the 2023 R2 historian honor REUSING one authenticated session across
|
||||||
|
/// many operations — the precondition for handshake amortization? Env-gated exactly like
|
||||||
|
/// <see cref="HistorianGrpcIntegrationTests"/> (silent skip without HISTORIAN_GRPC_HOST +
|
||||||
|
/// HISTORIAN_TEST_TAG + HISTORIAN_USER). READ-ONLY — no writes, safe against live data. Reuse
|
||||||
|
/// validity is ASSERTED; latency numbers are LOGGED only (no flaky perf gates).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class HandshakeReuseSpikeTests
|
||||||
|
{
|
||||||
|
private const int ReuseOps = 5;
|
||||||
|
private readonly ITestOutputHelper _output;
|
||||||
|
|
||||||
|
public HandshakeReuseSpikeTests(ITestOutputHelper output) => _output = output;
|
||||||
|
|
||||||
|
// (1) REUSE VALIDITY: one session, N reads on the same clientHandle. If the server rejects handle
|
||||||
|
// reuse, read #2+ throws -> RED finding. All succeed -> GREEN finding.
|
||||||
|
[Fact]
|
||||||
|
public void ReusedSession_RunsManyReads_AllSucceed()
|
||||||
|
{
|
||||||
|
if (!TryGetEnv(out string host, out string tag)) return;
|
||||||
|
HistorianClientOptions options = BuildOptions(host);
|
||||||
|
(DateTime startUtc, DateTime endUtc) = LastSevenDays();
|
||||||
|
|
||||||
|
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
||||||
|
|
||||||
|
var swHandshake = Stopwatch.StartNew();
|
||||||
|
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, CancellationToken.None);
|
||||||
|
swHandshake.Stop();
|
||||||
|
_output.WriteLine($"open-session (handshake) = {swHandshake.ElapsedMilliseconds} ms");
|
||||||
|
|
||||||
|
var orchestrator = new HistorianGrpcReadOrchestrator(options);
|
||||||
|
for (int i = 0; i < ReuseOps; i++)
|
||||||
|
{
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
List<HistorianSample> rows = orchestrator.RunRawQueryOnSession(
|
||||||
|
connection, session.ClientHandle, tag, startUtc, endUtc, maxValues: 8, CancellationToken.None);
|
||||||
|
sw.Stop();
|
||||||
|
_output.WriteLine($"reused-read[{i}] = {sw.ElapsedMilliseconds} ms, rows={rows.Count}");
|
||||||
|
Assert.NotEmpty(rows);
|
||||||
|
Assert.All(rows, r => Assert.Equal(tag, r.TagName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// (2) WIN MAGNITUDE: full per-call path (fresh Create+handshake+query) vs amortized (one handshake,
|
||||||
|
// N reused reads). Logged only — quantifies whether amortization is worth the refactor.
|
||||||
|
[Fact]
|
||||||
|
public async Task ReusedSession_VsPerCallPath_LogsLatencyDelta()
|
||||||
|
{
|
||||||
|
if (!TryGetEnv(out string host, out string tag)) return;
|
||||||
|
HistorianClientOptions options = BuildOptions(host);
|
||||||
|
(DateTime startUtc, DateTime endUtc) = LastSevenDays();
|
||||||
|
|
||||||
|
HistorianClient client = new(options);
|
||||||
|
var swPerCall = Stopwatch.StartNew();
|
||||||
|
for (int i = 0; i < ReuseOps; i++)
|
||||||
|
{
|
||||||
|
await foreach (HistorianSample _ in client.ReadRawAsync(tag, startUtc, endUtc, 8, CancellationToken.None)) { }
|
||||||
|
}
|
||||||
|
swPerCall.Stop();
|
||||||
|
|
||||||
|
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
||||||
|
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, CancellationToken.None);
|
||||||
|
var orchestrator = new HistorianGrpcReadOrchestrator(options);
|
||||||
|
var swAmortized = Stopwatch.StartNew();
|
||||||
|
for (int i = 0; i < ReuseOps; i++)
|
||||||
|
{
|
||||||
|
orchestrator.RunRawQueryOnSession(connection, session.ClientHandle, tag, startUtc, endUtc, 8, CancellationToken.None);
|
||||||
|
}
|
||||||
|
swAmortized.Stop();
|
||||||
|
|
||||||
|
_output.WriteLine($"per-call ({ReuseOps} ops) = {swPerCall.ElapsedMilliseconds} ms");
|
||||||
|
_output.WriteLine($"amortized ({ReuseOps} ops) = {swAmortized.ElapsedMilliseconds} ms");
|
||||||
|
_output.WriteLine($"saving over {ReuseOps} ops = {swPerCall.ElapsedMilliseconds - swAmortized.ElapsedMilliseconds} ms");
|
||||||
|
}
|
||||||
|
|
||||||
|
// (3) EXPIRY SWEEP: reuse after idle gaps. Default bounded [0s, 30s]; longer tiers opt-in via
|
||||||
|
// HISTORIAN_REUSE_IDLE_SECONDS="0,30,120,600". Rethrows at the tier where reuse first breaks.
|
||||||
|
[Fact]
|
||||||
|
public void ReusedSession_IdleSweep_SurfacesExpiryTier()
|
||||||
|
{
|
||||||
|
if (!TryGetEnv(out string host, out string tag)) return;
|
||||||
|
HistorianClientOptions options = BuildOptions(host);
|
||||||
|
(DateTime startUtc, DateTime endUtc) = LastSevenDays();
|
||||||
|
|
||||||
|
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
||||||
|
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, CancellationToken.None);
|
||||||
|
var orchestrator = new HistorianGrpcReadOrchestrator(options);
|
||||||
|
|
||||||
|
foreach (int delaySec in ParseIdleSweep())
|
||||||
|
{
|
||||||
|
if (delaySec > 0) Thread.Sleep(TimeSpan.FromSeconds(delaySec));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
List<HistorianSample> rows = orchestrator.RunRawQueryOnSession(
|
||||||
|
connection, session.ClientHandle, tag, startUtc, endUtc, 8, CancellationToken.None);
|
||||||
|
_output.WriteLine($"idle {delaySec,4}s -> OK (rows={rows.Count})");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_output.WriteLine($"idle {delaySec,4}s -> BROKE ({ex.GetType().Name})");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// (A) WRITE REUSE VALIDITY: one externally-opened 0x401 (write-enabled) session, N writes on it via
|
||||||
|
// RunWriteOnSession — NO Create()/handshake per write. If the server rejects reusing a write session,
|
||||||
|
// write #2 throws -> RED finding. Both succeed -> GREEN (write-reuse is sound). Bounded writes to the
|
||||||
|
// sandbox tag ONLY; latency LOGGED, success ASSERTED.
|
||||||
|
[Fact]
|
||||||
|
public void WriteEnabledSession_RunsTwoWrites_AllSucceed()
|
||||||
|
{
|
||||||
|
if (!TryGetWriteEnv(out string host, out string sandboxTag)) return;
|
||||||
|
HistorianClientOptions options = BuildOptions(host);
|
||||||
|
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
||||||
|
HistorianGrpcHandshake.Session session = OpenWriteSession(options, connection);
|
||||||
|
var writer = new HistorianGrpcHistoricalWriteOrchestrator(options);
|
||||||
|
|
||||||
|
for (int i = 0; i < 2; i++)
|
||||||
|
{
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
bool ok = writer.RunWriteOnSession(connection, session, sandboxTag,
|
||||||
|
new[] { new HistorianHistoricalValue(DateTime.UtcNow.AddSeconds(-i), 1.0 + i, OpcQuality: 192) },
|
||||||
|
CancellationToken.None);
|
||||||
|
sw.Stop();
|
||||||
|
_output.WriteLine($"reused-write[{i}] = {sw.ElapsedMilliseconds} ms, ok={ok}");
|
||||||
|
Assert.True(ok);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// (B) READ-ON-WRITE-SESSION PROBE: can a 0x401 (write-enabled) session ALSO serve a raw read? Decides
|
||||||
|
// the pool shape — ONE-KIND (a single write-enabled session serves both reads and writes) vs TWO-KIND
|
||||||
|
// (separate read 0x402 / write 0x401 sessions). The kind is LOGGED, never asserted; any failure is
|
||||||
|
// swallowed (a rejection is itself the finding, not a test failure). READ-ONLY here.
|
||||||
|
[Fact]
|
||||||
|
public void WriteEnabledSession_AlsoServesRead_RecordsKind()
|
||||||
|
{
|
||||||
|
if (!TryGetWriteEnv(out string host, out string sandboxTag)) return;
|
||||||
|
HistorianClientOptions options = BuildOptions(host);
|
||||||
|
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
||||||
|
HistorianGrpcHandshake.Session session = OpenWriteSession(options, connection);
|
||||||
|
(DateTime startUtc, DateTime endUtc) = LastSevenDays();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
List<HistorianSample> rows = new HistorianGrpcReadOrchestrator(options)
|
||||||
|
.RunRawQueryOnSession(connection, session.ClientHandle, sandboxTag, startUtc, endUtc, 8, CancellationToken.None);
|
||||||
|
_output.WriteLine($"read-on-0x401 -> OK (rows={rows.Count}) => ONE-KIND pool (write-enabled serves reads)");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_output.WriteLine($"read-on-0x401 -> FAILED ({ex.GetType().Name}) => TWO-KIND pool (separate read/write)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
private static bool TryGetWriteEnv(out string host, out string sandboxTag)
|
||||||
|
{
|
||||||
|
host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST") ?? "";
|
||||||
|
sandboxTag = Environment.GetEnvironmentVariable("HISTORIAN_WRITE_SANDBOX_TAG") ?? "";
|
||||||
|
return !string.IsNullOrWhiteSpace(host)
|
||||||
|
&& !string.IsNullOrWhiteSpace(sandboxTag)
|
||||||
|
&& !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HistorianGrpcHandshake.Session OpenWriteSession(HistorianClientOptions o, HistorianGrpcConnection c)
|
||||||
|
=> HistorianGrpcHandshake.OpenSession(c, o, CancellationToken.None,
|
||||||
|
connectionMode: HistorianWcfAuthChainHelper.NativeIntegratedWriteEnabledConnectionMode);
|
||||||
|
|
||||||
|
private static bool TryGetEnv(out string host, out string tag)
|
||||||
|
{
|
||||||
|
host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST") ?? "";
|
||||||
|
tag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG") ?? "";
|
||||||
|
return !string.IsNullOrWhiteSpace(host)
|
||||||
|
&& !string.IsNullOrWhiteSpace(tag)
|
||||||
|
&& !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (DateTime StartUtc, DateTime EndUtc) LastSevenDays()
|
||||||
|
{
|
||||||
|
DateTime end = DateTime.UtcNow;
|
||||||
|
return (end - TimeSpan.FromDays(7), end);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int[] ParseIdleSweep()
|
||||||
|
{
|
||||||
|
string? raw = Environment.GetEnvironmentVariable("HISTORIAN_REUSE_IDLE_SECONDS");
|
||||||
|
if (string.IsNullOrWhiteSpace(raw)) return [0, 30];
|
||||||
|
return raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.Select(s => int.Parse(s, System.Globalization.CultureInfo.InvariantCulture)).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HistorianClientOptions BuildOptions(string host)
|
||||||
|
{
|
||||||
|
string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER");
|
||||||
|
string? password = Environment.GetEnvironmentVariable("HISTORIAN_PASSWORD");
|
||||||
|
bool explicitCreds = !string.IsNullOrEmpty(user);
|
||||||
|
int port = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_PORT"), out int parsed)
|
||||||
|
? parsed
|
||||||
|
: HistorianClientOptions.DefaultGrpcPort;
|
||||||
|
bool tls = string.Equals(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_TLS"), "true", StringComparison.OrdinalIgnoreCase);
|
||||||
|
// Optional per-call deadline override (seconds) for slow/remote boxes — heavier aggregate
|
||||||
|
// modes over a tunnelled link can exceed the 30s default. Falls back to the SDK default.
|
||||||
|
TimeSpan timeout = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_TIMEOUT"), out int secs) && secs > 0
|
||||||
|
? TimeSpan.FromSeconds(secs)
|
||||||
|
: new HistorianClientOptions { Host = host }.RequestTimeout;
|
||||||
|
|
||||||
|
return new HistorianClientOptions
|
||||||
|
{
|
||||||
|
Host = host,
|
||||||
|
Port = port,
|
||||||
|
Transport = HistorianTransport.RemoteGrpc,
|
||||||
|
GrpcUseTls = tls,
|
||||||
|
AllowUntrustedServerCertificate = tls,
|
||||||
|
ServerDnsIdentity = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_DNSID"),
|
||||||
|
IntegratedSecurity = !explicitCreds,
|
||||||
|
UserName = user ?? string.Empty,
|
||||||
|
Password = password ?? string.Empty,
|
||||||
|
RequestTimeout = timeout,
|
||||||
|
Compression = true // the stock client always advertises grpc gzip request encoding
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -90,6 +90,69 @@ public sealed class HistorianEventRowProtocolTests
|
|||||||
Assert.Equal(500, evt.Properties["priority"]);
|
Assert.Equal(500, evt.Properties["priority"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_Version11GrpcHeader_ParsesRowsIdenticalToV9()
|
||||||
|
{
|
||||||
|
// 2023 R2 gRPC returns the event-row buffer with container version 11; the per-row layout is
|
||||||
|
// byte-identical to the WCF v9 format. The parser must accept both (verified against a captured
|
||||||
|
// stock-client read of 50 Alarm.Set/Alarm.Clear rows whose header began 0B00 .. 1E000000 0700).
|
||||||
|
DateTime t1 = new(2026, 6, 23, 13, 34, 14, DateTimeKind.Utc);
|
||||||
|
DateTime t2 = t1.AddSeconds(10);
|
||||||
|
|
||||||
|
byte[] header = BuildHeader(2u, HistorianEventRowProtocol.EventRowProtocolVersionGrpc); // version 11
|
||||||
|
byte[] buffer = Concat(header, BuildRow(t1, "Alarm.Set", []), BuildRow(t2, "Alarm.Clear", []));
|
||||||
|
IReadOnlyList<HistorianEvent> events = HistorianEventRowProtocol.Parse(buffer);
|
||||||
|
|
||||||
|
Assert.Equal(2, events.Count);
|
||||||
|
Assert.Equal("Alarm.Set", events[0].Type);
|
||||||
|
Assert.Equal(t1, events[0].EventTimeUtc);
|
||||||
|
Assert.Equal("Alarm.Clear", events[1].Type);
|
||||||
|
Assert.Equal(t2, events[1].EventTimeUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verification against the PROVIDED 2023 R2 client: parse the real GetNextEventQueryResultBuffer
|
||||||
|
// result the stock client received (50 events), proving our read path decodes genuine gRPC event
|
||||||
|
// data. The capture carries customer identity so it is gitignored — point HISTORIAN_EVENT_CAPTURE_NDJSON
|
||||||
|
// at the captured ndjson to run; the test skips cleanly otherwise (no fixture committed).
|
||||||
|
[Fact]
|
||||||
|
public void Parse_RealStockClientCapture_DecodesAllEvents()
|
||||||
|
{
|
||||||
|
string? ndjson = Environment.GetEnvironmentVariable("HISTORIAN_EVENT_CAPTURE_NDJSON");
|
||||||
|
if (string.IsNullOrWhiteSpace(ndjson) || !File.Exists(ndjson))
|
||||||
|
{
|
||||||
|
return; // gated: no capture available
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[]? resultBuffer = null;
|
||||||
|
foreach (string line in File.ReadLines(ndjson))
|
||||||
|
{
|
||||||
|
if (!line.Contains("GetNextEventQueryResultBuffer.result.out")) continue;
|
||||||
|
int i = line.IndexOf("\"Base64\":\"", StringComparison.Ordinal);
|
||||||
|
if (i < 0) continue;
|
||||||
|
i += "\"Base64\":\"".Length;
|
||||||
|
int j = line.IndexOf('"', i);
|
||||||
|
resultBuffer = Convert.FromBase64String(line.Substring(i, j - i));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.NotNull(resultBuffer);
|
||||||
|
ushort version = BinaryPrimitives.ReadUInt16LittleEndian(resultBuffer.AsSpan(0, 2));
|
||||||
|
uint rowCount = BinaryPrimitives.ReadUInt32LittleEndian(resultBuffer.AsSpan(2, 4));
|
||||||
|
Assert.Equal(HistorianEventRowProtocol.EventRowProtocolVersionGrpc, version); // real gRPC buffer is v11
|
||||||
|
|
||||||
|
IReadOnlyList<HistorianEvent> events = HistorianEventRowProtocol.Parse(resultBuffer);
|
||||||
|
|
||||||
|
// Our parser decodes every row the stock client received.
|
||||||
|
Assert.Equal((int)rowCount, events.Count);
|
||||||
|
Assert.All(events, e =>
|
||||||
|
{
|
||||||
|
Assert.False(string.IsNullOrEmpty(e.Type));
|
||||||
|
Assert.NotEqual(default, e.EventTimeUtc);
|
||||||
|
});
|
||||||
|
// Sanitized cross-check: only the generic AVEVA event types (no customer fields asserted).
|
||||||
|
Assert.All(events, e => Assert.Contains(e.Type, new[] { "Alarm.Set", "Alarm.Clear" }));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Parse_UnknownTypeMarker_KeepsRawBytesInPropertyBag()
|
public void Parse_UnknownTypeMarker_KeepsRawBytesInPropertyBag()
|
||||||
{
|
{
|
||||||
@@ -120,11 +183,15 @@ public sealed class HistorianEventRowProtocolTests
|
|||||||
Assert.Equal("Alarm.Set", events[0].Type);
|
Assert.Equal("Alarm.Set", events[0].Type);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static byte[] BuildHeader(uint rowCount)
|
private static byte[] BuildHeader(uint rowCount) => BuildHeader(rowCount, HistorianEventRowProtocol.EventRowProtocolVersion);
|
||||||
|
|
||||||
|
private static byte[] BuildHeader(uint rowCount, ushort version)
|
||||||
{
|
{
|
||||||
byte[] header = new byte[6];
|
// version(2) + rowCount(4) + the single buffer-level header field (0x1E). Rows are markerless.
|
||||||
BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(0, 2), HistorianEventRowProtocol.EventRowProtocolVersion);
|
byte[] header = new byte[10];
|
||||||
|
BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(0, 2), version);
|
||||||
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(2, 4), rowCount);
|
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(2, 4), rowCount);
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(6, 4), HistorianEventRowProtocol.BufferHeaderField);
|
||||||
return header;
|
return header;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,13 +208,13 @@ public sealed class HistorianEventRowProtocolTests
|
|||||||
propertyBlockSize += propertyBlocks[i].Length;
|
propertyBlockSize += propertyBlocks[i].Length;
|
||||||
}
|
}
|
||||||
|
|
||||||
byte[] row = new byte[4 + 2 + 8 + 16 + eventTypeBytes.Length + 2 + propertyBlockSize];
|
// Markerless row: rowFormat(2) + filetime(8) + 8×UInt16 slots(16) + type + propCount + props.
|
||||||
|
byte[] row = new byte[2 + 8 + 16 + eventTypeBytes.Length + 2 + propertyBlockSize];
|
||||||
Span<byte> span = row;
|
Span<byte> span = row;
|
||||||
BinaryPrimitives.WriteUInt32LittleEndian(span[..4], HistorianEventRowProtocol.RowMarker);
|
BinaryPrimitives.WriteUInt16LittleEndian(span[..2], HistorianEventRowProtocol.RowFormat);
|
||||||
BinaryPrimitives.WriteUInt16LittleEndian(span.Slice(4, 2), HistorianEventRowProtocol.RowFormatV9);
|
BinaryPrimitives.WriteInt64LittleEndian(span.Slice(2, 8), eventTimeUtc.ToFileTimeUtc());
|
||||||
BinaryPrimitives.WriteInt64LittleEndian(span.Slice(6, 8), eventTimeUtc.ToFileTimeUtc());
|
|
||||||
// 16 bytes of zeroed slot ushorts left as-is.
|
// 16 bytes of zeroed slot ushorts left as-is.
|
||||||
int eventTypeOffset = 4 + 2 + 8 + 16;
|
int eventTypeOffset = 2 + 8 + 16;
|
||||||
eventTypeBytes.CopyTo(span[eventTypeOffset..]);
|
eventTypeBytes.CopyTo(span[eventTypeOffset..]);
|
||||||
BinaryPrimitives.WriteUInt16LittleEndian(span.Slice(eventTypeOffset + eventTypeBytes.Length, 2), propertyCount);
|
BinaryPrimitives.WriteUInt16LittleEndian(span.Slice(eventTypeOffset + eventTypeBytes.Length, 2), propertyCount);
|
||||||
int cursor = eventTypeOffset + eventTypeBytes.Length + 2;
|
int cursor = eventTypeOffset + eventTypeBytes.Length + 2;
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
using AVEVA.Historian.Client.Grpc;
|
||||||
|
using AVEVA.Historian.Client.Models;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
|
||||||
|
namespace AVEVA.Historian.Client.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Live end-to-end round-trip for <see cref="HistorianEventSession"/> (the v8 EVENT sibling of
|
||||||
|
/// <see cref="HistorianSessionRoundTripTests"/>): open ONE reusable event session, SendEvent on it
|
||||||
|
/// TWICE (no re-handshake/re-register between sends), ping once, dispose. Env-gated exactly like
|
||||||
|
/// <see cref="EventSessionReuseSpikeTests"/> (silent early-return skip without HISTORIAN_GRPC_HOST +
|
||||||
|
/// HISTORIAN_USER + HISTORIAN_PASSWORD + HISTORIAN_EVENT_SANDBOX_TAG). Every send targets the sandbox
|
||||||
|
/// identity ONLY (carried as the event SourceName/Type), never a production tag.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class HistorianEventSessionRoundTripTests
|
||||||
|
{
|
||||||
|
private readonly ITestOutputHelper _output;
|
||||||
|
|
||||||
|
public HistorianEventSessionRoundTripTests(ITestOutputHelper output) => _output = output;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EventSession_SendTwicePing_AllOnOneSession()
|
||||||
|
{
|
||||||
|
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
|
||||||
|
string? sandboxTag = Environment.GetEnvironmentVariable("HISTORIAN_EVENT_SANDBOX_TAG");
|
||||||
|
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(sandboxTag)
|
||||||
|
|| string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER"))
|
||||||
|
|| string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_PASSWORD")))
|
||||||
|
{
|
||||||
|
return; // skip — env not configured
|
||||||
|
}
|
||||||
|
|
||||||
|
HistorianClientOptions options = BuildOptions(host);
|
||||||
|
await using var client = new HistorianClient(options);
|
||||||
|
await using HistorianEventSession session = await client.OpenEventSessionAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
// 1) send TWICE on the SAME (open + CM_EVENT-registered) session — the 2nd skips ECDH+register.
|
||||||
|
for (int i = 0; i < 2; i++)
|
||||||
|
{
|
||||||
|
bool sent = await session.SendEventAsync(BuildSandboxEvent(sandboxTag, attempt: i), CancellationToken.None);
|
||||||
|
_output.WriteLine($"{i + 1}) reused-send[{i}] -> ok={sent}");
|
||||||
|
Assert.True(sent, $"reused v8 Event session send[{i}] should be accepted (AddStreamValues BSuccess).");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) ping on the SAME session — must not throw.
|
||||||
|
await session.PingAsync(CancellationToken.None);
|
||||||
|
_output.WriteLine("3) ping -> ok");
|
||||||
|
|
||||||
|
// 3) prove the keepalive op actually RETURNS DATA on the v8 Event handle (not just no-throw):
|
||||||
|
// issue the same underlying GetSystemParameter the ping uses, directly against the event
|
||||||
|
// session's connection + ClientHandle, and assert it yields a non-empty value.
|
||||||
|
string? keepalive = HistorianGrpcStatusClient.GetSystemParameterOnSession(
|
||||||
|
session.Connection, session.Session.ClientHandle, options, "HistorianVersion", CancellationToken.None);
|
||||||
|
Assert.False(string.IsNullOrEmpty(keepalive));
|
||||||
|
_output.WriteLine($"4) keepalive GetSystemParameter on event handle -> '{keepalive}'");
|
||||||
|
|
||||||
|
_output.WriteLine("event-session round-trip OK (two sends + ping + verified keepalive on one session)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a send event that targets the sandbox identity ONLY (mirrors EventSessionReuseSpikeTests.
|
||||||
|
// BuildSandboxEvent): the CM_EVENT send buffer carries no per-tag routing field, so the sandbox tag
|
||||||
|
// NAME is stamped into SourceName + Type + a marker Property so the appended event is unambiguously
|
||||||
|
// attributable to the sandbox — never a production tag. A fresh Id/timestamps per attempt.
|
||||||
|
private static HistorianEvent BuildSandboxEvent(string sandboxTag, int attempt)
|
||||||
|
{
|
||||||
|
DateTime now = DateTime.UtcNow;
|
||||||
|
return new HistorianEvent(
|
||||||
|
Id: Guid.NewGuid(),
|
||||||
|
EventTimeUtc: now.AddSeconds(-attempt),
|
||||||
|
ReceivedTimeUtc: now,
|
||||||
|
Type: sandboxTag,
|
||||||
|
SourceName: sandboxTag,
|
||||||
|
Namespace: "HistGW.EventSessionRoundTrip",
|
||||||
|
RevisionVersion: 0,
|
||||||
|
Properties: new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["RoundTripAttempt"] = attempt.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||||
|
["RoundTripMarker"] = "B1-event-session-roundtrip",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// verbatim copy of BuildOptions from HistorianSessionRoundTripTests / EventSessionReuseSpikeTests
|
||||||
|
private static HistorianClientOptions BuildOptions(string host)
|
||||||
|
{
|
||||||
|
string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER");
|
||||||
|
string? password = Environment.GetEnvironmentVariable("HISTORIAN_PASSWORD");
|
||||||
|
bool explicitCreds = !string.IsNullOrEmpty(user);
|
||||||
|
int port = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_PORT"), out int parsed)
|
||||||
|
? parsed
|
||||||
|
: HistorianClientOptions.DefaultGrpcPort;
|
||||||
|
bool tls = string.Equals(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_TLS"), "true", StringComparison.OrdinalIgnoreCase);
|
||||||
|
TimeSpan timeout = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_TIMEOUT"), out int secs) && secs > 0
|
||||||
|
? TimeSpan.FromSeconds(secs)
|
||||||
|
: new HistorianClientOptions { Host = host }.RequestTimeout;
|
||||||
|
|
||||||
|
return new HistorianClientOptions
|
||||||
|
{
|
||||||
|
Host = host,
|
||||||
|
Port = port,
|
||||||
|
Transport = HistorianTransport.RemoteGrpc,
|
||||||
|
GrpcUseTls = tls,
|
||||||
|
AllowUntrustedServerCertificate = tls,
|
||||||
|
ServerDnsIdentity = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_DNSID"),
|
||||||
|
IntegratedSecurity = !explicitCreds,
|
||||||
|
UserName = user ?? string.Empty,
|
||||||
|
Password = password ?? string.Empty,
|
||||||
|
RequestTimeout = timeout,
|
||||||
|
Compression = true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -233,6 +233,37 @@ public sealed class HistorianGrpcIntegrationTests
|
|||||||
Assert.Contains(samples, s => s.NumericValue is { } v && Math.Abs(v - expected) < 0.01);
|
Assert.Contains(samples, s => s.NumericValue is { } v && Math.Abs(v - expected) < 0.01);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendEventAsync_OverGrpc_AcceptsEvent()
|
||||||
|
{
|
||||||
|
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
|
||||||
|
// Gated additionally on a dedicated opt-in so this WRITE test never runs by accident — it
|
||||||
|
// appends a clearly-marked test event to the server's event history. Captured 2026-06-23:
|
||||||
|
// the gRPC event send rides HistoryService.AddStreamValues with the same "OS" buffer the WCF
|
||||||
|
// path uses (HistorianEventWriteProtocol), on a v8 Event session + CM_EVENT registration.
|
||||||
|
if (string.IsNullOrWhiteSpace(host)
|
||||||
|
|| string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER"))
|
||||||
|
|| Environment.GetEnvironmentVariable("HISTORIAN_GRPC_EVENT_SEND") is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
HistorianClient client = new(BuildOptions(host));
|
||||||
|
|
||||||
|
var evt = new HistorianEvent(
|
||||||
|
Id: Guid.NewGuid(),
|
||||||
|
EventTimeUtc: DateTime.UtcNow,
|
||||||
|
ReceivedTimeUtc: DateTime.UtcNow,
|
||||||
|
Type: "SdkSendProbe",
|
||||||
|
SourceName: "SdkSendProbe",
|
||||||
|
Namespace: "SdkCapture",
|
||||||
|
RevisionVersion: 0,
|
||||||
|
Properties: new Dictionary<string, object?> { ["SdkProbeProp"] = "SdkProbeValue" });
|
||||||
|
|
||||||
|
bool sent = await client.SendEventAsync(evt, CancellationToken.None);
|
||||||
|
Assert.True(sent, "gRPC SendEvent should be accepted by the server (AddStreamValues BSuccess).");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ReadAggregateAsync_OverGrpc_ReturnsTimeWeightedAverageRows()
|
public async Task ReadAggregateAsync_OverGrpc_ReturnsTimeWeightedAverageRows()
|
||||||
{
|
{
|
||||||
@@ -546,7 +577,7 @@ public sealed class HistorianGrpcIntegrationTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
throw new Xunit.Sdk.XunitException(
|
throw new Xunit.Sdk.XunitException(
|
||||||
$"[DIAG] outcome={outcome} | events={events.Count} | LastResultLen={orch.LastResultBufferLength} " +
|
$"[DIAG] channel={orch.EventChannelMode} outcome={outcome} | events={events.Count} | LastResultLen={orch.LastResultBufferLength} " +
|
||||||
$"| ResultHex={orch.LastResultBufferHex} | Reg=[{orch.RegistrationDiag}] " +
|
$"| ResultHex={orch.LastResultBufferHex} | Reg=[{orch.RegistrationDiag}] " +
|
||||||
$"| v8open={AVEVA.Historian.Client.Grpc.HistorianGrpcHandshake.LastEventOpenRequestHex}");
|
$"| v8open={AVEVA.Historian.Client.Grpc.HistorianGrpcHandshake.LastEventOpenRequestHex}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,8 +71,9 @@ public sealed class HistorianServerVersionGateTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Validate_VerificationDisabled_NeverThrows()
|
public void Validate_VerificationDisabled_NeverThrows()
|
||||||
{
|
{
|
||||||
// A wildly wrong version is tolerated when the operator opts out (e.g. bringing up a
|
// A wildly wrong version is tolerated when the operator opts out. The 2023 R2 gRPC
|
||||||
// 2023 R2 gRPC server whose reported integers have not yet been captured).
|
// integers are now captured live (see docs/reverse-engineering/grpc-interface-versions.md)
|
||||||
|
// and all accepted; this opt-out is a safety valve for some future, not-yet-captured value.
|
||||||
HistorianServerVersionGate.Validate(HistorianServiceInterface.History, 999u, Options(verify: false));
|
HistorianServerVersionGate.Validate(HistorianServiceInterface.History, 999u, Options(verify: false));
|
||||||
HistorianServerVersionGate.Validate(HistorianServiceInterface.Retrieval, 0u, Options(verify: false));
|
HistorianServerVersionGate.Validate(HistorianServiceInterface.Retrieval, 0u, Options(verify: false));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
using AVEVA.Historian.Client.Models;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
|
||||||
|
namespace AVEVA.Historian.Client.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Live end-to-end round-trip for <see cref="HistorianSession"/>: open → write → read → status →
|
||||||
|
/// ping → dispose — ALL on ONE reused session (no re-handshake). Env-gated exactly like
|
||||||
|
/// <see cref="HandshakeReuseSpikeTests"/> (silent skip without HISTORIAN_GRPC_HOST +
|
||||||
|
/// HISTORIAN_WRITE_SANDBOX_TAG + HISTORIAN_USER). Bounded write to the sandbox tag only.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class HistorianSessionRoundTripTests
|
||||||
|
{
|
||||||
|
private readonly ITestOutputHelper _output;
|
||||||
|
|
||||||
|
public HistorianSessionRoundTripTests(ITestOutputHelper output) => _output = output;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Session_WriteReadStatusPing_AllOnOneSession()
|
||||||
|
{
|
||||||
|
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
|
||||||
|
string? sandboxTag = Environment.GetEnvironmentVariable("HISTORIAN_WRITE_SANDBOX_TAG");
|
||||||
|
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(sandboxTag)
|
||||||
|
|| string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER")))
|
||||||
|
{
|
||||||
|
return; // skip — env not configured
|
||||||
|
}
|
||||||
|
|
||||||
|
HistorianClientOptions options = BuildOptions(host);
|
||||||
|
await using var client = new HistorianClient(options);
|
||||||
|
await using HistorianSession session = await client.OpenSessionAsync(HistorianSessionKind.WriteEnabled, CancellationToken.None);
|
||||||
|
|
||||||
|
// 1) write on the session
|
||||||
|
bool wrote = await session.AddHistoricalValuesAsync(sandboxTag,
|
||||||
|
new[] { new HistorianHistoricalValue(DateTime.UtcNow, 42.0, OpcQuality: 192) },
|
||||||
|
CancellationToken.None);
|
||||||
|
Assert.True(wrote);
|
||||||
|
_output.WriteLine("1) write -> ok");
|
||||||
|
|
||||||
|
// 2) read on the SAME session
|
||||||
|
DateTime end = DateTime.UtcNow, start = end - TimeSpan.FromDays(7);
|
||||||
|
var rows = new List<HistorianSample>();
|
||||||
|
await foreach (HistorianSample s in session.ReadRawAsync(sandboxTag, start, end, 8, CancellationToken.None))
|
||||||
|
rows.Add(s);
|
||||||
|
_output.WriteLine($"2) session read rows={rows.Count}");
|
||||||
|
Assert.NotEmpty(rows);
|
||||||
|
|
||||||
|
// 3) status + ping on the SAME session
|
||||||
|
string? version = await session.GetSystemParameterAsync("HistorianVersion", CancellationToken.None);
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(version));
|
||||||
|
_output.WriteLine($"3) HistorianVersion={version}");
|
||||||
|
|
||||||
|
await session.PingAsync(CancellationToken.None); // must not throw
|
||||||
|
_output.WriteLine("4) ping -> ok");
|
||||||
|
|
||||||
|
// 5) metadata + browse on the SAME session (no re-handshake)
|
||||||
|
HistorianTagMetadata? meta = await session.GetTagMetadataAsync(sandboxTag, CancellationToken.None);
|
||||||
|
Assert.NotNull(meta);
|
||||||
|
_output.WriteLine("5) metadata -> ok");
|
||||||
|
|
||||||
|
List<string> browsed = [];
|
||||||
|
await foreach (string n in session.BrowseTagNamesAsync("*", CancellationToken.None))
|
||||||
|
{
|
||||||
|
browsed.Add(n);
|
||||||
|
if (browsed.Count >= 5) break;
|
||||||
|
}
|
||||||
|
Assert.NotEmpty(browsed);
|
||||||
|
_output.WriteLine($"6) browse rows={browsed.Count}");
|
||||||
|
|
||||||
|
_output.WriteLine("session round-trip OK (write+read+status+ping+metadata+browse on one session)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// verbatim copy of BuildOptions from HandshakeReuseSpikeTests
|
||||||
|
private static HistorianClientOptions BuildOptions(string host)
|
||||||
|
{
|
||||||
|
string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER");
|
||||||
|
string? password = Environment.GetEnvironmentVariable("HISTORIAN_PASSWORD");
|
||||||
|
bool explicitCreds = !string.IsNullOrEmpty(user);
|
||||||
|
int port = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_PORT"), out int parsed)
|
||||||
|
? parsed
|
||||||
|
: HistorianClientOptions.DefaultGrpcPort;
|
||||||
|
bool tls = string.Equals(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_TLS"), "true", StringComparison.OrdinalIgnoreCase);
|
||||||
|
TimeSpan timeout = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_TIMEOUT"), out int secs) && secs > 0
|
||||||
|
? TimeSpan.FromSeconds(secs)
|
||||||
|
: new HistorianClientOptions { Host = host }.RequestTimeout;
|
||||||
|
|
||||||
|
return new HistorianClientOptions
|
||||||
|
{
|
||||||
|
Host = host,
|
||||||
|
Port = port,
|
||||||
|
Transport = HistorianTransport.RemoteGrpc,
|
||||||
|
GrpcUseTls = tls,
|
||||||
|
AllowUntrustedServerCertificate = tls,
|
||||||
|
ServerDnsIdentity = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_DNSID"),
|
||||||
|
IntegratedSecurity = !explicitCreds,
|
||||||
|
UserName = user ?? string.Empty,
|
||||||
|
Password = password ?? string.Empty,
|
||||||
|
RequestTimeout = timeout,
|
||||||
|
Compression = true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,6 +62,21 @@ public sealed class HistorianTagWriteProtocolTests
|
|||||||
+ "09120052657465737453646B5772697465496E7432"
|
+ "09120052657465737453646B5772697465496E7432"
|
||||||
+ "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"
|
+ "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"
|
||||||
+ "09180053444B2077726974652D52452073616E64626F78207461670904004D44415302010100000001E8030000549B0E36EDDBDC011A030904007465737410270000000000000000F03FFE00")]
|
+ "09180053444B2077726974652D52452073616E64626F78207461670904004D44415302010100000001E8030000549B0E36EDDBDC011A030904007465737410270000000000000000F03FFE00")]
|
||||||
|
// Int8/UInt8 (H1 un-gate): prefix is the captured Double buffer; only the data-type code byte differs.
|
||||||
|
[InlineData(
|
||||||
|
AVEVA.Historian.Client.Models.HistorianDataType.Int8,
|
||||||
|
"RetestSdkWriteDouble", 0x01dcdbed24988f3aL,
|
||||||
|
"4E6703000100000004C6021900000000000000000000000000000000"
|
||||||
|
+ "09140052657465737453646B5772697465446F75626C65"
|
||||||
|
+ "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"
|
||||||
|
+ "09180053444B2077726974652D52452073616E64626F78207461670904004D44415302010100000001E80300003A8F9824EDDBDC011A030904007465737410270000000000000000F03FFE00")]
|
||||||
|
[InlineData(
|
||||||
|
AVEVA.Historian.Client.Models.HistorianDataType.UInt8,
|
||||||
|
"RetestSdkWriteDouble", 0x01dcdbed24988f3aL,
|
||||||
|
"4E6703000100000004C6023900000000000000000000000000000000"
|
||||||
|
+ "09140052657465737453646B5772697465446F75626C65"
|
||||||
|
+ "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"
|
||||||
|
+ "09180053444B2077726974652D52452073616E64626F78207461670904004D44415302010100000001E80300003A8F9824EDDBDC011A030904007465737410270000000000000000F03FFE00")]
|
||||||
public void SerializeAnalogCTagMetadata_PerDataType_MatchesCapturedNativeBytes(
|
public void SerializeAnalogCTagMetadata_PerDataType_MatchesCapturedNativeBytes(
|
||||||
AVEVA.Historian.Client.Models.HistorianDataType dataType,
|
AVEVA.Historian.Client.Models.HistorianDataType dataType,
|
||||||
string tagName,
|
string tagName,
|
||||||
@@ -249,6 +264,9 @@ public sealed class HistorianTagWriteProtocolTests
|
|||||||
() => HistorianTagWriteProtocol.GetAnalogDataTypeCode(AVEVA.Historian.Client.Models.HistorianDataType.SingleByteString));
|
() => HistorianTagWriteProtocol.GetAnalogDataTypeCode(AVEVA.Historian.Client.Models.HistorianDataType.SingleByteString));
|
||||||
Assert.Throws<ProtocolEvidenceMissingException>(
|
Assert.Throws<ProtocolEvidenceMissingException>(
|
||||||
() => HistorianTagWriteProtocol.GetAnalogDataTypeCode(AVEVA.Historian.Client.Models.HistorianDataType.Int1));
|
() => HistorianTagWriteProtocol.GetAnalogDataTypeCode(AVEVA.Historian.Client.Models.HistorianDataType.Int1));
|
||||||
|
// UInt1 re-gated: historian creates a degenerate UInt1 analog tag (null type descriptor) — see pending notes.
|
||||||
|
Assert.Throws<ProtocolEvidenceMissingException>(
|
||||||
|
() => HistorianTagWriteProtocol.GetAnalogDataTypeCode(AVEVA.Historian.Client.Models.HistorianDataType.UInt1));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using AVEVA.Historian.Client.Grpc;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AVEVA.Historian.Client.Tests;
|
||||||
|
|
||||||
|
public class TagClientOnSessionSeamTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("BrowseTagNamesOnSession")]
|
||||||
|
[InlineData("GetTagInfosRawOnSession")]
|
||||||
|
[InlineData("GetTagMetadataOnSession")]
|
||||||
|
public void TagClient_ExposesOnSessionSeam(string name)
|
||||||
|
{
|
||||||
|
MethodInfo? m = typeof(HistorianGrpcTagClient).GetMethod(
|
||||||
|
name, BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Public);
|
||||||
|
Assert.NotNull(m);
|
||||||
|
ParameterInfo[] ps = m!.GetParameters();
|
||||||
|
Assert.Equal("HistorianGrpcConnection", ps[0].ParameterType.Name);
|
||||||
|
Assert.Equal("Session", ps[1].ParameterType.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
using System.Runtime.Versioning;
|
||||||
|
using AVEVA.Historian.Client.Models;
|
||||||
|
using AVEVA.Historian.Client.Wcf;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
|
||||||
|
namespace AVEVA.Historian.Client.Tests;
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// C2 live diagnostic spike (pending.md C2): does the managed WCF event-read path return rows
|
||||||
|
/// against an event-bearing 2023 R2 historian? gRPC event reads are a proven server-side dead-end
|
||||||
|
/// (docs/reverse-engineering/grpc-event-query-capture.md); the WCF transport is C2's only listed
|
||||||
|
/// unblock but is itself unproven (the orchestrator documents native code 76 / 85 on this server).
|
||||||
|
///
|
||||||
|
/// Drives <see cref="HistorianWcfEventOrchestrator"/> directly over RemoteTcpIntegrated and dumps the
|
||||||
|
/// full native chain. It NEVER fails the suite (skip+log diagnostic) and is inert off Windows — the
|
||||||
|
/// GREEN/RED call is by reading the printed "events observed" count + native return codes.
|
||||||
|
///
|
||||||
|
/// Gated by <c>HISTORIAN_WCF_EVENT_HOST</c> (independent of the gRPC live vars so it never runs by
|
||||||
|
/// accident). Optional: <c>HISTORIAN_WCF_EVENT_PORT</c> (default 32568), <c>HISTORIAN_WCF_EVENT_USER</c>
|
||||||
|
/// + <c>HISTORIAN_WCF_EVENT_PASSWORD</c> (absent => IntegratedSecurity), <c>HISTORIAN_WCF_EVENT_SPN</c>
|
||||||
|
/// (Kerberos SPN override; the default is the LocalPipe identity and will not authenticate remotely),
|
||||||
|
/// <c>HISTORIAN_WCF_EVENT_DAYS</c> (lookback window, default 90 — the live event store held 71,332
|
||||||
|
/// events in -90d).
|
||||||
|
///
|
||||||
|
/// Run from the Windows capture rig over VPN:
|
||||||
|
/// dotnet test --filter "FullyQualifiedName~WcfEventReadSpike" -l "console;verbosity=detailed"
|
||||||
|
/// </remarks>
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
public sealed class WcfEventReadSpikeTests
|
||||||
|
{
|
||||||
|
private readonly ITestOutputHelper _output;
|
||||||
|
|
||||||
|
public WcfEventReadSpikeTests(ITestOutputHelper output) => _output = output;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WcfEventRead_DiagnosticDump_AgainstRemoteHistorian()
|
||||||
|
{
|
||||||
|
string? host = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_HOST");
|
||||||
|
bool certificate = string.Equals(
|
||||||
|
Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_TRANSPORT"), "certificate", StringComparison.OrdinalIgnoreCase);
|
||||||
|
// The certificate (TLS) transport and NegotiateAuthentication-based app auth are cross-platform;
|
||||||
|
// the integrated (Windows SSPI transport-security) transport is Windows-only. Skip when the gate
|
||||||
|
// is unconfigured, or when an integrated run is requested off Windows.
|
||||||
|
if (string.IsNullOrWhiteSpace(host) || (!certificate && !OperatingSystem.IsWindows()))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int port = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_PORT"), out int p)
|
||||||
|
? p : HistorianClientOptions.DefaultPort; // 32568
|
||||||
|
int days = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_DAYS"), out int d)
|
||||||
|
? d : 90;
|
||||||
|
string? user = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_USER");
|
||||||
|
string? password = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_PASSWORD");
|
||||||
|
string? spn = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_SPN");
|
||||||
|
string? dnsId = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_DNSID");
|
||||||
|
string? via = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_VIA");
|
||||||
|
bool bypassVersion = string.Equals(
|
||||||
|
Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_NOVERSIONCHECK"), "1", StringComparison.Ordinal);
|
||||||
|
int timeoutSec = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_TIMEOUT_SEC"), out int ts) && ts > 0
|
||||||
|
? ts : 30;
|
||||||
|
string? connModeRaw = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_CONNMODE");
|
||||||
|
uint? connModeOverride = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(connModeRaw))
|
||||||
|
{
|
||||||
|
string hex = connModeRaw.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? connModeRaw[2..] : connModeRaw;
|
||||||
|
if (uint.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null, out uint cm)) { connModeOverride = cm; }
|
||||||
|
}
|
||||||
|
bool allowUntrusted = string.Equals(
|
||||||
|
Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_ALLOW_UNTRUSTED"), "1", StringComparison.Ordinal);
|
||||||
|
// Certificate transport carries no Windows transport credential (cert-validated TLS channel);
|
||||||
|
// integrated derives one from the logged-in identity unless an explicit DOMAIN\user is supplied.
|
||||||
|
// App-level ValidateClientCredential still uses UserName/Password (or the process identity).
|
||||||
|
bool integrated = !certificate && string.IsNullOrEmpty(user);
|
||||||
|
|
||||||
|
HistorianClientOptions options = new()
|
||||||
|
{
|
||||||
|
Host = host,
|
||||||
|
Port = port,
|
||||||
|
Transport = certificate ? HistorianTransport.RemoteTcpCertificate : HistorianTransport.RemoteTcpIntegrated,
|
||||||
|
IntegratedSecurity = integrated,
|
||||||
|
UserName = user ?? string.Empty,
|
||||||
|
Password = password ?? string.Empty,
|
||||||
|
TargetSpn = string.IsNullOrWhiteSpace(spn) ? "NT SERVICE\\aahClientAccessPoint" : spn,
|
||||||
|
ServerDnsIdentity = string.IsNullOrWhiteSpace(dnsId) ? null : dnsId,
|
||||||
|
AllowUntrustedServerCertificate = allowUntrusted,
|
||||||
|
ConnectViaAddress = string.IsNullOrWhiteSpace(via) ? null : via,
|
||||||
|
VerifyServerInterfaceVersion = !bypassVersion,
|
||||||
|
ConnectTimeout = TimeSpan.FromSeconds(timeoutSec),
|
||||||
|
RequestTimeout = TimeSpan.FromSeconds(timeoutSec),
|
||||||
|
EventReadConnectionModeOverride = connModeOverride,
|
||||||
|
};
|
||||||
|
|
||||||
|
HistorianWcfEventOrchestrator orchestrator = new(options);
|
||||||
|
DateTime endUtc = DateTime.UtcNow;
|
||||||
|
DateTime startUtc = endUtc - TimeSpan.FromDays(days);
|
||||||
|
|
||||||
|
int budgetSec = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_BUDGET_SEC"), out int bgs) && bgs > 0
|
||||||
|
? bgs : 60;
|
||||||
|
|
||||||
|
int observed = 0;
|
||||||
|
bool hasFirstEvent = false;
|
||||||
|
string outcome = "completed";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Race the read against an overall budget. The chain is synchronous WCF, so a stuck call
|
||||||
|
// can't be token-cancelled — but the orchestrator's static/instance diagnostics are set as it
|
||||||
|
// progresses, so on a budget timeout we still dump them to see which phase (auth / CM_EVENT
|
||||||
|
// registration / query) is hanging. The abandoned task keeps running in the background harness.
|
||||||
|
using var budget = new CancellationTokenSource(TimeSpan.FromSeconds(budgetSec));
|
||||||
|
Task<int> readTask = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
int n = 0;
|
||||||
|
await foreach (HistorianEvent evt in orchestrator.ReadEventsAsync(startUtc, endUtc, filter: null, budget.Token))
|
||||||
|
{
|
||||||
|
n++;
|
||||||
|
_ = evt; // event identity intentionally NOT logged (sanitized)
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
|
||||||
|
Task finished = await Task.WhenAny(readTask, Task.Delay(TimeSpan.FromSeconds(budgetSec + 5)));
|
||||||
|
if (finished == readTask)
|
||||||
|
{
|
||||||
|
observed = await readTask;
|
||||||
|
hasFirstEvent = observed > 0;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
outcome = $"TIMED OUT after {budgetSec}s (chain still running — see return codes for phase reached)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Default: type name only (sanitized). Opt into messages for live binding/protocol debugging
|
||||||
|
// via HISTORIAN_WCF_EVENT_VERBOSE=1 — binding/protocol errors may carry the endpoint host
|
||||||
|
// (already known to the operator) but never credentials; still off by default.
|
||||||
|
outcome = string.Equals(Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_VERBOSE"), "1", StringComparison.Ordinal)
|
||||||
|
? $"threw {ex.GetType().FullName} :: {ex.Message} | inner={ex.InnerException?.GetType().FullName} :: {ex.InnerException?.Message}"
|
||||||
|
: $"threw {ex.GetType().Name}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitized diagnostic dump — counts, native return codes, buffer lengths, sha256 ONLY.
|
||||||
|
_output.WriteLine($"[C2 WCF spike] transport: {options.Transport} (integratedSec={integrated}, allowUntrusted={allowUntrusted}, connMode={(connModeOverride.HasValue ? "0x" + connModeOverride.Value.ToString("X") : "default-0x402")})");
|
||||||
|
_output.WriteLine($"[C2 WCF spike] outcome: {outcome}");
|
||||||
|
_output.WriteLine($"[C2 WCF spike] events observed: {observed}");
|
||||||
|
_output.WriteLine($"[C2 WCF spike] hasFirstEvent: {hasFirstEvent}");
|
||||||
|
_output.WriteLine($"[C2 WCF spike] LastUpdC3ReturnCode: {HistorianWcfEventOrchestrator.LastUpdC3ReturnCode}");
|
||||||
|
_output.WriteLine($"[C2 WCF spike] LastRTag2ReturnCode: {HistorianWcfEventOrchestrator.LastRTag2ReturnCode}");
|
||||||
|
_output.WriteLine($"[C2 WCF spike] LastAddReturnCode(EnsT2): {HistorianWcfEventOrchestrator.LastAddReturnCode}");
|
||||||
|
_output.WriteLine($"[C2 WCF spike] LastAddOutputLength: {HistorianWcfEventOrchestrator.LastAddOutputLength}");
|
||||||
|
_output.WriteLine($"[C2 WCF spike] LastEnsT2PayloadSha256: {HistorianWcfEventOrchestrator.LastEnsT2PayloadSha256}");
|
||||||
|
_output.WriteLine($"[C2 WCF spike] LastResultBufferLength: {orchestrator.LastResultBufferLength}");
|
||||||
|
// Contract-safe: LastErrorBufferDescription is DescribeNativeError's STRUCTURED formatting of the
|
||||||
|
// 5-byte native error buffer ("type=N code=M (0xHEX)" / "<short>") — never freeform server text,
|
||||||
|
// FQDN, SPN, or credentials. The code value (e.g. 76 / 85) is the RED-case signal this spike exists
|
||||||
|
// to capture, so it is dumped in full rather than reduced to a length.
|
||||||
|
_output.WriteLine($"[C2 WCF spike] LastErrorBufferDescription: {orchestrator.LastErrorBufferDescription}");
|
||||||
|
_output.WriteLine($"[C2 WCF spike] window days: {days}");
|
||||||
|
|
||||||
|
// Diagnostic: NEVER fails the suite. GREEN = observed > 0; RED = observed == 0 (read the output).
|
||||||
|
Assert.True(observed >= 0, "diagnostic always passes; the GREEN/RED signal is the printed count");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,11 @@ public sealed class WcfHistoricalWriteProtocolTests
|
|||||||
"4f4e0100380000002e00" + "ca9735f7f841b244b56f9c14ccfeac32" + "b09bc72fd701dd01" + "c000" + "c0100100" + "104b59f3e701dd01" + "0000000039300000")]
|
"4f4e0100380000002e00" + "ca9735f7f841b244b56f9c14ccfeac32" + "b09bc72fd701dd01" + "c000" + "c0100100" + "104b59f3e701dd01" + "0000000039300000")]
|
||||||
[InlineData(HistorianDataType.UInt4, 305419896d,
|
[InlineData(HistorianDataType.UInt4, 305419896d,
|
||||||
"4f4e0100380000002e00" + "e7ae22d8e4cc65439ebd8bcb09402974" + "602d6663d701dd01" + "c000" + "c0100100" + "498af726e801dd01" + "0000000078563412")]
|
"4f4e0100380000002e00" + "e7ae22d8e4cc65439ebd8bcb09402974" + "602d6663d701dd01" + "c000" + "c0100100" + "498af726e801dd01" + "0000000078563412")]
|
||||||
|
// Int8/UInt8 (H1 un-gate): prefix is the captured Double buffer; only the value bytes differ. Live round-trip confirms (gateway HistorianTypeRoundTripTests).
|
||||||
|
[InlineData(HistorianDataType.Int8, 100000d,
|
||||||
|
"4f4e01003c0000003200" + "7f970bb0b5a7344bb7bf6899ff06d027" + "c07d5f23d701dd01" + "c000" + "c0100100" + "08eff1e6e701dd01" + "00000000a086010000000000")]
|
||||||
|
[InlineData(HistorianDataType.UInt8, 100001d,
|
||||||
|
"4f4e01003c0000003200" + "7f970bb0b5a7344bb7bf6899ff06d027" + "c07d5f23d701dd01" + "c000" + "c0100100" + "08eff1e6e701dd01" + "00000000a186010000000000")]
|
||||||
public void SerializeAddStreamValuesBuffer_MatchesCapturedNativeBuffer(HistorianDataType dataType, double value, string capturedHex)
|
public void SerializeAddStreamValuesBuffer_MatchesCapturedNativeBuffer(HistorianDataType dataType, double value, string capturedHex)
|
||||||
{
|
{
|
||||||
byte[] captured = Convert.FromHexString(capturedHex);
|
byte[] captured = Convert.FromHexString(capturedHex);
|
||||||
@@ -47,6 +52,10 @@ public sealed class WcfHistoricalWriteProtocolTests
|
|||||||
Assert.Throws<ProtocolEvidenceMissingException>(() =>
|
Assert.Throws<ProtocolEvidenceMissingException>(() =>
|
||||||
HistorianHistoricalWriteProtocol.SerializeAddStreamValuesBuffer(
|
HistorianHistoricalWriteProtocol.SerializeAddStreamValuesBuffer(
|
||||||
Guid.NewGuid(), HistorianDataType.SingleByteString, DateTime.UtcNow, 1.0, DateTime.UtcNow));
|
Guid.NewGuid(), HistorianDataType.SingleByteString, DateTime.UtcNow, 1.0, DateTime.UtcNow));
|
||||||
|
// UInt1 re-gated: historian creates a degenerate UInt1 analog tag (null type descriptor) — see pending notes.
|
||||||
|
Assert.Throws<ProtocolEvidenceMissingException>(() =>
|
||||||
|
HistorianHistoricalWriteProtocol.SerializeAddStreamValuesBuffer(
|
||||||
|
Guid.NewGuid(), HistorianDataType.UInt1, DateTime.UtcNow, 1.0, DateTime.UtcNow));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -92,8 +92,10 @@ namespace AVEVA.Historian.Grpc2023CaptureHarness
|
|||||||
return DeleteTag(managedDll, args);
|
return DeleteTag(managedDll, args);
|
||||||
case "capture-event":
|
case "capture-event":
|
||||||
return CaptureEvent(managedDll, args);
|
return CaptureEvent(managedDll, args);
|
||||||
|
case "capture-send-event":
|
||||||
|
return CaptureSendEvent(managedDll, args);
|
||||||
default:
|
default:
|
||||||
Console.Error.WriteLine($"Unknown scenario '{scenario}'. Supported: load-check, connect, capture-write, delete-tag, capture-event.");
|
Console.Error.WriteLine($"Unknown scenario '{scenario}'. Supported: load-check, connect, capture-write, delete-tag, capture-event, capture-send-event.");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -603,6 +605,132 @@ namespace AVEVA.Historian.Grpc2023CaptureHarness
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drives the native 2023 R2 client through an event SEND so the IL-rewritten GrpcClient dumps
|
||||||
|
/// the AddStreamValues.btValues (the event VTQ storage-sample buffer — resolves whether a gRPC
|
||||||
|
/// event send uses the "OS" or "ON" outer signature) AND the Event-connection EnsureTags.btTagInfos
|
||||||
|
/// (the 83-vs-86-byte CM_EVENT registration byte-diff). Opens a WRITE-ENABLED Event connection,
|
||||||
|
/// builds a clearly-marked test HistorianEvent, calls AddStreamedValue, then CloseConnection to
|
||||||
|
/// flush the queued event onto the wire. WRITES a real test event into the server's event history.
|
||||||
|
/// Run with --grpc-rewrite pointing at the instrumented copy and AVEVA_HISTORIAN_RE_CAPTURE set.
|
||||||
|
/// Usage: capture-send-event [--server <host>] [--port 32565] [--cert <host>]
|
||||||
|
/// [--event-type SdkCaptureProbe] [--flush-seconds 6]
|
||||||
|
/// </summary>
|
||||||
|
private static int CaptureSendEvent(string managedDll, string[] args)
|
||||||
|
{
|
||||||
|
Assembly asm = Assembly.LoadFrom(managedDll);
|
||||||
|
Type accessType = Req(asm, "ArchestrA.HistorianAccess");
|
||||||
|
Type connArgsType = Req(asm, "ArchestrA.HistorianConnectionArgs");
|
||||||
|
Type connModeType = Req(asm, "ArchestrA.HistorianConnectionMode");
|
||||||
|
Type connTypeType = Req(asm, "ArchestrA.HistorianConnectionType");
|
||||||
|
Type errorType = Req(asm, "ArchestrA.HistorianAccessError");
|
||||||
|
Type eventType = Req(asm, "ArchestrA.HistorianEvent");
|
||||||
|
Type propTypeEnum = Req(asm, "ArchestrA.HistorianEventPropertyType");
|
||||||
|
Type certInfoType = Req(asm, "ArchestrA.CertificateInfo");
|
||||||
|
Type secModeType = Req(asm, "ArchestrA.HistorianSecurityMode");
|
||||||
|
|
||||||
|
string server = GetOption(args, "--server") ?? Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST") ?? "localhost";
|
||||||
|
int port = int.TryParse(GetOption(args, "--port"), out int p) ? p : 32565;
|
||||||
|
string certName = GetOption(args, "--cert") ?? server;
|
||||||
|
string evtTypeName = GetOption(args, "--event-type") ?? "SdkCaptureProbe";
|
||||||
|
int flushSeconds = int.TryParse(GetOption(args, "--flush-seconds"), out int fs) ? fs : 6;
|
||||||
|
string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER");
|
||||||
|
string? password = Environment.GetEnvironmentVariable("HISTORIAN_PASSWORD");
|
||||||
|
if (string.IsNullOrEmpty(user))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("Set HISTORIAN_USER/HISTORIAN_PASSWORD.");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("AVEVA_HISTORIAN_RE_CAPTURE")))
|
||||||
|
{
|
||||||
|
string defaultCap = Path.GetFullPath(Path.Combine(
|
||||||
|
"artifacts", "reverse-engineering", "grpc-event-capture", "send-event-capture.ndjson"));
|
||||||
|
Environment.SetEnvironmentVariable("AVEVA_HISTORIAN_RE_CAPTURE", defaultCap);
|
||||||
|
}
|
||||||
|
Console.WriteLine($"Capture sink: {Environment.GetEnvironmentVariable("AVEVA_HISTORIAN_RE_CAPTURE")}");
|
||||||
|
|
||||||
|
object connArgs = Activator.CreateInstance(connArgsType)!;
|
||||||
|
SetProp(connArgs, "ServerName", server);
|
||||||
|
SetProp(connArgs, "TcpPort", checked((ushort)port));
|
||||||
|
SetProp(connArgs, "ConnectionMode", Enum.Parse(connModeType, "Historian"));
|
||||||
|
SetProp(connArgs, "ConnectionType", Enum.Parse(connTypeType, "Event")); // EVENT connection
|
||||||
|
SetProp(connArgs, "ReadOnly", false); // WRITE-enabled
|
||||||
|
SetProp(connArgs, "IntegratedSecurity", false);
|
||||||
|
SetProp(connArgs, "AllowUnTrustedConnection", true);
|
||||||
|
SetProp(connArgs, "UserName", user);
|
||||||
|
SetProp(connArgs, "Password", password ?? string.Empty);
|
||||||
|
object certInfo = Activator.CreateInstance(certInfoType)!;
|
||||||
|
TrySetProp(certInfo, "CertificateName", certName);
|
||||||
|
TrySetProp(certInfo, "SecurityMode", Enum.Parse(secModeType, "TransportCertificate"));
|
||||||
|
TrySetProp(connArgs, "SecurityInfo", certInfo);
|
||||||
|
|
||||||
|
object access = Activator.CreateInstance(accessType)!;
|
||||||
|
object?[] openArgs = { connArgs, Activator.CreateInstance(errorType) };
|
||||||
|
Console.WriteLine($"OpenConnection: server={server} port={port} type=Event readonly=false");
|
||||||
|
bool opened;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
opened = (bool)accessType.GetMethod("OpenConnection", new[] { connArgsType, errorType.MakeByRefType() })!
|
||||||
|
.Invoke(access, openArgs)!;
|
||||||
|
}
|
||||||
|
catch (TargetInvocationException tie)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"OpenConnection threw: {tie.InnerException?.GetType().Name}: {tie.InnerException?.Message}");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
Console.WriteLine($"OpenConnection returned: {opened} err={DescribeError(openArgs[1])}");
|
||||||
|
if (!opened) { return 2; }
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Build a clearly-marked test event. Required: Type (≤32 chars), Id, EventTime.
|
||||||
|
object evt = Activator.CreateInstance(eventType)!;
|
||||||
|
SetProp(evt, "Type", evtTypeName);
|
||||||
|
TrySetProp(evt, "Id", Guid.NewGuid());
|
||||||
|
TrySetProp(evt, "EventTime", DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Utc));
|
||||||
|
TrySetProp(evt, "Namespace", "SdkCapture");
|
||||||
|
TrySetProp(evt, "Source", "SdkCaptureProbe");
|
||||||
|
|
||||||
|
// One string property to exercise the property-bag framing.
|
||||||
|
MethodInfo? addProp = eventType.GetMethods().FirstOrDefault(m =>
|
||||||
|
m.Name == "AddProperty" && m.GetParameters().Length == 4);
|
||||||
|
if (addProp != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
object strEnum = Enum.Parse(propTypeEnum, "String", true);
|
||||||
|
object?[] apArgs = { "SdkProbeProp", "SdkProbeValue", strEnum, Activator.CreateInstance(errorType) };
|
||||||
|
addProp.Invoke(evt, apArgs);
|
||||||
|
Console.WriteLine($"AddProperty: err={DescribeError(apArgs[3])}");
|
||||||
|
}
|
||||||
|
catch (Exception ex) { Console.WriteLine($"AddProperty skipped: {ex.GetType().Name}"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
MethodInfo addStreamed = accessType.GetMethods().First(m =>
|
||||||
|
m.Name == "AddStreamedValue" && m.GetParameters().Length == 2
|
||||||
|
&& m.GetParameters()[0].ParameterType == eventType);
|
||||||
|
object?[] asArgs = { evt, Activator.CreateInstance(errorType) };
|
||||||
|
bool sent = (bool)addStreamed.Invoke(access, asArgs)!;
|
||||||
|
Console.WriteLine($"AddStreamedValue({evtTypeName}): {sent} err={DescribeError(asArgs[1])}");
|
||||||
|
|
||||||
|
// Let the native delivery queue flush the event onto the wire (AddStreamValues).
|
||||||
|
System.Threading.Thread.Sleep(flushSeconds * 1000);
|
||||||
|
Console.WriteLine(sent ? "CAPTURE-SEND-EVENT: AddStreamedValue accepted (buffer captured on flush)" : "CAPTURE-SEND-EVENT: AddStreamedValue rejected");
|
||||||
|
return sent ? 0 : 3;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// CloseConnection flushes any remaining queued values before teardown.
|
||||||
|
MethodInfo? close = accessType.GetMethod("CloseConnection", new[] { errorType.MakeByRefType() });
|
||||||
|
if (close != null) close.Invoke(access, new object?[] { Activator.CreateInstance(errorType) });
|
||||||
|
}
|
||||||
|
catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Read-only gRPC connect probe: opens a 2023 R2 Historian (mode=Historian) connection via the
|
/// Read-only gRPC connect probe: opens a 2023 R2 Historian (mode=Historian) connection via the
|
||||||
/// native client and reports the resulting connection status. Proves the mixed-mode client can
|
/// native client and reports the resulting connection status. Proves the mixed-mode client can
|
||||||
|
|||||||
Reference in New Issue
Block a user