diff --git a/docs/plans/hcal-roadmap.md b/docs/plans/hcal-roadmap.md index bc8dc3d..fe8e2bd 100644 --- a/docs/plans/hcal-roadmap.md +++ b/docs/plans/hcal-roadmap.md @@ -166,8 +166,8 @@ read/browse/status surface is Windows-free and the gRPC stack is the default pat | ~~R1.5~~ | Extended-property **read** | `Retrieval.GetTagExtendedPropertiesFromName` (`GetTepByNm`) | ✅ **DONE (2026-06-20), live-verified.** `GetTagExtendedPropertiesAsync(tag)` → name/value pairs. String-handle op via the uppercase storage GUID; name-based path (`GetTagExtendedPropertiesByName`, not the QTB-gated TagQuery path). Request `tagNames` = `uint count` + per-name(`uint charCount`+UTF-16); response = `uint tagCount` + per-tag(marker + compact-ASCII name + `uint propCount` + per-prop(marker + compact-ASCII name + `0x43` VT_BSTR value) + trailer). Sequence-paged. Shipped: `HistorianTagExtendedPropertyProtocol`, golden `WcfTagExtendedPropertyProtocolTests`, gated live test. See `docs/reverse-engineering/wcf-tag-extended-properties.md`. | uppercase string handle | | ~~R1.6~~ | Localized-property **read** | (no op) | ⛔ **No distinct op on 2020 — collapses into R1.5.** There is no `GetTagLocalizedPropertiesFromName`/`GetTlpByNm` or `GetTagLocalizedPropertiesByName` in `current/aahClientManaged.dll`; the only "localized" surfaces are error-message/UI-text localization. Extended properties (R1.5) are the user-defined tag-property read surface. Closed, not throwing. | — | | ~~R1.7~~ | Event **filters** | filter bytes in `Retrieval.StartEventQuery` | ✅ **DONE (2026-06-20), live-honored.** `ReadEventsAsync(start, end, HistorianEventFilter)`. The filter rides `StartEventQuery`'s `pRequestBuff` (captured via `EventQuery.AddEventFilter` + instrument-wcf-writemessage; Equal vs Contains diffed to isolate the op). Filter block: `ushort 0 + uint filterCount + uint condCount + uint nameLen + name(UTF-16) + uint 1 + ushort op + uint 1 + value(0x09-len-0x00 compact-ASCII) + byte 0`. **REAL, not inert** (a non-matching predicate returns 0 events; matching returns the subset). Single string-valued predicate only; multi-filter (OR) / multi-condition (AND via `AddEventFilterCondition`) framing not yet fully captured. See `HistorianEventFilter`, golden `WcfEventQueryProtocolTests`. | — | -| R1.8 | Analog-summary query | `Retrieval.StartQuery` (summary mode) | summary row layout — **`uint`-handle, reachable. Scoped + decode targets located** (`CAnalogSummaryValue.UnpackFromValueBuffer`, fields Min/Max/First/Last/ValueCount/Integral/…). Plan: [`r1.8-r1.9-summary-queries.md`](r1.8-r1.9-summary-queries.md) | — | -| R1.9 | State-summary query | `Retrieval.StartQuery` (state mode) | state-summary row layout — **`uint`-handle, reachable. Scoped** (`CStateSummaryStruct`: MinContained/MaxContained/TotalContained/PartialStart/PartialEnd/StateEntryCount). Plan: [`r1.8-r1.9-summary-queries.md`](r1.8-r1.9-summary-queries.md) | — | +| ~~R1.8~~ | Analog-summary query | `Retrieval.StartQuery` (summary mode) | ✅ **RESOLVED (2026-06-21) — no new code; == existing `ReadAggregateAsync`.** Request + response both captured (`scripts/Capture-SummaryRequest.ps1 -WithResponse`): the `GetNextQueryResultBuffer2` response is the **ordinary version-9 row buffer** the raw/aggregate parser already handles (decoded 7 rows = SQL ground truth exactly). There is **no rich `CAnalogSummaryValue` struct on the wire** — each row carries a *single* value selected by `RetrievalMode`/QueryType (Integral→8, TimeWeightedAverage→5, …), not an all-aggregates-in-one row; `ValueSelector`/`AggregationType`/`MaxStates` are **inert** on the WCF retrieval path (they configure the SQL provider, not this query). The all-aggregates-at-once shape is the SQL/OLEDB provider's, or the gRPC front door — not 2020 WCF binary. Plan + capture evidence: [`r1.8-r1.9-summary-queries.md`](r1.8-r1.9-summary-queries.md). | — | +| ~~R1.9~~ | State-summary query | `Retrieval.StartQuery` (state mode) | ✅ **RESOLVED (2026-06-21) — same finding as R1.8.** State-summary is the **same `StartQuery2` request** (only `MaxStates`/defaults differ on the wire); the response carries no distinct `CStateSummaryStruct` on the 2020 WCF binary path. Covered by the existing aggregate read; no new `src/` code warranted. Plan: [`r1.8-r1.9-summary-queries.md`](r1.8-r1.9-summary-queries.md). | — | ### 1c. Bounded config writes (S–M each) | ID | Capability | gRPC op | Payload | Notes | @@ -221,9 +221,21 @@ byte-correct `AddS2` (✅). Appears-and-reads-back is environment-gated on event *Goal: insert original historical VTQs (backfill), the path that is NOT the gated cache push.* +> ✅ **gRPC UNLOCK (2026-06-21, LIVE-VERIFIED): the transaction lifecycle is REACHABLE over the +> 2023 R2 gRPC front door.** The `grpc-revision-probe` opened a **write-enabled** (`0x401`) gRPC +> session and drove `TransactionService.AddNonStreamValuesBegin(storage-GUID **uppercase**)` → +> real `strTransactionId` → `AddNonStreamValuesEnd(bCommit=false)` (discarded, no data written). +> Where 2020 WCF returns `UnknownClient (51)`, the gRPC `TransactionService` is itself the gateway +> to the storage engine, so the Open2 session GUID is accepted directly — **no legacy pipe**. This +> answers the M3-over-gRPC question below: **yes**, the non-streamed *original* write transaction is +> reachable from the pure-managed SDK. **Not yet shipped:** the `AddNonStreamValues` `btInput` VTQ +> buffer must be captured before any value-commit (never guess wire bytes); revision *edits* (R4.2) +> remain pipe-only even on gRPC. Full detail + decompile basis: +> [`revision-write-path.md`](revision-write-path.md) §"2023 R2 gRPC — the wall is gone". +> > ⛔ **BLOCKED on 2020 WCF — re-confirmed by the D2 probe (2026-05-05), see > [`revision-write-path.md`](revision-write-path.md).** The premise above ("the path that is NOT -> the gated cache push") was **disproved**: R3.1's op +> the gated cache push") was **disproved** *on WCF*: R3.1's op > (`Transaction.AddNonStreamValuesBegin/AddNonStreamValues/End`) is the **same** > `ITransactionServiceContract2.AddNonStreamValuesBegin2` D2 probed, and over WCF it returns > `04 33 00 00 00` = `UnknownClient (51)` for every handle format **and** the full priming chain @@ -242,12 +254,13 @@ byte-correct `AddS2` (✅). Appears-and-reads-back is environment-gated on event | ID | Work | gRPC op | Status | |---|---|---|---| -| R3.1 | Decode non-streamed VTQ packet | `Transaction.AddNonStreamValuesBegin/AddNonStreamValues/End` | ⛔ WCF blocked (storage-engine pipe — D2). gRPC: untested | -| R3.2 | `AddHistoricalValuesAsync` | batched begin→values→end | ⛔ gated on R3.1 | -| R3.3 | Ingest-permission validation | confirm the target accepts original-data insert (distinct from `AddS2` cache wall) | ⛔ proven to share the same gate, not distinct | +| R3.1 | Decode non-streamed VTQ packet | `Transaction.AddNonStreamValuesBegin/AddNonStreamValues/End` | 🟡 **gRPC transaction Begin/End LIVE-VERIFIED 2026-06-21** (WCF still blocked — D2). Remaining: capture the `AddNonStreamValues` `btInput` VTQ buffer (don't guess) | +| R3.2 | `AddHistoricalValuesAsync` | batched begin→values→end | 🟡 unblocked by R3.1's gRPC proof; needs the `btInput` serializer + a real `bCommit=true` write/read-back | +| R3.3 | Ingest-permission validation | confirm the target accepts original-data insert (distinct from `AddS2` cache wall) | ✅ **distinct on gRPC** — Begin succeeded against a real write-enabled session (the WCF/native cache gate does not apply here) | -**Acceptance:** historical points inserted and read back. **WCF path closed (D2);** would require -the gRPC write path (live 2023 R2 server + capture) to reopen. +**Acceptance:** historical points inserted and read back. **WCF path closed (D2).** gRPC path: +**transaction lifecycle proven (Begin/End live)**; full insert+read-back pending the `btInput` +capture + serializer. --- @@ -301,11 +314,13 @@ event-send). M3/M4 as demand dictates. > **Status 2026-06-21:** sprints 1 + 2 are **complete** (M0 gRPC parity, the reachable M1 surface, > and M2 event-send all shipped + live-verified; remaining M1 items are evidence-bounded-out). The -> reachable surface on the **available 2020 WCF infrastructure is exhausted** — every remaining -> roadmap item is now either (a) blocked by the storage-engine-pipe architecture (**M3-WCF**, R4.2), -> (b) **gRPC/2023R2-only** and needs the live 2023 R2 server for a native capture (R1.3 timezone, -> R1.4 EventStorageMode, M3/revisions over gRPC), or (c) a HARD deferred subsystem (M4). No further -> work lands without one of: a live-2023R2 capture session, or a customer-demand trigger. +> reachable surface on the **available 2020 WCF infrastructure is exhausted**. **M3 update +> (2026-06-21):** with the live 2023 R2 server, the **M3 non-streamed write transaction is now +> proven reachable over gRPC** — `TransactionService.AddNonStreamValuesBegin/End` round-trips live +> (the D2 storage-engine-pipe wall is WCF-only). The remaining M3 work is bounded and concrete: +> capture the `AddNonStreamValues` `btInput` VTQ buffer → golden-tested serializer → real +> commit+read-back → public `AddHistoricalValuesAsync`. The other levers are unchanged: R4.2 revision +> *edits* stay pipe-only even on gRPC, and M4 (SF / redundancy) is a HARD deferred subsystem. ## One-glance status @@ -314,5 +329,5 @@ event-send). M3/M4 as demand dictates. | M0 gRPC parity + capture tooling | foundation | M | unblocks everything, Windows-free | ✅ **done** | | M1 cheap surface | TRIVIAL/BOUNDED | M–L | most remaining read/config | ✅ **done** (reachable surface; rest bounded out) | | M2 event send | CAPTURE | S–M | headline write capability | ✅ **done** | -| M3 historical writes | BOUNDED | M | backfill | ⛔ WCF blocked (D2); gRPC = on-demand + live 2023R2 | +| M3 historical writes | BOUNDED | M | backfill | 🟡 **gRPC transaction Begin/End live-verified (2026-06-21)**; WCF blocked (D2). Remaining: `btInput` capture → commit+read-back | | M4 SF / revisions / redundancy | HARD | L×N | parity completeness | defer (R4.2 = same pipe wall) | diff --git a/docs/plans/revision-write-path.md b/docs/plans/revision-write-path.md index b326689..66065aa 100644 --- a/docs/plans/revision-write-path.md +++ b/docs/plans/revision-write-path.md @@ -1,6 +1,66 @@ # Plan: Revision-Write Path (`AddRevisionValuesBegin/Value/End`) -Status: **ARCHITECTURALLY BLOCKED — verified 2026-05-05.** Same root +Status: **WCF: ARCHITECTURALLY BLOCKED (verified 2026-05-05).** **gRPC (2023 R2): the +non-streamed-original transaction is REACHABLE — Begin/End round-trip LIVE-VERIFIED 2026-06-21.** +Same root cause on WCF as `AddS2`: the `TransactionService` relay needs a pre-existing +storage-engine *pipe* session no WCF op can create. The 2023 R2 gRPC front door removes that wall +(see the §"2023 R2 gRPC — the wall is gone" section immediately below); the legacy WCF analysis is +preserved unchanged after it. + +## 2023 R2 gRPC — the wall is gone (non-streamed original writes), LIVE-VERIFIED 2026-06-21 + +The whole D2 WCF blocker was: `ITransactionServiceContract2.AddNonStreamValuesBegin2` returns +`04 33 00 00 00` = `UnknownClient (51)` because the server-side Trx relay requires a storage-engine +pipe session (`STransactPipeClient2` → `aaStorageEngine.exe`) that no WCF op establishes. On the +**2023 R2 gRPC** transport that relay is replaced by a first-class `TransactionService` gRPC +service, and the gRPC server is itself the gateway to the storage engine — so the client passes the +**HistoryService Open2 storage-session GUID** straight in as `strHandle` and the transaction opens. + +**Live probe (`grpc-revision-probe` CLI command / `HistorianGrpcRevisionProbe`):** against the real +2023 R2 server (History iface 12), over a **write-enabled** (`0x401`) gRPC session — + +| step | result | +|---|---| +| `HistoryService.OpenConnection` (write-enabled `0x401`) | ✅ `OpenSucceeded`, client handle + storage GUID returned | +| `TransactionService.GetTransactionInterfaceVersion` | ✅ error 0, **version 2** | +| `TransactionService.AddNonStreamValuesBegin(strHandle = storage GUID **UPPERCASE**)` | ✅ **`BeginSucceeded`** — returns a real `strTransactionId` (e.g. `…-FE0A-4822-…`) on the **first** handle format tried | +| `TransactionService.AddNonStreamValuesEnd(handle, txId, bCommit=**false**)` | ✅ `EndDiscardSucceeded` — transaction discarded, **no data written** | + +So the answer to the roadmap's open M3-over-gRPC question ("does the 2023 R2 gRPC front door expose +a non-streamed write that bypasses the legacy storage-engine pipe?") is **YES** — Begin/End is +reachable from the pure-managed SDK with no pipe, no native wrapper. The probe is committed as the +`grpc-revision-probe` CLI command + the gated test +`HistorianGrpcIntegrationTests.NonStreamedWriteTransaction_OverGrpc_BeginsAndDiscards`; re-run any +time to confirm the path is still open. + +### Decompile basis (handle + op group) + +`Archestra.Historian.GrpcClient.GrpcHistoryClient` drives the identical three-phase sequence +(`AddNonStreamValuesBegin(strHandle) → strTransactionId`; `AddNonStreamValues(strHandle, +strTransactionId, btInput)`; `AddNonStreamValuesEnd(strHandle, strTransactionId, bCommit)`), passing +the Open2 session GUID as `strHandle`. `btInput` is the **same opaque native VTQ buffer** the 2020 +path uses. Proto: `src/AVEVA.Historian.Client/Grpc/Protos/TransactionService.proto`. + +### What is proven vs. what remains (do NOT ship yet) + +- ✅ **Proven:** the transaction lifecycle (Begin → End/rollback) is reachable over gRPC. The D2 + architectural wall is specific to the WCF transport. +- ⛔ **Not yet captured:** the `AddNonStreamValues` **`btInput` VTQ buffer byte layout**. Per project + discipline ("never guess wire bytes; capture first") no value-commit is implemented. The next step + to actually *ship* M3 (`AddHistoricalValuesAsync`) is to capture the native gRPC `AddNonStreamValues` + `btInput` (or decode the `GrpcHistoryClient` serializer), build a golden-tested serializer, then do a + real `bCommit=true` write + SQL read-back against a sandbox tag created by `EnsureTagAsync`. +- 🔒 **Scope:** this is **non-streamed ORIGINAL backfill** (`HistorianDataCategory.NonStreamedOriginal` + → `TransactionService.AddNonStreamValues*`). **Revision EDITS** (`AddRevisionValue(s)` / + `RevisionInsert*`, the R4.2 path) are NOT on the gRPC contract even in 2023 R2 — the capability + matrix confirms they still ride the storage-engine pipe. The gRPC unlock here is original backfill, + not after-the-fact edits. + +--- + +## Legacy WCF analysis (preserved — still accurate for the 2020 WCF transport) + +Status (WCF only): **ARCHITECTURALLY BLOCKED — verified 2026-05-05.** Same root cause as `AddS2`: client-side cache rejects values for tags that weren't registered through a configured IO server / Application Server pipeline. Documented below; implementation deferred until / unless that diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcHandshake.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcHandshake.cs index 787adf0..79d88b1 100644 --- a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcHandshake.cs +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcHandshake.cs @@ -40,10 +40,17 @@ internal static class HistorianGrpcHandshake CancellationToken cancellationToken) => OpenSession(connection, options, cancellationToken).ClientHandle; + /// + /// The native Open2 connection mode. Defaults to read-only (0x402); pass + /// + /// (0x401) for write-enabled sessions (e.g. the non-streamed/revision Transaction path, + /// which the read-only mode silently rejects with err 132 OperationNotEnabled). + /// public static Session OpenSession( HistorianGrpcConnection connection, HistorianClientOptions options, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + uint connectionMode = HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode) { DateTime Deadline() => DateTime.UtcNow.Add(options.RequestTimeout); @@ -73,7 +80,7 @@ internal static class HistorianGrpcHandshake cancellationToken); byte[] open2Request = HistorianNativeHandshake.BuildOpenConnection3Request( - options.Host, contextKey, HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode); + options.Host, contextKey, connectionMode); GrpcHistory.OpenConnectionResponse open2 = historyClient.OpenConnection( new GrpcHistory.OpenConnectionRequest { BtConnectionRequest = ByteString.CopyFrom(open2Request) }, diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcRevisionProbe.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcRevisionProbe.cs new file mode 100644 index 0000000..cbff48e --- /dev/null +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcRevisionProbe.cs @@ -0,0 +1,157 @@ +using Google.Protobuf; +using AVEVA.Historian.Client.Wcf; +using GrpcTransaction = ArchestrA.Grpc.Contract.Transaction; + +namespace AVEVA.Historian.Client.Grpc; + +/// +/// Live probe for the M3 (historical / non-streamed original-value write) path over the 2023 R2 +/// gRPC front door. On 2020 WCF this op group is architecturally blocked: the +/// ITransactionServiceContract2.AddNonStreamValuesBegin2 relay returns +/// UnknownClient (51) because it requires a pre-existing storage-engine pipe session +/// (STransactPipeClient2aaStorageEngine.exe) that no WCF op can establish — see +/// docs/plans/revision-write-path.md (the D2 finding). +/// +/// The 2023 R2 decompile shows the native gRPC client driving the SAME op group over +/// TransactionService.AddNonStreamValuesBegin/AddNonStreamValues/AddNonStreamValuesEnd and +/// passing the HistoryService Open2 session GUID directly as strHandle — i.e. the gRPC +/// server is the gateway to the storage engine, so the client never touches the legacy pipe. This +/// probe tests whether the SDK's pure-managed handshake can reproduce that: it opens a +/// write-enabled session and calls AddNonStreamValuesBegin, surfacing whatever the server +/// returns. It writes NO data — if Begin succeeds it immediately calls AddNonStreamValuesEnd +/// with bCommit=false to discard the transaction. +/// +internal sealed class HistorianGrpcRevisionProbe +{ + private readonly HistorianClientOptions _options; + + public HistorianGrpcRevisionProbe(HistorianClientOptions options) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + } + + public Task ProbeBeginAsync(CancellationToken cancellationToken) + => Task.Run(() => ProbeBegin(cancellationToken), cancellationToken); + + private HistorianGrpcRevisionProbeResult ProbeBegin(CancellationToken cancellationToken) + { + var result = new HistorianGrpcRevisionProbeResult(); + + using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options); + + HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession( + connection, + _options, + cancellationToken, + connectionMode: HistorianWcfAuthChainHelper.NativeIntegratedWriteEnabledConnectionMode); + + result.OpenSucceeded = true; + result.ClientHandle = session.ClientHandle; + result.StorageSessionId = session.StorageSessionId; + + var transactionClient = new GrpcTransaction.TransactionService.TransactionServiceClient(connection.Channel); + DateTime Deadline() => DateTime.UtcNow.Add(_options.RequestTimeout); + + // Register the client with the Transaction service's session table (matches the + // cross-service GetV priming the WCF write path uses). + try + { + GrpcTransaction.GetTransactionInterfaceVersionResponse version = transactionClient.GetTransactionInterfaceVersion( + new GrpcTransaction.GetTransactionInterfaceVersionRequest(), connection.Metadata, Deadline(), cancellationToken); + result.TrxInterfaceVersionError = version.Error; + result.TrxInterfaceVersion = version.Version; + } + catch (Exception ex) + { + result.TrxInterfaceVersionException = $"{ex.GetType().Name}: {ex.Message}"; + } + + // The decompiled native client passes the Open2 storage-session GUID (string) as strHandle. + // Try that first (uppercase "D" form, as the other string-handle ops require), then a couple + // of fallbacks mirroring the WCF probe, so a wrong-format rejection is distinguishable from a + // genuine server-side block. + foreach ((string label, string handle) in new[] + { + ("storageSessionId-upper", session.StringHandle), + ("storageSessionId-lower", session.StorageSessionId.ToString("D")), + ("clientHandle-as-string", session.ClientHandle.ToString()), + }) + { + var attempt = new HistorianGrpcRevisionBeginAttempt { HandleLabel = label, HandleSent = handle }; + try + { + GrpcTransaction.AddNonStreamValuesBeginResponse begin = transactionClient.AddNonStreamValuesBegin( + new GrpcTransaction.AddNonStreamValuesBeginRequest { StrHandle = handle }, + connection.Metadata, Deadline(), cancellationToken); + + attempt.Succeeded = begin.Status?.BSuccess ?? false; + attempt.TransactionId = begin.StrTransactionId; + byte[] error = begin.Status?.BtError?.ToByteArray() ?? []; + attempt.ErrorHex = error.Length == 0 ? null : Convert.ToHexString(error); + result.BeginAttempts.Add(attempt); + + if (attempt.Succeeded && !string.IsNullOrEmpty(attempt.TransactionId)) + { + result.BeginSucceeded = true; + result.BeginTransactionId = attempt.TransactionId; + + // Discard immediately — bCommit=false writes nothing. This keeps the probe + // read-only against the live (production) server. + try + { + GrpcTransaction.AddNonStreamValuesEndResponse end = transactionClient.AddNonStreamValuesEnd( + new GrpcTransaction.AddNonStreamValuesEndRequest + { + StrHandle = handle, + StrTransactionId = attempt.TransactionId, + BCommit = false, + }, + connection.Metadata, Deadline(), cancellationToken); + result.EndDiscardSucceeded = end.Status?.BSuccess ?? false; + byte[] endError = end.Status?.BtError?.ToByteArray() ?? []; + result.EndDiscardErrorHex = endError.Length == 0 ? null : Convert.ToHexString(endError); + } + catch (Exception ex) + { + result.EndDiscardException = $"{ex.GetType().Name}: {ex.Message}"; + } + + break; + } + } + catch (Exception ex) + { + attempt.Exception = $"{ex.GetType().Name}: {ex.Message}"; + result.BeginAttempts.Add(attempt); + } + } + + return result; + } +} + +internal sealed class HistorianGrpcRevisionProbeResult +{ + public bool OpenSucceeded { get; set; } + public uint ClientHandle { get; set; } + public Guid StorageSessionId { get; set; } + public uint? TrxInterfaceVersionError { get; set; } + public uint? TrxInterfaceVersion { get; set; } + public string? TrxInterfaceVersionException { get; set; } + public bool BeginSucceeded { get; set; } + public string? BeginTransactionId { get; set; } + public bool EndDiscardSucceeded { get; set; } + public string? EndDiscardErrorHex { get; set; } + public string? EndDiscardException { get; set; } + public List BeginAttempts { get; } = new(); +} + +internal sealed class HistorianGrpcRevisionBeginAttempt +{ + public string HandleLabel { get; set; } = ""; + public string HandleSent { get; set; } = ""; + public bool Succeeded { get; set; } + public string? TransactionId { get; set; } + public string? ErrorHex { get; set; } + public string? Exception { get; set; } +} diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs index facb60c..6a805f3 100644 --- a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs @@ -1,3 +1,4 @@ +using AVEVA.Historian.Client.Grpc; using AVEVA.Historian.Client.Models; namespace AVEVA.Historian.Client.Tests; @@ -123,6 +124,30 @@ public sealed class HistorianGrpcIntegrationTests Assert.All(names, n => Assert.StartsWith("Sys", n, StringComparison.Ordinal)); } + [Fact] + public async Task NonStreamedWriteTransaction_OverGrpc_BeginsAndDiscards() + { + string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST"); + if (string.IsNullOrWhiteSpace(host) || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER"))) + { + return; + } + + // M3 reachability probe: on 2020 WCF this op group is walled (TransactionService relay + // returns UnknownClient(51) — the storage-engine-pipe requirement, see + // docs/plans/revision-write-path.md). On the 2023 R2 gRPC front door the native client + // passes the Open2 storage-session GUID straight to TransactionService and it works. + // This asserts the wall is gone: a write-enabled session opens and AddNonStreamValuesBegin + // returns a transaction id, which we immediately End with bCommit=false (writes nothing). + var probe = new HistorianGrpcRevisionProbe(BuildOptions(host)); + HistorianGrpcRevisionProbeResult result = await probe.ProbeBeginAsync(CancellationToken.None); + + Assert.True(result.OpenSucceeded); + Assert.True(result.BeginSucceeded, "AddNonStreamValuesBegin should return a transaction id over gRPC."); + Assert.False(string.IsNullOrEmpty(result.BeginTransactionId)); + Assert.True(result.EndDiscardSucceeded, "AddNonStreamValuesEnd(bCommit:false) should discard cleanly."); + } + private static HistorianClientOptions BuildOptions(string host) { string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER"); diff --git a/tools/AVEVA.Historian.ReverseEngineering/Program.cs b/tools/AVEVA.Historian.ReverseEngineering/Program.cs index 2b64c15..76168f8 100644 --- a/tools/AVEVA.Historian.ReverseEngineering/Program.cs +++ b/tools/AVEVA.Historian.ReverseEngineering/Program.cs @@ -13,6 +13,8 @@ using System.Runtime.Versioning; using System.Text; using System.Text.Json; using AVEVA.Historian.Client; +using AVEVA.Historian.Client.Grpc; +using AVEVA.Historian.Client.Models; using AVEVA.Historian.Client.Wcf; using AVEVA.Historian.Client.Wcf.Contracts; using AVEVA.Historian.ReverseEngineering.Capture; @@ -72,6 +74,7 @@ try "wcf-register-event-tag" => RegisterEventTagAndStartQuery(args), "wcf-add-event-tag" => AddEventTagAndStartQuery(args), "capture-tag-info" => CaptureTagInfo(args), + "grpc-revision-probe" => ProbeGrpcRevision(args), _ => UnknownCommand(args[0]) }; } @@ -3209,6 +3212,41 @@ static int WriteMarker(string[] args) return 0; } +static int ProbeGrpcRevision(string[] args) +{ + // Usage: grpc-revision-probe [port] [--tls] [--dnsid ] [--insecure-cert] + // Reads HISTORIAN_USER / HISTORIAN_PASSWORD from the environment for explicit creds; + // falls back to IntegratedSecurity when unset. + string host = args.Length > 1 ? args[1] : "localhost"; + int port = args.Length > 2 && int.TryParse(args[2], out int parsedPort) + ? parsedPort + : HistorianClientOptions.DefaultGrpcPort; + bool tls = HasOption(args, "--tls"); + string? dnsId = GetOption(args, "--dnsid"); + + string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER"); + string? password = Environment.GetEnvironmentVariable("HISTORIAN_PASSWORD"); + bool explicitCreds = !string.IsNullOrEmpty(user); + + var options = new HistorianClientOptions + { + Host = host, + Port = port, + Transport = HistorianTransport.RemoteGrpc, + GrpcUseTls = tls, + AllowUntrustedServerCertificate = tls, + ServerDnsIdentity = dnsId, + IntegratedSecurity = !explicitCreds, + UserName = user ?? string.Empty, + Password = password ?? string.Empty, + }; + + var probe = new HistorianGrpcRevisionProbe(options); + HistorianGrpcRevisionProbeResult result = probe.ProbeBeginAsync(CancellationToken.None).GetAwaiter().GetResult(); + Console.WriteLine(JsonSerializer.Serialize(result, CreateJsonOptions())); + return result.BeginSucceeded ? 0 : 2; +} + static int ProbeWcf(string[] args) { string host = args.Length > 1 ? args[1] : "localhost";