M3 probe: non-streamed write transaction reachable over 2023 R2 gRPC (Begin/End live-verified)

The D2 storage-engine-pipe wall is WCF-transport-specific. On the 2023 R2 gRPC
front door, TransactionService is a first-class service AND the gateway to the
storage engine, so the Open2 storage-session GUID (uppercase) is accepted
directly as strHandle with no legacy pipe.

Live-verified against the real 2023 R2 server over a write-enabled (0x401) gRPC
session: AddNonStreamValuesBegin returns a real strTransactionId, and
AddNonStreamValuesEnd(bCommit=false) discards it cleanly (no data written). On
2020 WCF the same op returns UnknownClient(51) for every handle + priming chain.

- HistorianGrpcRevisionProbe + grpc-revision-probe CLI command + gated test
  NonStreamedWriteTransaction_OverGrpc_BeginsAndDiscards (live pass).
- HistorianGrpcHandshake.OpenSession gains an optional connectionMode param
  (default read-only 0x402; pass 0x401 for write-enabled) — non-breaking.
- Docs: revision-write-path.md "the wall is gone" section; roadmap M3 section,
  R3.1-R3.3 rows, one-glance table, and status note updated honestly.

Not yet shipped: the AddNonStreamValues btInput VTQ buffer is uncaptured (never
guess wire bytes), so no value-commit is implemented. Scope is non-streamed
ORIGINAL backfill; revision EDITS (R4.2) remain pipe-only even on gRPC.

