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:
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user