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
@@ -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; }
}