272 unit tests pass; sanitization scan clean.

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:
Joseph Doherty
2026-06-21 17:51:17 -04:00
parent 04ea0b9a1f
commit 23798db1ef
6 changed files with 319 additions and 17 deletions
+29 -14
View File
@@ -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 (SM 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 | ML | most remaining read/config | ✅ **done** (reachable surface; rest bounded out) |
| M2 event send | CAPTURE | SM | 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) |
+61 -1
View File
@@ -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
@@ -40,10 +40,17 @@ internal static class HistorianGrpcHandshake
CancellationToken cancellationToken)
=> OpenSession(connection, options, cancellationToken).ClientHandle;
/// <param name="connectionMode">
/// The native Open2 connection mode. Defaults to read-only (<c>0x402</c>); pass
/// <see cref="HistorianWcfAuthChainHelper.NativeIntegratedWriteEnabledConnectionMode"/>
/// (<c>0x401</c>) for write-enabled sessions (e.g. the non-streamed/revision Transaction path,
/// which the read-only mode silently rejects with err 132 OperationNotEnabled).
/// </param>
public static Session OpenSession(
HistorianGrpcConnection connection,
HistorianClientOptions options,
CancellationToken cancellationToken)
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) },
@@ -0,0 +1,157 @@
using Google.Protobuf;
using AVEVA.Historian.Client.Wcf;
using GrpcTransaction = ArchestrA.Grpc.Contract.Transaction;
namespace AVEVA.Historian.Client.Grpc;
/// <summary>
/// Live probe for the M3 (historical / non-streamed original-value write) path over the 2023 R2
/// gRPC front door. On 2020 WCF this op group is architecturally blocked: the
/// <c>ITransactionServiceContract2.AddNonStreamValuesBegin2</c> relay returns
/// <c>UnknownClient (51)</c> because it requires a pre-existing storage-engine pipe session
/// (<c>STransactPipeClient2</c> → <c>aaStorageEngine.exe</c>) that no WCF op can establish — see
/// <c>docs/plans/revision-write-path.md</c> (the D2 finding).
///
/// The 2023 R2 decompile shows the native gRPC client driving the SAME op group over
/// <c>TransactionService.AddNonStreamValuesBegin/AddNonStreamValues/AddNonStreamValuesEnd</c> and
/// passing the HistoryService Open2 session GUID directly as <c>strHandle</c> — i.e. the gRPC
/// server is the gateway to the storage engine, so the client never touches the legacy pipe. This
/// probe tests whether the SDK's pure-managed handshake can reproduce that: it opens a
/// write-enabled session and calls <c>AddNonStreamValuesBegin</c>, surfacing whatever the server
/// returns. It writes NO data — if Begin succeeds it immediately calls <c>AddNonStreamValuesEnd</c>
/// with <c>bCommit=false</c> to discard the transaction.
/// </summary>
internal sealed class HistorianGrpcRevisionProbe
{
private readonly HistorianClientOptions _options;
public HistorianGrpcRevisionProbe(HistorianClientOptions options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
}
public Task<HistorianGrpcRevisionProbeResult> ProbeBeginAsync(CancellationToken cancellationToken)
=> Task.Run(() => ProbeBegin(cancellationToken), cancellationToken);
private HistorianGrpcRevisionProbeResult ProbeBegin(CancellationToken cancellationToken)
{
var result = new HistorianGrpcRevisionProbeResult();
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(
connection,
_options,
cancellationToken,
connectionMode: HistorianWcfAuthChainHelper.NativeIntegratedWriteEnabledConnectionMode);
result.OpenSucceeded = true;
result.ClientHandle = session.ClientHandle;
result.StorageSessionId = session.StorageSessionId;
var transactionClient = new GrpcTransaction.TransactionService.TransactionServiceClient(connection.Channel);
DateTime Deadline() => DateTime.UtcNow.Add(_options.RequestTimeout);
// Register the client with the Transaction service's session table (matches the
// cross-service GetV priming the WCF write path uses).
try
{
GrpcTransaction.GetTransactionInterfaceVersionResponse version = transactionClient.GetTransactionInterfaceVersion(
new GrpcTransaction.GetTransactionInterfaceVersionRequest(), connection.Metadata, Deadline(), cancellationToken);
result.TrxInterfaceVersionError = version.Error;
result.TrxInterfaceVersion = version.Version;
}
catch (Exception ex)
{
result.TrxInterfaceVersionException = $"{ex.GetType().Name}: {ex.Message}";
}
// The decompiled native client passes the Open2 storage-session GUID (string) as strHandle.
// Try that first (uppercase "D" form, as the other string-handle ops require), then a couple
// of fallbacks mirroring the WCF probe, so a wrong-format rejection is distinguishable from a
// genuine server-side block.
foreach ((string label, string handle) in new[]
{
("storageSessionId-upper", session.StringHandle),
("storageSessionId-lower", session.StorageSessionId.ToString("D")),
("clientHandle-as-string", session.ClientHandle.ToString()),
})
{
var attempt = new HistorianGrpcRevisionBeginAttempt { HandleLabel = label, HandleSent = handle };
try
{
GrpcTransaction.AddNonStreamValuesBeginResponse begin = transactionClient.AddNonStreamValuesBegin(
new GrpcTransaction.AddNonStreamValuesBeginRequest { StrHandle = handle },
connection.Metadata, Deadline(), cancellationToken);
attempt.Succeeded = begin.Status?.BSuccess ?? false;
attempt.TransactionId = begin.StrTransactionId;
byte[] error = begin.Status?.BtError?.ToByteArray() ?? [];
attempt.ErrorHex = error.Length == 0 ? null : Convert.ToHexString(error);
result.BeginAttempts.Add(attempt);
if (attempt.Succeeded && !string.IsNullOrEmpty(attempt.TransactionId))
{
result.BeginSucceeded = true;
result.BeginTransactionId = attempt.TransactionId;
// Discard immediately — bCommit=false writes nothing. This keeps the probe
// read-only against the live (production) server.
try
{
GrpcTransaction.AddNonStreamValuesEndResponse end = transactionClient.AddNonStreamValuesEnd(
new GrpcTransaction.AddNonStreamValuesEndRequest
{
StrHandle = handle,
StrTransactionId = attempt.TransactionId,
BCommit = false,
},
connection.Metadata, Deadline(), cancellationToken);
result.EndDiscardSucceeded = end.Status?.BSuccess ?? false;
byte[] endError = end.Status?.BtError?.ToByteArray() ?? [];
result.EndDiscardErrorHex = endError.Length == 0 ? null : Convert.ToHexString(endError);
}
catch (Exception ex)
{
result.EndDiscardException = $"{ex.GetType().Name}: {ex.Message}";
}
break;
}
}
catch (Exception ex)
{
attempt.Exception = $"{ex.GetType().Name}: {ex.Message}";
result.BeginAttempts.Add(attempt);
}
}
return result;
}
}
internal sealed class HistorianGrpcRevisionProbeResult
{
public bool OpenSucceeded { get; set; }
public uint ClientHandle { get; set; }
public Guid StorageSessionId { get; set; }
public uint? TrxInterfaceVersionError { get; set; }
public uint? TrxInterfaceVersion { get; set; }
public string? TrxInterfaceVersionException { get; set; }
public bool BeginSucceeded { get; set; }
public string? BeginTransactionId { get; set; }
public bool EndDiscardSucceeded { get; set; }
public string? EndDiscardErrorHex { get; set; }
public string? EndDiscardException { get; set; }
public List<HistorianGrpcRevisionBeginAttempt> BeginAttempts { get; } = new();
}
internal sealed class HistorianGrpcRevisionBeginAttempt
{
public string HandleLabel { get; set; } = "";
public string HandleSent { get; set; } = "";
public bool Succeeded { get; set; }
public string? TransactionId { get; set; }
public string? ErrorHex { get; set; }
public string? Exception { get; set; }
}
@@ -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");
@@ -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 <host> [port] [--tls] [--dnsid <name>] [--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";