Merge grpc-config-ops: tool the WCF-only config ops over gRPC + completion plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
This commit is contained in:
@@ -66,8 +66,11 @@ Open2 buffers and SSPI tokens on both — on gRPC they simply ride inside protob
|
|||||||
`bytes` fields — so reads are at parity. The surfaces diverge at the edges.
|
`bytes` fields — so reads are at parity. The surfaces diverge at the edges.
|
||||||
|
|
||||||
Legend: ✅ tooled + live-verified · ⚠️ tooled, partial/synthesized ·
|
Legend: ✅ tooled + live-verified · ⚠️ tooled, partial/synthesized ·
|
||||||
🔌 **the gRPC server exposes the RPC (recovered in `Grpc/Protos/*.proto`) but the
|
🧪 tooled + routed but **sandbox-gated** (mutates server state, not yet run
|
||||||
SDK doesn't drive it yet** — untooled/uncaptured, *not* a protocol gap ·
|
destructively against a live box) · 🔌 **the gRPC server exposes the RPC
|
||||||
|
(recovered in `Grpc/Protos/*.proto`) but the SDK doesn't drive it yet** —
|
||||||
|
untooled/uncaptured, *not* a protocol gap · ⛔ tooled but **server-walled** (the
|
||||||
|
request rides the RPC but the server faults on an unmet precondition) ·
|
||||||
❌ unavailable on that transport.
|
❌ unavailable on that transport.
|
||||||
|
|
||||||
| Operation | WCF | gRPC | Notes |
|
| Operation | WCF | gRPC | Notes |
|
||||||
@@ -82,27 +85,32 @@ SDK doesn't drive it yet** — untooled/uncaptured, *not* a protocol gap ·
|
|||||||
| `AddHistoricalValuesAsync` | ❌ | ✅ | historical/backfill writes ride `HistoryService.AddStreamValues`; non-gRPC throws `ProtocolEvidenceMissingException` |
|
| `AddHistoricalValuesAsync` | ❌ | ✅ | historical/backfill writes ride `HistoryService.AddStreamValues`; non-gRPC throws `ProtocolEvidenceMissingException` |
|
||||||
| `GetServerTimeZoneAsync` | ❌ | ✅ | 2020 `GetSystemTimeZoneName` is a client-side stub (empty); WCF throws |
|
| `GetServerTimeZoneAsync` | ❌ | ✅ | 2020 `GetSystemTimeZoneName` is a client-side stub (empty); WCF throws |
|
||||||
| `GetStoreForwardStatusAsync` | ⚠️ | ✅ | gRPC contacts the server (measured idle-state, reports `ErrorOccurred`); WCF returns synthesized all-false. Active-SF magnitude is D2-gated on both |
|
| `GetStoreForwardStatusAsync` | ⚠️ | ✅ | gRPC contacts the server (measured idle-state, reports `ErrorOccurred`); WCF returns synthesized all-false. Active-SF magnitude is D2-gated on both |
|
||||||
| `ReadEventsAsync` | ✅ | 🔌 | gRPC `RetrievalService.StartEventQuery` / `GetNextEventQueryResultBuffer` / `EndEventQuery` recovered (`bytes btRequest` + handle); not tooled over gRPC |
|
| `GetRuntimeParameterAsync` | ✅ | ✅ | tooled + live-verified over gRPC (`StatusService.GetRuntimeParameter`, the 2020 `GETRP` buffers ride unchanged) |
|
||||||
|
| `GetTagExtendedPropertiesAsync` | ✅ | ✅ | tooled + live-verified over gRPC (`RetrievalService.GetTagExtendedPropertiesFromName`, the `GetTepByNm` buffers ride unchanged) |
|
||||||
|
| `ExecuteSqlCommandAsync` | ✅ | ⛔ | gRPC request rides `RetrievalService.ExecuteSqlCommand`, but the server-side `CSrvDbConnection.ExecuteSqlCommand` faults (`IndexOutOfRange`, native err 38) — an unmet DB-connection precondition; bounded behind `ProtocolEvidenceMissingException`. Use WCF |
|
||||||
|
| `ReadEventsAsync` | ✅ | 🔌 | gRPC `StartEventQuery`/`GetNextEventQueryResultBuffer`/`EndEventQuery` recovered, but the read needs the full CM_EVENT registration state machine (RTag2+EnsT2) ported — not yet tooled |
|
||||||
| `SendEventAsync` | ✅ | 🔌 | rides `AddStreamValues` family; no distinct event-send RPC, framing uncaptured over gRPC |
|
| `SendEventAsync` | ✅ | 🔌 | rides `AddStreamValues` family; no distinct event-send RPC, framing uncaptured over gRPC |
|
||||||
| `EnsureTagAsync` / `DeleteTagAsync` / `RenameTagsAsync` | ✅ | 🔌 | gRPC `HistoryService.EnsureTags` / `DeleteTags` / `StartJob`(+`GetJobStatus`) recovered (`bytes btTagInfos`/`btTagnames`/`btInput` + handle) |
|
| `EnsureTagAsync` / `DeleteTagAsync` / `RenameTagsAsync` | ✅ | 🧪 | tooled + routed over gRPC (`HistoryService.EnsureTags` / `DeleteTags` / `StartJob`, write-enabled 0x401 session, WCF serializers reused); sandbox-gated — not yet run destructively against a live box |
|
||||||
| `GetTagExtendedPropertiesAsync` / `AddTagExtendedPropertiesAsync` | ✅ | 🔌 | gRPC `RetrievalService.GetTagExtendedPropertiesFromName` + `HistoryService.AddTagExtendedProperties`; gRPC also exposes `DeleteTagExtendedProperties` (WCF delete was server-blocked) |
|
| `AddTagExtendedPropertiesAsync` | ✅ | 🧪 | tooled + routed over gRPC (`HistoryService.AddTagExtendedProperties`, write-enabled session); sandbox-gated. gRPC also exposes `DeleteTagExtendedProperties` (WCF delete was server-blocked) |
|
||||||
| `ExecuteSqlCommandAsync` | ✅ | 🔌 | gRPC `RetrievalService.ExecuteSqlCommand` (`StrCommand` + `uiOption`, mirrors WCF `ExeC`/`GetR`) |
|
|
||||||
| `GetRuntimeParameterAsync` | ✅ | 🔌 | gRPC `StatusService.GetRuntimeParameter` (`bytes btRequest` + handle) |
|
|
||||||
| `GetConnectionStatusAsync` | ✅ | ❌ | synthesized from an authenticated probe — no dedicated RPC on either transport (gRPC `PingServer`/`GetHistorianConsoleStatus` could synthesize it) |
|
| `GetConnectionStatusAsync` | ✅ | ❌ | synthesized from an authenticated probe — no dedicated RPC on either transport (gRPC `PingServer`/`GetHistorianConsoleStatus` could synthesize it) |
|
||||||
| `ReadBlocksAsync` | ❌ | ❌ | `StartBlockRetrievalQuery` never captured on either transport — throws `ProtocolEvidenceMissingException` |
|
| `ReadBlocksAsync` | ❌ | ❌ | `StartBlockRetrievalQuery` never captured on either transport — throws `ProtocolEvidenceMissingException` |
|
||||||
|
|
||||||
In short: **WCF is the broad, mature surface** (every config write, events, SQL,
|
In short: **WCF is the broad, mature surface** (every config write, events, SQL,
|
||||||
and all reads), while **gRPC is the narrower *tooled* surface** — but the 2023 R2
|
and all reads), while **gRPC is the narrower *tooled* surface** — but the 2023 R2
|
||||||
gRPC *contract* is actually a **superset** of WCF. Every 🔌 row above has a
|
gRPC *contract* is actually a **superset** of WCF. The recovered config RPCs carry
|
||||||
recovered RPC carrying the **same opaque `bytes` buffers the existing WCF
|
the **same opaque `bytes` buffers the existing WCF serializers already emit**,
|
||||||
serializers already emit**, keyed by the same `strHandle`/`uiHandle` session
|
keyed by the same `strHandle`/`uiHandle` session handle the read path obtains —
|
||||||
handle the gRPC read path already obtains. So these are **capture-and-wire** items
|
confirmed by tooling the read-side config ops (`GetRuntimeParameter`,
|
||||||
(route the existing serializer into a gRPC orchestrator + golden-capture the
|
`GetTagExtendedProperties`) live: the WCF buffers ride the gRPC RPC unchanged and
|
||||||
framing), **not** protocol-discovery items. We have only *buffer-verified* two
|
the server accepts them. Two caveats surfaced when capturing the rest: `ExecuteSqlCommand`
|
||||||
gRPC families live — the read chain and `AddStreamValues` — so per the
|
is **server-walled** (the front-door `CSrvDbConnection` faults on a DB-connection
|
||||||
"capture first, never guess wire bytes" rule the 🔌 rows stay untooled until each
|
precondition the managed session doesn't establish — the same *class* of wall as
|
||||||
is captured. The natural production pattern today remains WCF for config/reads and
|
`OpenStorageConnection`), and `ReadEvents` needs the CM_EVENT registration state
|
||||||
`RemoteGrpc` reserved for `AddHistoricalValuesAsync`.
|
machine ported. The remaining 🔌 rows are **capture-and-wire** items (route the
|
||||||
|
existing serializer into a gRPC orchestrator + live-capture), not
|
||||||
|
protocol-discovery — but per "capture first, never guess wire bytes" they stay
|
||||||
|
untooled until each is verified live. The natural production pattern today remains
|
||||||
|
WCF for config/writes and `RemoteGrpc` for reads + `AddHistoricalValuesAsync`.
|
||||||
|
|
||||||
> A 2023 R2 server reports History interface version 12 (vs. 11 on 2020). The
|
> A 2023 R2 server reports History interface version 12 (vs. 11 on 2020). The
|
||||||
> connect-time version gate accepts both — they are byte-compatible — so gRPC
|
> connect-time version gate accepts both — they are byte-compatible — so gRPC
|
||||||
@@ -216,6 +224,7 @@ $env:HISTORIAN_GRPC_TLS = 'true' # gRPC over TLS
|
|||||||
$env:HISTORIAN_GRPC_DNSID = 'my-2023r2-host' # cert DNS name when connecting by IP
|
$env:HISTORIAN_GRPC_DNSID = 'my-2023r2-host' # cert DNS name when connecting by IP
|
||||||
$env:HISTORIAN_GRPC_TIMEOUT = '120' # per-call deadline (s); raise for slow links
|
$env:HISTORIAN_GRPC_TIMEOUT = '120' # per-call deadline (s); raise for slow links
|
||||||
$env:HISTORIAN_WRITE_SANDBOX_TAG = 'MyFloatTag' # gates the AddHistoricalValues write test
|
$env:HISTORIAN_WRITE_SANDBOX_TAG = 'MyFloatTag' # gates the AddHistoricalValues write test
|
||||||
|
$env:HISTORIAN_GRPC_WRITE_SANDBOX_TAG = 'SandboxTag' # gates the DESTRUCTIVE tag create/rename/delete lifecycle test
|
||||||
```
|
```
|
||||||
|
|
||||||
The aggregate tests self-calibrate their query window from a real raw sample, so
|
The aggregate tests self-calibrate their query window from a real raw sample, so
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# gRPC Tooling Completion Plan
|
||||||
|
|
||||||
|
Status as of 2026-06-22. Tracks the remaining work to finish tooling the AVEVA
|
||||||
|
Historian SDK's `RemoteGrpc` (2023 R2) transport so it reaches WCF surface parity.
|
||||||
|
Self-contained for pickup after context compaction.
|
||||||
|
|
||||||
|
## Where things stand
|
||||||
|
|
||||||
|
The gRPC transport already tools: probe, raw/aggregate/at-time reads, browse,
|
||||||
|
metadata, system-parameter, server time-zone, measured store-forward status,
|
||||||
|
`AddHistoricalValues` backfill write, **and** (newest, branch `grpc-config-ops`,
|
||||||
|
3 commits, NOT yet merged — `main` = `035d8a9`):
|
||||||
|
|
||||||
|
- `GetRuntimeParameterAsync` — ✅ live-verified
|
||||||
|
- `GetTagExtendedPropertiesAsync` (read) — ✅ live-verified
|
||||||
|
- `ExecuteSqlCommandAsync` — ⛔ server-walled, bounded behind `ProtocolEvidenceMissingException`
|
||||||
|
- `EnsureTag` / `DeleteTag` / `RenameTags` / `AddTagExtendedProperties` — 🧪 tooled + routed, sandbox-gated, **not yet run destructively live**
|
||||||
|
|
||||||
|
Test baseline: 317 offline green, 19 gRPC-live green. Relevant memory:
|
||||||
|
`project_grpc_config_ops_tooling`, `project_m0_grpc_parity`,
|
||||||
|
`project_roadmap_exhausted_2020wcf`, `reference_2023r2_live_server_access`,
|
||||||
|
`reference_wonder_sql_vd03_credentials`.
|
||||||
|
|
||||||
|
## Proven pattern (reuse for everything below)
|
||||||
|
|
||||||
|
A WCF config op is tooled over gRPC by reusing its **existing byte serializer/parser
|
||||||
|
verbatim** inside the protobuf `bytes` fields, keyed by the Open2 session handle:
|
||||||
|
|
||||||
|
- `HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);`
|
||||||
|
- `HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, ct[, connectionMode]);`
|
||||||
|
- `session.StringHandle` = uppercase Open2 GUID → **string-handle** ops (Retrieval/Status/History string-handle RPCs).
|
||||||
|
- `session.ClientHandle` = transient `uint` → **uint-handle** ops (StartQuery, DeleteTags, GetNext*).
|
||||||
|
- write ops pass `connectionMode: HistorianWcfAuthChainHelper.NativeIntegratedWriteEnabledConnectionMode` (0x401).
|
||||||
|
- Call `new <Service>.<Service>Client(connection.Channel).<Rpc>(request, connection.Metadata, DateTime.UtcNow.Add(options.RequestTimeout), ct)`.
|
||||||
|
- Check `response.Status?.BSuccess`; decode error via `response.Status?.BtError` (hex = native byte0 0x84 + LE u32 code, often followed by facility/file/message ASCII — this decode cracked the SQL + extended-prop cases).
|
||||||
|
- The gRPC RetrievalService string-handle ops do NOT need the WCF `Retr.GetV` prime.
|
||||||
|
|
||||||
|
Proto field-name reference and WCF serializer signatures: see the mapping captured
|
||||||
|
in `project_grpc_config_ops_tooling` memory and `Grpc/Protos/*.proto`.
|
||||||
|
|
||||||
|
## Remaining items (priority order)
|
||||||
|
|
||||||
|
### 1. Live-verify the write ops (cheapest, highest-confidence-gain)
|
||||||
|
- **Goal:** flip the 🧪 writes to ✅ by running the gated lifecycle test against a sandbox tag.
|
||||||
|
- **How:** set `HISTORIAN_GRPC_WRITE_SANDBOX_TAG` to a throwaway name and run
|
||||||
|
`TagWriteLifecycle_OverGrpc_CreatesAddsPropRenamesDeletes` against the live 2023 R2 box.
|
||||||
|
- **Risk/gotcha:** if any write is rejected, the first fix is to add the WCF write
|
||||||
|
**priming discovery-dance** (`HistorianWcfTagWriteOrchestrator.RunWritePriming`:
|
||||||
|
UpdC3 + 6 `GetSystemParameter` + `AllowRenameTags` + Trx/Stat/Retr `GetV`) to
|
||||||
|
`HistorianGrpcTagWriteOrchestrator` over the gRPC StatusService/HistoryService.
|
||||||
|
Rename also needs server `AllowRenameTags` enabled. Needs explicit user OK to
|
||||||
|
mutate the shared server (they previously chose "no live mutate").
|
||||||
|
- **Files:** `tests/.../HistorianGrpcIntegrationTests.cs` (run only),
|
||||||
|
`src/.../Grpc/HistorianGrpcTagWriteOrchestrator.cs` (priming only if rejected).
|
||||||
|
|
||||||
|
### 2. ReadEvents over gRPC (heaviest read op)
|
||||||
|
- **Goal:** route `ReadEventsAsync` over gRPC.
|
||||||
|
- **RPCs (exist):** `RetrievalService.StartEventQuery` (`uiHandle`, `uiQueryRequestType`,
|
||||||
|
`btRequest`) → `{Status, uiQueryHandle, btResonse}`; `GetNextEventQueryResultBuffer`
|
||||||
|
(`uiHandle`, `uiQueryHandle`) → `{Status, btResult}`; `EndEventQuery`.
|
||||||
|
- **Reuse:** `HistorianEventQueryProtocol.CreateStartEventQueryAttempts(...)` for the
|
||||||
|
request buffer (`QueryRequestTypeEvent`), `HistorianEventRowProtocol.Parse(...)` for rows.
|
||||||
|
- **The hard part — port the CM_EVENT registration state machine.** Without it,
|
||||||
|
`GetNextEventQueryResultBuffer` returns native error type=4 **code=85**. WCF does this
|
||||||
|
in `HistorianWcfEventOrchestrator.AddCmEventTagViaAddT`: UpdC3 → 6 system params →
|
||||||
|
`RegisterTags2` (CM_EVENT tag id `353b8145-5df0-4d46-a253-871aef49b321`, 24-byte
|
||||||
|
RTag2 buffer) → cross-service `GetV` → `EnsureTags2` (CM_EVENT CTagMetadata via
|
||||||
|
`HistorianAddTagsProtocol.SerializeCmEventCTagMetadata`). gRPC equivalents:
|
||||||
|
`HistoryService.RegisterTags`, `HistoryService.EnsureTags`,
|
||||||
|
`HistoryService.UpdateClientStatus`, `StatusService.GetSystemParameter`.
|
||||||
|
- **Approach:** new `Grpc/HistorianGrpcEventOrchestrator`. Open a read-only session,
|
||||||
|
replay the registration over gRPC (RegisterTags + EnsureTags + the discovery calls),
|
||||||
|
then run StartEventQuery → loop GetNextEventQueryResultBuffer → EndEventQuery, parsing
|
||||||
|
rows. Route in `Historian2020ProtocolDialect.ReadEventsAsync` on `UseGrpc`.
|
||||||
|
- **Verify:** live (read-only, safe) against the 2023 R2 box; dev box may return no
|
||||||
|
rows (env) — assert "no error 85 + chain completes," mirror the WCF event test.
|
||||||
|
- **Risk:** medium-high. Registration may need exact call ordering; capture the error
|
||||||
|
buffer (hex+ASCII) at each step if code 85 persists.
|
||||||
|
|
||||||
|
### 3. SendEvent over gRPC
|
||||||
|
- **Goal:** route `SendEventAsync` over gRPC.
|
||||||
|
- **Blocker:** no distinct event-send RPC; WCF rides `AddStreamValues2` (the
|
||||||
|
`HistorianEventWriteProtocol.SerializeAddStreamValuesBuffer` VTQ). The gRPC framing is
|
||||||
|
**uncaptured** — needs a native-client gRPC capture before implementing (per
|
||||||
|
"capture first, never guess"). Depends on #2 (same CM_EVENT registration).
|
||||||
|
- **Risk:** high / blocked on capture. Lowest priority.
|
||||||
|
|
||||||
|
### 4. (Stretch) SQL server-wall investigation
|
||||||
|
- `ExecuteSqlCommand` over gRPC faults server-side in `CSrvDbConnection.ExecuteSqlCommand`
|
||||||
|
(IndexOutOfRange / native err 38) — a DB-connection precondition the managed session
|
||||||
|
doesn't establish. Next avenue: try a `HistoryService.RegisterTags`-family prime before
|
||||||
|
`ExecuteSqlCommand` (same fix that unblocked the M3 write path / OpenStorageConnection
|
||||||
|
class of wall). If it works, replace the bounded throw in `HistorianGrpcSqlClient` with
|
||||||
|
the real GetNextQueryResultBuffer fetch loop (already written there) and flip the test.
|
||||||
|
|
||||||
|
### 5. (Optional) GetConnectionStatus over gRPC
|
||||||
|
- Currently WCF-only, synthesized from an authenticated probe (no dedicated RPC either
|
||||||
|
transport). Could synthesize the same over gRPC via `StatusService.PingServer` /
|
||||||
|
`GetHistorianConsoleStatus`. Low value; do only if parity is wanted.
|
||||||
|
|
||||||
|
### Out of scope
|
||||||
|
- `ReadBlocks` (`StartBlockRetrievalQuery`) — never captured on either transport; leave
|
||||||
|
throwing `ProtocolEvidenceMissingException`.
|
||||||
|
- `DeleteTagExtendedProperties` — server-blocked on WCF (per-connection working set);
|
||||||
|
gRPC's single multiplexed channel *might* fix it — opportunistic probe only.
|
||||||
|
|
||||||
|
## Live verification setup (every live run)
|
||||||
|
|
||||||
|
Tunnel to `WONDER-SQL-VD03` must be up (gRPC `localhost:32565`, TLS, cert CN
|
||||||
|
`WONDER-SQL-VD03`; hosts entry present). Creds in gitignored `wonder-sql-vd03.txt`
|
||||||
|
(**QUOTED, colon-delimited** — strip quotes; use the `domainusername`/`domainpassword`
|
||||||
|
NAM domain account, which works for Historian gRPC; `wonderapp` does NOT). Env:
|
||||||
|
|
||||||
|
```
|
||||||
|
HISTORIAN_GRPC_HOST=wonder-sql-vd03 HISTORIAN_GRPC_PORT=32565
|
||||||
|
HISTORIAN_GRPC_TLS=true HISTORIAN_GRPC_DNSID=WONDER-SQL-VD03
|
||||||
|
HISTORIAN_USER=<domain user> HISTORIAN_PASSWORD=<domain pass>
|
||||||
|
HISTORIAN_TEST_TAG=SysTimeSec
|
||||||
|
# writes only, destructive: HISTORIAN_GRPC_WRITE_SANDBOX_TAG=<throwaway>
|
||||||
|
# slow links: HISTORIAN_GRPC_TIMEOUT=120
|
||||||
|
```
|
||||||
|
|
||||||
|
Run a subset: `dotnet test ./Histsdk.slnx --no-build --filter "FullyQualifiedName~<name>"`.
|
||||||
|
Aggregate tests self-calibrate their window from a real raw sample (the box is idle/
|
||||||
|
not-collecting). Sanitization scan before any commit:
|
||||||
|
`wonder-sql-vd03|zimmer|nam\\|dohertj2|ADOBuild` over commit-safe files.
|
||||||
|
|
||||||
|
## Standing constraints
|
||||||
|
- Never commit credentials/hostnames/customer tag names/raw captures — placeholders only.
|
||||||
|
- `src/` stays pure managed .NET 10 (one allowed P/Invoke: SSPI). Never modify `current/`
|
||||||
|
or `aveva-install-*/`.
|
||||||
|
- Commit only when asked; branch first if on `main`; required footers
|
||||||
|
(Co-Authored-By + Claude-Session). Capture wire bytes before implementing — never guess.
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
using Google.Protobuf;
|
||||||
|
using AVEVA.Historian.Client.Models;
|
||||||
|
using AVEVA.Historian.Client.Protocol;
|
||||||
|
using AVEVA.Historian.Client.Wcf;
|
||||||
|
using GrpcRetrieval = ArchestrA.Grpc.Contract.Retrieval;
|
||||||
|
|
||||||
|
namespace AVEVA.Historian.Client.Grpc;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Executes SQL commands over the 2023 R2 gRPC transport (HCAL R1.1), mirroring
|
||||||
|
/// <see cref="HistorianWcfSqlClient"/>'s two-op <c>ExeC</c>/<c>GetR</c> flow. The 2020 WCF path uses a
|
||||||
|
/// dedicated <c>GetRecordSetByteStream</c> op; the gRPC front door has no such RPC, so the NRBF
|
||||||
|
/// recordset stream would be fetched through the generic <c>RetrievalService.GetNextQueryResultBuffer</c>
|
||||||
|
/// keyed by the query handle <c>ExecuteSqlCommand</c> returns. <c>ExecuteSqlCommand</c> takes the
|
||||||
|
/// uppercase string session handle; the result-buffer fetch takes the transient <c>uint</c> client
|
||||||
|
/// handle (both come from the one Open2 session).
|
||||||
|
/// <para>
|
||||||
|
/// <b>SERVER-WALLED (captured 2026-06-22).</b> The 2023 R2 front-door
|
||||||
|
/// <c>RetrievalService.ExecuteSqlCommand</c> faults server-side before returning a query handle:
|
||||||
|
/// the response carries native error 38 wrapping a managed
|
||||||
|
/// <c>System.IndexOutOfRangeException ... at aahClientAccessPoint.CSrvDbConnection.ExecuteSqlCommand</c>.
|
||||||
|
/// This is a server-side <c>CSrvDbConnection</c> (SQL DB-connection) precondition that the pure
|
||||||
|
/// managed gRPC session does not establish — the same class of wall as
|
||||||
|
/// <c>StorageService.OpenStorageConnection</c> (whose real precondition is the front-door
|
||||||
|
/// <c>HistoryService.RegisterTags</c> family). Priming <c>Retr.GetV</c> does not clear it. The request
|
||||||
|
/// framing here is the captured/expected shape; the op stays bounded behind
|
||||||
|
/// <see cref="ProtocolEvidenceMissingException"/> until the DB-connection registration is reproduced.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
internal static class HistorianGrpcSqlClient
|
||||||
|
{
|
||||||
|
// GetNextQueryResultBuffer is byte-stream-paged; a small record set returns in one page. Runaway guard.
|
||||||
|
private const int MaxPages = 4096;
|
||||||
|
|
||||||
|
public static Task<HistorianSqlResult> ExecuteSqlCommandAsync(
|
||||||
|
HistorianClientOptions options,
|
||||||
|
string command,
|
||||||
|
HistorianSqlExecuteOption option,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(command);
|
||||||
|
return Task.Run(() => ExecuteSqlCommand(options, command, option, cancellationToken), cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HistorianSqlResult ExecuteSqlCommand(
|
||||||
|
HistorianClientOptions options,
|
||||||
|
string command,
|
||||||
|
HistorianSqlExecuteOption option,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
||||||
|
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, cancellationToken);
|
||||||
|
var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel);
|
||||||
|
DateTime Deadline() => DateTime.UtcNow.Add(options.RequestTimeout);
|
||||||
|
|
||||||
|
// Prime the Retrieval service version handshake (Retr.GetV) before the string-handle SQL op, as
|
||||||
|
// the native WCF SQL path does — the server-side ExecuteSqlCommand otherwise faults.
|
||||||
|
retrievalClient.GetRetrievalInterfaceVersion(
|
||||||
|
new GrpcRetrieval.GetRetrievalInterfaceVersionRequest(), connection.Metadata, Deadline(), cancellationToken);
|
||||||
|
|
||||||
|
GrpcRetrieval.ExecuteSqlCommandResponse exec = retrievalClient.ExecuteSqlCommand(
|
||||||
|
new GrpcRetrieval.ExecuteSqlCommandRequest
|
||||||
|
{
|
||||||
|
StrHandle = session.StringHandle,
|
||||||
|
StrCommand = command,
|
||||||
|
UiOption = (uint)option,
|
||||||
|
UiQueryHandle = 0
|
||||||
|
},
|
||||||
|
connection.Metadata,
|
||||||
|
Deadline(),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (!(exec.Status?.BSuccess ?? false))
|
||||||
|
{
|
||||||
|
// Captured 2026-06-22: the server-side CSrvDbConnection.ExecuteSqlCommand throws
|
||||||
|
// IndexOutOfRange (native error 38) — a DB-connection precondition the pure managed gRPC
|
||||||
|
// session doesn't establish. Surface the SDK's evidence-missing signal rather than a raw
|
||||||
|
// server fault. See the class remarks.
|
||||||
|
throw new ProtocolEvidenceMissingException(
|
||||||
|
"ExecuteSqlCommand over gRPC: server-side CSrvDbConnection.ExecuteSqlCommand faults " +
|
||||||
|
"(IndexOutOfRange / native error 38) — an unmet DB-connection precondition (gRPC transport). Use WCF.");
|
||||||
|
}
|
||||||
|
|
||||||
|
int returnValue = exec.IRetValue;
|
||||||
|
uint queryHandle = exec.UiQueryHandle;
|
||||||
|
|
||||||
|
using MemoryStream accumulated = new();
|
||||||
|
for (int page = 0; page < MaxPages; page++)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
GrpcRetrieval.GetNextQueryResultBufferResponse buffer = retrievalClient.GetNextQueryResultBuffer(
|
||||||
|
new GrpcRetrieval.GetNextQueryResultBufferRequest { UiHandle = session.ClientHandle, UiQueryHandle = queryHandle },
|
||||||
|
connection.Metadata,
|
||||||
|
Deadline(),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
byte[] resultBuffer = buffer.BtQueryResult?.ToByteArray() ?? [];
|
||||||
|
|
||||||
|
// GetR is false-even-on-success: the final page returns false with the data still in the
|
||||||
|
// buffer, so always consume the buffer first, then stop on a false status or an empty page.
|
||||||
|
if (resultBuffer.Length > 0)
|
||||||
|
{
|
||||||
|
accumulated.Write(resultBuffer, 0, resultBuffer.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(buffer.Status?.BSuccess ?? false) || resultBuffer.Length == 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return HistorianSqlResultProtocol.Parse(accumulated.ToArray(), returnValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
using Google.Protobuf;
|
||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
using AVEVA.Historian.Client.Models;
|
using AVEVA.Historian.Client.Models;
|
||||||
|
using AVEVA.Historian.Client.Wcf;
|
||||||
using GrpcStatus = ArchestrA.Grpc.Contract.Status;
|
using GrpcStatus = ArchestrA.Grpc.Contract.Status;
|
||||||
|
|
||||||
namespace AVEVA.Historian.Client.Grpc;
|
namespace AVEVA.Historian.Client.Grpc;
|
||||||
@@ -142,4 +144,46 @@ internal static class HistorianGrpcStatusClient
|
|||||||
string? value = response.StrSystemTimeZoneName;
|
string? value = response.StrSystemTimeZoneName;
|
||||||
return string.IsNullOrEmpty(value) ? null : value;
|
return string.IsNullOrEmpty(value) ? null : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads a Historian runtime parameter over gRPC (<c>StatusService.GetRuntimeParameter</c>).
|
||||||
|
/// The request/response byte buffers are the proven 2020 <c>GETRP</c> wire format
|
||||||
|
/// (<see cref="HistorianRuntimeParameterProtocol"/>) carried unchanged inside the protobuf
|
||||||
|
/// <c>btRequest</c>/<c>btResponse</c> fields; the op keys on the uppercase string session handle.
|
||||||
|
/// </summary>
|
||||||
|
public static Task<string?> GetRuntimeParameterAsync(
|
||||||
|
HistorianClientOptions options,
|
||||||
|
string parameterName,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(parameterName);
|
||||||
|
return Task.Run(() => GetRuntimeParameter(options, parameterName, cancellationToken), cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetRuntimeParameter(HistorianClientOptions options, string parameterName, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
||||||
|
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, cancellationToken);
|
||||||
|
|
||||||
|
byte[] request = HistorianRuntimeParameterProtocol.SerializeRequest(parameterName);
|
||||||
|
|
||||||
|
var statusClient = new GrpcStatus.StatusService.StatusServiceClient(connection.Channel);
|
||||||
|
GrpcStatus.GetRuntimeParameterResponse response = statusClient.GetRuntimeParameter(
|
||||||
|
new GrpcStatus.GetRuntimeParameterRequest
|
||||||
|
{
|
||||||
|
StrHandle = session.StringHandle,
|
||||||
|
BtRequest = ByteString.CopyFrom(request)
|
||||||
|
},
|
||||||
|
connection.Metadata,
|
||||||
|
DateTime.UtcNow.Add(options.RequestTimeout),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (!(response.Status?.BSuccess ?? false))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] responseBuffer = response.BtResponse?.ToByteArray() ?? [];
|
||||||
|
return HistorianRuntimeParameterProtocol.ParseSingleStringResult(responseBuffer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,78 @@ internal static class HistorianGrpcTagClient
|
|||||||
return response.BtTagInfos?.ToByteArray() ?? [];
|
return response.BtTagInfos?.ToByteArray() ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetTagExtendedPropertiesFromName is sequence-paged; a single tag returns everything on page 0
|
||||||
|
// and an empty/false buffer next. The cap is a runaway guard (mirrors the WCF path).
|
||||||
|
private const int MaxExtendedPropertyPages = 64;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads a tag's extended (user-defined) properties over gRPC
|
||||||
|
/// (<c>RetrievalService.GetTagExtendedPropertiesFromName</c>, a string-handle op). The request
|
||||||
|
/// <c>btTagNames</c> and response <c>btTeps</c> buffers are the proven 2020 <c>GetTepByNm</c> wire
|
||||||
|
/// format (<see cref="HistorianTagExtendedPropertyProtocol"/>) carried unchanged; paging follows
|
||||||
|
/// the same sequence loop as the WCF path.
|
||||||
|
/// </summary>
|
||||||
|
public static Task<IReadOnlyList<HistorianTagExtendedProperty>> GetTagExtendedPropertiesAsync(
|
||||||
|
HistorianClientOptions options,
|
||||||
|
string tag,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
|
||||||
|
return Task.Run(() => GetTagExtendedProperties(options, tag, cancellationToken), cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<HistorianTagExtendedProperty> GetTagExtendedProperties(
|
||||||
|
HistorianClientOptions options, string tag, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
||||||
|
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, cancellationToken);
|
||||||
|
var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel);
|
||||||
|
|
||||||
|
byte[] tagNames = HistorianTagExtendedPropertyProtocol.SerializeRequest(tag);
|
||||||
|
List<HistorianTagExtendedProperty> properties = [];
|
||||||
|
uint sequence = 0;
|
||||||
|
|
||||||
|
for (int page = 0; page < MaxExtendedPropertyPages; page++)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
GrpcRetrieval.GetTagExtendedPropertiesFromNameResponse response = retrievalClient.GetTagExtendedPropertiesFromName(
|
||||||
|
new GrpcRetrieval.GetTagExtendedPropertiesFromNameRequest
|
||||||
|
{
|
||||||
|
StrHandle = session.StringHandle,
|
||||||
|
BtTagNames = ByteString.CopyFrom(tagNames),
|
||||||
|
UiSequence = sequence
|
||||||
|
},
|
||||||
|
connection.Metadata,
|
||||||
|
DateTime.UtcNow.Add(options.RequestTimeout),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (!(response.Status?.BSuccess ?? false))
|
||||||
|
{
|
||||||
|
// A non-success terminates paging. The server signals "no more rows" with a
|
||||||
|
// CClientUtil::FillBufferFromVector marker (live-confirmed) — including on page 0 when
|
||||||
|
// the tag has no user-defined properties, which is a legitimate empty result, not an
|
||||||
|
// error. This mirrors the WCF path, which also breaks (returns empty) rather than throws.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
IReadOnlyList<HistorianTagExtendedPropertyRow> rows =
|
||||||
|
HistorianTagExtendedPropertyProtocol.ParseResponse(response.BtTeps?.ToByteArray() ?? []);
|
||||||
|
if (rows.Count == 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (HistorianTagExtendedPropertyRow row in rows)
|
||||||
|
{
|
||||||
|
properties.Add(new HistorianTagExtendedProperty(row.PropertyName, row.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
sequence = response.UiSequence;
|
||||||
|
}
|
||||||
|
|
||||||
|
return properties;
|
||||||
|
}
|
||||||
|
|
||||||
// QueryTag (browse paging) request framing, recovered from the .rdata packet-descriptor table
|
// QueryTag (browse paging) request framing, recovered from the .rdata packet-descriptor table
|
||||||
// in aahClientManaged.dll (entries {0x6751,1}=StartTagQuery, {0x6752,1}=QueryTag) and confirmed
|
// in aahClientManaged.dll (entries {0x6751,1}=StartTagQuery, {0x6752,1}=QueryTag) and confirmed
|
||||||
// live: btRequest = u16 marker(0x6752) + u16 version(1) + u16 queryType + u32 startIndex + u32 count.
|
// live: btRequest = u16 marker(0x6752) + u16 version(1) + u16 queryType + u32 startIndex + u32 count.
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
using Google.Protobuf;
|
||||||
|
using AVEVA.Historian.Client.Models;
|
||||||
|
using AVEVA.Historian.Client.Wcf;
|
||||||
|
using GrpcHistory = ArchestrA.Grpc.Contract.History;
|
||||||
|
|
||||||
|
namespace AVEVA.Historian.Client.Grpc;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tag-configuration write ops over the 2023 R2 gRPC transport, mirroring
|
||||||
|
/// <see cref="HistorianWcfTagWriteOrchestrator"/>. Each op opens a <b>write-enabled</b> Open2 session
|
||||||
|
/// (<c>0x401</c>) and reuses the proven 2020 byte serializers verbatim inside the protobuf
|
||||||
|
/// <c>bytes</c> fields:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><see cref="EnsureTagAsync"/> → <c>HistoryService.EnsureTags</c> (string handle,
|
||||||
|
/// <c>btTagInfos</c> = <see cref="HistorianTagWriteProtocol.SerializeAnalogCTagMetadata"/>)</item>
|
||||||
|
/// <item><see cref="DeleteTagAsync"/> → <c>HistoryService.DeleteTags</c> (uint handle,
|
||||||
|
/// <c>btTagnames</c> = <see cref="HistorianTagWriteProtocol.SerializeDeleteTagNames"/>)</item>
|
||||||
|
/// <item><see cref="RenameTagsAsync"/> → <c>HistoryService.StartJob</c> (string handle,
|
||||||
|
/// <c>btInput</c> = <see cref="HistorianTagRenameProtocol.SerializeRenameJob"/>)</item>
|
||||||
|
/// <item><see cref="AddTagExtendedPropertiesAsync"/> → <c>HistoryService.AddTagExtendedProperties</c>
|
||||||
|
/// (string handle, <c>btTeps</c> = <see cref="HistorianTagExtendedPropertyProtocol.SerializeAddRequest"/>)</item>
|
||||||
|
/// </list>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Tooled but not yet live-verified.</b> The request framing reuses the WCF serializers proven on
|
||||||
|
/// the 2020 transport, and the read-side config ops confirm WCF config buffers ride the gRPC RPC
|
||||||
|
/// unchanged — but these mutate server state (create/delete/rename tags, write properties), so they
|
||||||
|
/// are gated behind a sandbox-tag in the integration tests and have not been run destructively against
|
||||||
|
/// a shared live server. The WCF path additionally runs a priming "discovery dance" (UpdC3 + system
|
||||||
|
/// parameters + cross-service GetV) before the write; the gRPC front door established the equivalent
|
||||||
|
/// session state in the M3 non-streamed-write probe without it, so it is omitted here pending live
|
||||||
|
/// confirmation. If a live run is rejected, that priming is the first thing to add.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class HistorianGrpcTagWriteOrchestrator
|
||||||
|
{
|
||||||
|
private const uint WriteEnabledConnectionMode = HistorianWcfAuthChainHelper.NativeIntegratedWriteEnabledConnectionMode;
|
||||||
|
|
||||||
|
private readonly HistorianClientOptions _options;
|
||||||
|
|
||||||
|
public HistorianGrpcTagWriteOrchestrator(HistorianClientOptions options)
|
||||||
|
{
|
||||||
|
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<bool> EnsureTagAsync(HistorianTagDefinition definition, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(definition);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(definition.TagName, nameof(definition));
|
||||||
|
// Surface unsupported (non-analog) types early, exactly as the WCF path does.
|
||||||
|
_ = HistorianTagWriteProtocol.GetAnalogDataTypeCode(definition.DataType);
|
||||||
|
return Task.Run(() => EnsureTag(definition, cancellationToken), cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool EnsureTag(HistorianTagDefinition definition, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
|
||||||
|
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, _options, cancellationToken, WriteEnabledConnectionMode);
|
||||||
|
|
||||||
|
byte[] payload = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
|
||||||
|
tagName: definition.TagName,
|
||||||
|
description: definition.Description,
|
||||||
|
engineeringUnit: definition.EngineeringUnit,
|
||||||
|
dateCreatedUtc: DateTime.UtcNow,
|
||||||
|
dataType: definition.DataType,
|
||||||
|
minEU: definition.MinEU,
|
||||||
|
maxEU: definition.MaxEU,
|
||||||
|
minRaw: definition.MinRaw,
|
||||||
|
maxRaw: definition.MaxRaw,
|
||||||
|
storageRateMs: definition.StorageRateMs,
|
||||||
|
applyScaling: definition.ApplyScaling,
|
||||||
|
storageType: definition.StorageType,
|
||||||
|
integralDivisor: definition.IntegralDivisor);
|
||||||
|
|
||||||
|
var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel);
|
||||||
|
GrpcHistory.EnsureTagsResponse response = historyClient.EnsureTags(
|
||||||
|
new GrpcHistory.EnsureTagsRequest
|
||||||
|
{
|
||||||
|
StrHandle = session.StringHandle,
|
||||||
|
BtTagInfos = ByteString.CopyFrom(payload),
|
||||||
|
ElementCount = 1
|
||||||
|
},
|
||||||
|
connection.Metadata,
|
||||||
|
DateTime.UtcNow.Add(_options.RequestTimeout),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return response.Status?.BSuccess ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<bool> DeleteTagAsync(string tagName, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(tagName);
|
||||||
|
return Task.Run(() => DeleteTag(tagName, cancellationToken), cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool DeleteTag(string tagName, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
|
||||||
|
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, _options, cancellationToken, WriteEnabledConnectionMode);
|
||||||
|
|
||||||
|
// DeleteTags takes the transient uint client handle (not the string handle), per the WCF wire capture.
|
||||||
|
byte[] tagNames = HistorianTagWriteProtocol.SerializeDeleteTagNames([tagName]);
|
||||||
|
var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel);
|
||||||
|
GrpcHistory.DeleteTagsResponse response = historyClient.DeleteTags(
|
||||||
|
new GrpcHistory.DeleteTagsRequest
|
||||||
|
{
|
||||||
|
UiHandle = session.ClientHandle,
|
||||||
|
BtTagnames = ByteString.CopyFrom(tagNames)
|
||||||
|
},
|
||||||
|
connection.Metadata,
|
||||||
|
DateTime.UtcNow.Add(_options.RequestTimeout),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return response.Status?.BSuccess ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<bool> AddTagExtendedPropertiesAsync(
|
||||||
|
string tagName, IReadOnlyList<HistorianTagExtendedProperty> properties, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(tagName);
|
||||||
|
ArgumentNullException.ThrowIfNull(properties);
|
||||||
|
if (properties.Count == 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("At least one extended property is required.", nameof(properties));
|
||||||
|
}
|
||||||
|
return Task.Run(() => AddTagExtendedProperties(tagName, properties, cancellationToken), cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool AddTagExtendedProperties(
|
||||||
|
string tagName, IReadOnlyList<HistorianTagExtendedProperty> properties, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
|
||||||
|
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, _options, cancellationToken, WriteEnabledConnectionMode);
|
||||||
|
|
||||||
|
byte[] inBuff = HistorianTagExtendedPropertyProtocol.SerializeAddRequest(tagName, properties);
|
||||||
|
var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel);
|
||||||
|
GrpcHistory.AddTagExtendedPropertiesResponse response = historyClient.AddTagExtendedProperties(
|
||||||
|
new GrpcHistory.AddTagExtendedPropertiesRequest
|
||||||
|
{
|
||||||
|
StrHandle = session.StringHandle,
|
||||||
|
BtTeps = ByteString.CopyFrom(inBuff)
|
||||||
|
},
|
||||||
|
connection.Metadata,
|
||||||
|
DateTime.UtcNow.Add(_options.RequestTimeout),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return response.Status?.BSuccess ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<HistorianTagRenameResult> RenameTagsAsync(
|
||||||
|
IReadOnlyList<(string OldName, string NewName)> pairs, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(pairs);
|
||||||
|
if (pairs.Count == 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("At least one (old,new) name pair is required.", nameof(pairs));
|
||||||
|
}
|
||||||
|
foreach ((string oldName, string newName) in pairs)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(oldName, nameof(pairs));
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(newName, nameof(pairs));
|
||||||
|
}
|
||||||
|
return Task.Run(() => RenameTags(pairs, cancellationToken), cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private HistorianTagRenameResult RenameTags(IReadOnlyList<(string OldName, string NewName)> pairs, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
|
||||||
|
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, _options, cancellationToken, WriteEnabledConnectionMode);
|
||||||
|
|
||||||
|
byte[] jobBuffer = HistorianTagRenameProtocol.SerializeRenameJob(pairs);
|
||||||
|
var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel);
|
||||||
|
GrpcHistory.StartJobResponse response = historyClient.StartJob(
|
||||||
|
new GrpcHistory.StartJobRequest
|
||||||
|
{
|
||||||
|
StrHandle = session.StringHandle,
|
||||||
|
BtInput = ByteString.CopyFrom(jobBuffer)
|
||||||
|
},
|
||||||
|
connection.Metadata,
|
||||||
|
DateTime.UtcNow.Add(_options.RequestTimeout),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
bool ok = response.Status?.BSuccess ?? false;
|
||||||
|
Guid parsedJobId = Guid.Empty;
|
||||||
|
if (!string.IsNullOrWhiteSpace(response.StrJobid))
|
||||||
|
{
|
||||||
|
Guid.TryParse(response.StrJobid.Trim().Trim('$', '{', '}'), out parsedJobId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HistorianTagRenameResult
|
||||||
|
{
|
||||||
|
Accepted = ok,
|
||||||
|
JobId = parsedJobId,
|
||||||
|
PairCount = pairs.Count,
|
||||||
|
Error = ok ? null : "Server rejected the rename job (StartJob returned false). Check that the 'AllowRenameTags' system parameter is enabled.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -237,7 +237,9 @@ public sealed class HistorianClient : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
|
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
|
||||||
ArgumentNullException.ThrowIfNull(properties);
|
ArgumentNullException.ThrowIfNull(properties);
|
||||||
return new HistorianWcfTagWriteOrchestrator(_options).AddTagExtendedPropertiesAsync(tag, properties, cancellationToken);
|
return _options.Transport == HistorianTransport.RemoteGrpc
|
||||||
|
? new Grpc.HistorianGrpcTagWriteOrchestrator(_options).AddTagExtendedPropertiesAsync(tag, properties, cancellationToken)
|
||||||
|
: new HistorianWcfTagWriteOrchestrator(_options).AddTagExtendedPropertiesAsync(tag, properties, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Convenience overload of <see cref="AddTagExtendedPropertiesAsync"/> for a single
|
/// <summary>Convenience overload of <see cref="AddTagExtendedPropertiesAsync"/> for a single
|
||||||
@@ -285,7 +287,9 @@ public sealed class HistorianClient : IAsyncDisposable
|
|||||||
public Task<bool> EnsureTagAsync(HistorianTagDefinition definition, CancellationToken cancellationToken = default)
|
public Task<bool> EnsureTagAsync(HistorianTagDefinition definition, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(definition);
|
ArgumentNullException.ThrowIfNull(definition);
|
||||||
return new HistorianWcfTagWriteOrchestrator(_options).EnsureTagAsync(definition, cancellationToken);
|
return _options.Transport == HistorianTransport.RemoteGrpc
|
||||||
|
? new Grpc.HistorianGrpcTagWriteOrchestrator(_options).EnsureTagAsync(definition, cancellationToken)
|
||||||
|
: new HistorianWcfTagWriteOrchestrator(_options).EnsureTagAsync(definition, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -299,7 +303,9 @@ public sealed class HistorianClient : IAsyncDisposable
|
|||||||
public Task<bool> DeleteTagAsync(string tagName, CancellationToken cancellationToken = default)
|
public Task<bool> DeleteTagAsync(string tagName, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(tagName);
|
ArgumentException.ThrowIfNullOrWhiteSpace(tagName);
|
||||||
return new HistorianWcfTagWriteOrchestrator(_options).DeleteTagAsync(tagName, cancellationToken);
|
return _options.Transport == HistorianTransport.RemoteGrpc
|
||||||
|
? new Grpc.HistorianGrpcTagWriteOrchestrator(_options).DeleteTagAsync(tagName, cancellationToken)
|
||||||
|
: new HistorianWcfTagWriteOrchestrator(_options).DeleteTagAsync(tagName, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -325,7 +331,9 @@ public sealed class HistorianClient : IAsyncDisposable
|
|||||||
public Task<HistorianTagRenameResult> RenameTagsAsync(IReadOnlyList<(string OldName, string NewName)> pairs, CancellationToken cancellationToken = default)
|
public Task<HistorianTagRenameResult> RenameTagsAsync(IReadOnlyList<(string OldName, string NewName)> pairs, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(pairs);
|
ArgumentNullException.ThrowIfNull(pairs);
|
||||||
return new HistorianWcfTagWriteOrchestrator(_options).RenameTagsAsync(pairs, cancellationToken);
|
return _options.Transport == HistorianTransport.RemoteGrpc
|
||||||
|
? new Grpc.HistorianGrpcTagWriteOrchestrator(_options).RenameTagsAsync(pairs, cancellationToken)
|
||||||
|
: new HistorianWcfTagWriteOrchestrator(_options).RenameTagsAsync(pairs, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ValueTask DisposeAsync()
|
public ValueTask DisposeAsync()
|
||||||
|
|||||||
@@ -95,21 +95,27 @@ internal sealed class Historian2020ProtocolDialect
|
|||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||||
return Wcf.HistorianWcfStatusClient.GetRuntimeParameterAsync(_options, name, cancellationToken);
|
return UseGrpc
|
||||||
|
? HistorianGrpcStatusClient.GetRuntimeParameterAsync(_options, name, cancellationToken)
|
||||||
|
: Wcf.HistorianWcfStatusClient.GetRuntimeParameterAsync(_options, name, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<IReadOnlyList<Models.HistorianTagExtendedProperty>> GetTagExtendedPropertiesAsync(string tag, CancellationToken cancellationToken)
|
public Task<IReadOnlyList<Models.HistorianTagExtendedProperty>> GetTagExtendedPropertiesAsync(string tag, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
|
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
|
||||||
return Wcf.HistorianWcfTagExtendedPropertyClient.GetTagExtendedPropertiesAsync(_options, tag, cancellationToken);
|
return UseGrpc
|
||||||
|
? Grpc.HistorianGrpcTagClient.GetTagExtendedPropertiesAsync(_options, tag, cancellationToken)
|
||||||
|
: Wcf.HistorianWcfTagExtendedPropertyClient.GetTagExtendedPropertiesAsync(_options, tag, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<HistorianSqlResult> ExecuteSqlCommandAsync(string command, HistorianSqlExecuteOption option, CancellationToken cancellationToken)
|
public Task<HistorianSqlResult> ExecuteSqlCommandAsync(string command, HistorianSqlExecuteOption option, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(command);
|
ArgumentException.ThrowIfNullOrWhiteSpace(command);
|
||||||
return Wcf.HistorianWcfSqlClient.ExecuteSqlCommandAsync(_options, command, option, cancellationToken);
|
return UseGrpc
|
||||||
|
? Grpc.HistorianGrpcSqlClient.ExecuteSqlCommandAsync(_options, command, option, cancellationToken)
|
||||||
|
: Wcf.HistorianWcfSqlClient.ExecuteSqlCommandAsync(_options, command, option, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async IAsyncEnumerable<T> Missing<T>(
|
private static async IAsyncEnumerable<T> Missing<T>(
|
||||||
|
|||||||
@@ -351,6 +351,104 @@ public sealed class HistorianGrpcIntegrationTests
|
|||||||
Assert.All(samples, s => Assert.Equal(testTag, s.TagName));
|
Assert.All(samples, s => Assert.Equal(testTag, s.TagName));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetRuntimeParameterAsync_OverGrpc_ReturnsValue()
|
||||||
|
{
|
||||||
|
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
|
||||||
|
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER")))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config op tooled over gRPC: StatusService.GetRuntimeParameter carries the proven 2020 GETRP
|
||||||
|
// request/response buffers unchanged inside the protobuf bytes fields.
|
||||||
|
HistorianClient client = new(BuildOptions(host));
|
||||||
|
string? value = await client.GetRuntimeParameterAsync("HistorianVersion", CancellationToken.None);
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetTagExtendedPropertiesAsync_OverGrpc_DoesNotThrow()
|
||||||
|
{
|
||||||
|
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
|
||||||
|
string? tag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG");
|
||||||
|
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(tag)
|
||||||
|
|| string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER")))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config op tooled over gRPC: RetrievalService.GetTagExtendedPropertiesFromName carries the
|
||||||
|
// proven 2020 GetTepByNm buffers. A system tag may have no user-defined properties, so this
|
||||||
|
// asserts the call completes and returns a well-formed (possibly empty) list.
|
||||||
|
HistorianClient client = new(BuildOptions(host));
|
||||||
|
IReadOnlyList<HistorianTagExtendedProperty> props = await client.GetTagExtendedPropertiesAsync(tag!, CancellationToken.None);
|
||||||
|
Assert.NotNull(props);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExecuteSqlCommandAsync_OverGrpc_IsServerWalled()
|
||||||
|
{
|
||||||
|
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
|
||||||
|
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER")))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteSqlCommand request rides the gRPC front door, but the server-side
|
||||||
|
// CSrvDbConnection.ExecuteSqlCommand faults (IndexOutOfRange / native error 38) — an unmet
|
||||||
|
// DB-connection precondition the pure managed gRPC session doesn't establish (captured
|
||||||
|
// 2026-06-22). The SDK surfaces this as ProtocolEvidenceMissingException. This test pins the
|
||||||
|
// wall so a future server/registration change that lifts it is noticed.
|
||||||
|
HistorianClient client = new(BuildOptions(host));
|
||||||
|
await Assert.ThrowsAsync<ProtocolEvidenceMissingException>(() => client.ExecuteSqlCommandAsync(
|
||||||
|
"SELECT 10 AS Num, 'alpha' AS Word UNION ALL SELECT 20, NULL",
|
||||||
|
cancellationToken: CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TagWriteLifecycle_OverGrpc_CreatesAddsPropRenamesDeletes()
|
||||||
|
{
|
||||||
|
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
|
||||||
|
// DESTRUCTIVE: gated on a dedicated sandbox-tag name so it never mutates a server by accident.
|
||||||
|
// Set HISTORIAN_GRPC_WRITE_SANDBOX_TAG to a throwaway tag name the test may create/rename/delete.
|
||||||
|
string? sandbox = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_WRITE_SANDBOX_TAG");
|
||||||
|
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(sandbox)
|
||||||
|
|| string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER")))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exercises the full gRPC tag-config write surface end-to-end against a write-enabled (0x401)
|
||||||
|
// session, then cleans up after itself: EnsureTags -> AddTagExtendedProperties ->
|
||||||
|
// (read-back verify) -> StartJob rename -> DeleteTags.
|
||||||
|
HistorianClient client = new(BuildOptions(host));
|
||||||
|
string renamed = sandbox + "_R";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
bool created = await client.EnsureTagAsync(
|
||||||
|
new HistorianTagDefinition { TagName = sandbox!, DataType = HistorianDataType.Float, EngineeringUnit = "u", MaxEU = 100 },
|
||||||
|
CancellationToken.None);
|
||||||
|
Assert.True(created, "EnsureTags over gRPC should create the sandbox tag.");
|
||||||
|
|
||||||
|
bool propAdded = await client.AddTagExtendedPropertyAsync(sandbox!, "GrpcToolingTest", "ok", CancellationToken.None);
|
||||||
|
Assert.True(propAdded, "AddTagExtendedProperties over gRPC should succeed.");
|
||||||
|
|
||||||
|
IReadOnlyList<HistorianTagExtendedProperty> props = await client.GetTagExtendedPropertiesAsync(sandbox!, CancellationToken.None);
|
||||||
|
Assert.Contains(props, p => string.Equals(p.Name, "GrpcToolingTest", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
HistorianTagRenameResult rename = await client.RenameTagsAsync([(sandbox!, renamed)], CancellationToken.None);
|
||||||
|
Assert.True(rename.Accepted, $"StartJob rename over gRPC should be accepted: {rename.Error}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// Best-effort cleanup of whichever name survives (rename is an async server job).
|
||||||
|
try { await client.DeleteTagAsync(sandbox!, CancellationToken.None); } catch { /* ignore */ }
|
||||||
|
try { await client.DeleteTagAsync(renamed, CancellationToken.None); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static HistorianClientOptions BuildOptions(string host)
|
private static HistorianClientOptions BuildOptions(string host)
|
||||||
{
|
{
|
||||||
string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER");
|
string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER");
|
||||||
|
|||||||
Reference in New Issue
Block a user