diff --git a/README.md b/README.md index 3e95762..09ab836 100644 --- a/README.md +++ b/README.md @@ -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. Legend: ✅ tooled + live-verified · ⚠️ tooled, partial/synthesized · -🔌 **the gRPC server exposes the RPC (recovered in `Grpc/Protos/*.proto`) but the -SDK doesn't drive it yet** — untooled/uncaptured, *not* a protocol gap · +🧪 tooled + routed but **sandbox-gated** (mutates server state, not yet run +destructively against a live box) · 🔌 **the gRPC server exposes the RPC +(recovered in `Grpc/Protos/*.proto`) but the SDK doesn't drive it yet** — +untooled/uncaptured, *not* a protocol gap · ⛔ tooled but **server-walled** (the +request rides the RPC but the server faults on an unmet precondition) · ❌ unavailable on that transport. | Operation | WCF | gRPC | Notes | @@ -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` | | `GetServerTimeZoneAsync` | ❌ | ✅ | 2020 `GetSystemTimeZoneName` is a client-side stub (empty); WCF throws | | `GetStoreForwardStatusAsync` | ⚠️ | ✅ | gRPC contacts the server (measured idle-state, reports `ErrorOccurred`); WCF returns synthesized all-false. Active-SF magnitude is D2-gated on both | -| `ReadEventsAsync` | ✅ | 🔌 | gRPC `RetrievalService.StartEventQuery` / `GetNextEventQueryResultBuffer` / `EndEventQuery` recovered (`bytes btRequest` + handle); not tooled over gRPC | +| `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 | -| `EnsureTagAsync` / `DeleteTagAsync` / `RenameTagsAsync` | ✅ | 🔌 | gRPC `HistoryService.EnsureTags` / `DeleteTags` / `StartJob`(+`GetJobStatus`) recovered (`bytes btTagInfos`/`btTagnames`/`btInput` + handle) | -| `GetTagExtendedPropertiesAsync` / `AddTagExtendedPropertiesAsync` | ✅ | 🔌 | gRPC `RetrievalService.GetTagExtendedPropertiesFromName` + `HistoryService.AddTagExtendedProperties`; gRPC also exposes `DeleteTagExtendedProperties` (WCF delete was server-blocked) | -| `ExecuteSqlCommandAsync` | ✅ | 🔌 | gRPC `RetrievalService.ExecuteSqlCommand` (`StrCommand` + `uiOption`, mirrors WCF `ExeC`/`GetR`) | -| `GetRuntimeParameterAsync` | ✅ | 🔌 | gRPC `StatusService.GetRuntimeParameter` (`bytes btRequest` + handle) | +| `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 | +| `AddTagExtendedPropertiesAsync` | ✅ | 🧪 | tooled + routed over gRPC (`HistoryService.AddTagExtendedProperties`, write-enabled session); sandbox-gated. gRPC also exposes `DeleteTagExtendedProperties` (WCF delete was server-blocked) | | `GetConnectionStatusAsync` | ✅ | ❌ | synthesized from an authenticated probe — no dedicated RPC on either transport (gRPC `PingServer`/`GetHistorianConsoleStatus` could synthesize it) | | `ReadBlocksAsync` | ❌ | ❌ | `StartBlockRetrievalQuery` never captured on either transport — throws `ProtocolEvidenceMissingException` | In short: **WCF is the broad, mature surface** (every config write, events, SQL, and all reads), while **gRPC is the narrower *tooled* surface** — but the 2023 R2 -gRPC *contract* is actually a **superset** of WCF. Every 🔌 row above has a -recovered RPC carrying the **same opaque `bytes` buffers the existing WCF -serializers already emit**, keyed by the same `strHandle`/`uiHandle` session -handle the gRPC read path already obtains. So these are **capture-and-wire** items -(route the existing serializer into a gRPC orchestrator + golden-capture the -framing), **not** protocol-discovery items. We have only *buffer-verified* two -gRPC families live — the read chain and `AddStreamValues` — so per the -"capture first, never guess wire bytes" rule the 🔌 rows stay untooled until each -is captured. The natural production pattern today remains WCF for config/reads and -`RemoteGrpc` reserved for `AddHistoricalValuesAsync`. +gRPC *contract* is actually a **superset** of WCF. The recovered config RPCs carry +the **same opaque `bytes` buffers the existing WCF serializers already emit**, +keyed by the same `strHandle`/`uiHandle` session handle the read path obtains — +confirmed by tooling the read-side config ops (`GetRuntimeParameter`, +`GetTagExtendedProperties`) live: the WCF buffers ride the gRPC RPC unchanged and +the server accepts them. Two caveats surfaced when capturing the rest: `ExecuteSqlCommand` +is **server-walled** (the front-door `CSrvDbConnection` faults on a DB-connection +precondition the managed session doesn't establish — the same *class* of wall as +`OpenStorageConnection`), and `ReadEvents` needs the CM_EVENT registration state +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 > 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_TIMEOUT = '120' # per-call deadline (s); raise for slow links $env:HISTORIAN_WRITE_SANDBOX_TAG = 'MyFloatTag' # gates the AddHistoricalValues write test +$env:HISTORIAN_GRPC_WRITE_SANDBOX_TAG = 'SandboxTag' # gates the DESTRUCTIVE tag create/rename/delete lifecycle test ``` The aggregate tests self-calibrate their query window from a real raw sample, so diff --git a/docs/plans/grpc-tooling-completion.md b/docs/plans/grpc-tooling-completion.md new file mode 100644 index 0000000..5f58d45 --- /dev/null +++ b/docs/plans/grpc-tooling-completion.md @@ -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 .Client(connection.Channel).(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= HISTORIAN_PASSWORD= +HISTORIAN_TEST_TAG=SysTimeSec +# writes only, destructive: HISTORIAN_GRPC_WRITE_SANDBOX_TAG= +# slow links: HISTORIAN_GRPC_TIMEOUT=120 +``` + +Run a subset: `dotnet test ./Histsdk.slnx --no-build --filter "FullyQualifiedName~"`. +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. diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcSqlClient.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcSqlClient.cs new file mode 100644 index 0000000..b5bb36d --- /dev/null +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcSqlClient.cs @@ -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; + +/// +/// Executes SQL commands over the 2023 R2 gRPC transport (HCAL R1.1), mirroring +/// 's two-op ExeC/GetR flow. The 2020 WCF path uses a +/// dedicated GetRecordSetByteStream op; the gRPC front door has no such RPC, so the NRBF +/// recordset stream would be fetched through the generic RetrievalService.GetNextQueryResultBuffer +/// keyed by the query handle ExecuteSqlCommand returns. ExecuteSqlCommand takes the +/// uppercase string session handle; the result-buffer fetch takes the transient uint client +/// handle (both come from the one Open2 session). +/// +/// SERVER-WALLED (captured 2026-06-22). The 2023 R2 front-door +/// RetrievalService.ExecuteSqlCommand faults server-side before returning a query handle: +/// the response carries native error 38 wrapping a managed +/// System.IndexOutOfRangeException ... at aahClientAccessPoint.CSrvDbConnection.ExecuteSqlCommand. +/// This is a server-side CSrvDbConnection (SQL DB-connection) precondition that the pure +/// managed gRPC session does not establish — the same class of wall as +/// StorageService.OpenStorageConnection (whose real precondition is the front-door +/// HistoryService.RegisterTags family). Priming Retr.GetV does not clear it. The request +/// framing here is the captured/expected shape; the op stays bounded behind +/// until the DB-connection registration is reproduced. +/// +/// +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 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); + } +} diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcStatusClient.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcStatusClient.cs index f96141a..3e67538 100644 --- a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcStatusClient.cs +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcStatusClient.cs @@ -1,5 +1,7 @@ +using Google.Protobuf; using Grpc.Core; using AVEVA.Historian.Client.Models; +using AVEVA.Historian.Client.Wcf; using GrpcStatus = ArchestrA.Grpc.Contract.Status; namespace AVEVA.Historian.Client.Grpc; @@ -142,4 +144,46 @@ internal static class HistorianGrpcStatusClient string? value = response.StrSystemTimeZoneName; return string.IsNullOrEmpty(value) ? null : value; } + + /// + /// Reads a Historian runtime parameter over gRPC (StatusService.GetRuntimeParameter). + /// The request/response byte buffers are the proven 2020 GETRP wire format + /// () carried unchanged inside the protobuf + /// btRequest/btResponse fields; the op keys on the uppercase string session handle. + /// + public static Task 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); + } } diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs index 7600d80..e8476b0 100644 --- a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs @@ -92,6 +92,78 @@ internal static class HistorianGrpcTagClient 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; + + /// + /// Reads a tag's extended (user-defined) properties over gRPC + /// (RetrievalService.GetTagExtendedPropertiesFromName, a string-handle op). The request + /// btTagNames and response btTeps buffers are the proven 2020 GetTepByNm wire + /// format () carried unchanged; paging follows + /// the same sequence loop as the WCF path. + /// + public static Task> GetTagExtendedPropertiesAsync( + HistorianClientOptions options, + string tag, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tag); + return Task.Run(() => GetTagExtendedProperties(options, tag, cancellationToken), cancellationToken); + } + + private static IReadOnlyList 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 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 rows = + HistorianTagExtendedPropertyProtocol.ParseResponse(response.BtTeps?.ToByteArray() ?? []); + if (rows.Count == 0) + { + break; + } + + foreach (HistorianTagExtendedPropertyRow row in rows) + { + properties.Add(new HistorianTagExtendedProperty(row.PropertyName, row.Value)); + } + + sequence = response.UiSequence; + } + + return properties; + } + // QueryTag (browse paging) request framing, recovered from the .rdata packet-descriptor table // in aahClientManaged.dll (entries {0x6751,1}=StartTagQuery, {0x6752,1}=QueryTag) and confirmed // live: btRequest = u16 marker(0x6752) + u16 version(1) + u16 queryType + u32 startIndex + u32 count. diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagWriteOrchestrator.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagWriteOrchestrator.cs new file mode 100644 index 0000000..480b991 --- /dev/null +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagWriteOrchestrator.cs @@ -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; + +/// +/// Tag-configuration write ops over the 2023 R2 gRPC transport, mirroring +/// . Each op opens a write-enabled Open2 session +/// (0x401) and reuses the proven 2020 byte serializers verbatim inside the protobuf +/// bytes fields: +/// +/// HistoryService.EnsureTags (string handle, +/// btTagInfos = ) +/// HistoryService.DeleteTags (uint handle, +/// btTagnames = ) +/// HistoryService.StartJob (string handle, +/// btInput = ) +/// HistoryService.AddTagExtendedProperties +/// (string handle, btTeps = ) +/// +/// +/// Tooled but not yet live-verified. 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. +/// +/// +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 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 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 AddTagExtendedPropertiesAsync( + string tagName, IReadOnlyList 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 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 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.", + }; + } +} diff --git a/src/AVEVA.Historian.Client/HistorianClient.cs b/src/AVEVA.Historian.Client/HistorianClient.cs index d044514..fd7f8d7 100644 --- a/src/AVEVA.Historian.Client/HistorianClient.cs +++ b/src/AVEVA.Historian.Client/HistorianClient.cs @@ -237,7 +237,9 @@ public sealed class HistorianClient : IAsyncDisposable { ArgumentException.ThrowIfNullOrWhiteSpace(tag); 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); } /// Convenience overload of for a single @@ -285,7 +287,9 @@ public sealed class HistorianClient : IAsyncDisposable public Task EnsureTagAsync(HistorianTagDefinition definition, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(definition); - return new HistorianWcfTagWriteOrchestrator(_options).EnsureTagAsync(definition, cancellationToken); + return _options.Transport == HistorianTransport.RemoteGrpc + ? new Grpc.HistorianGrpcTagWriteOrchestrator(_options).EnsureTagAsync(definition, cancellationToken) + : new HistorianWcfTagWriteOrchestrator(_options).EnsureTagAsync(definition, cancellationToken); } /// @@ -299,7 +303,9 @@ public sealed class HistorianClient : IAsyncDisposable public Task DeleteTagAsync(string tagName, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(tagName); - return new HistorianWcfTagWriteOrchestrator(_options).DeleteTagAsync(tagName, cancellationToken); + return _options.Transport == HistorianTransport.RemoteGrpc + ? new Grpc.HistorianGrpcTagWriteOrchestrator(_options).DeleteTagAsync(tagName, cancellationToken) + : new HistorianWcfTagWriteOrchestrator(_options).DeleteTagAsync(tagName, cancellationToken); } /// @@ -325,7 +331,9 @@ public sealed class HistorianClient : IAsyncDisposable public Task RenameTagsAsync(IReadOnlyList<(string OldName, string NewName)> pairs, CancellationToken cancellationToken = default) { 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() diff --git a/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs b/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs index 82b4290..b13d90b 100644 --- a/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs +++ b/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs @@ -95,21 +95,27 @@ internal sealed class Historian2020ProtocolDialect { cancellationToken.ThrowIfCancellationRequested(); 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> GetTagExtendedPropertiesAsync(string tag, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); 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 ExecuteSqlCommandAsync(string command, HistorianSqlExecuteOption option, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); 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 Missing( diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs index 7cfd9cd..7cf7e47 100644 --- a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs @@ -351,6 +351,104 @@ public sealed class HistorianGrpcIntegrationTests 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 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(() => 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 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) { string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER");