diff --git a/docs/plans/revision-write-path.md b/docs/plans/revision-write-path.md index f25e2e7..85de6e6 100644 --- a/docs/plans/revision-write-path.md +++ b/docs/plans/revision-write-path.md @@ -134,6 +134,51 @@ path is implementable. If it fails with a server-side cache error, try `RTag2` first. If it still fails, the path is genuinely blocked server-side. +### SDK-direct probe results (2026-05-05) + +`HistorianWcfRevisionOrchestrator` wires up the priming chain + a probe +of `ITransactionServiceContract2.AddNonStreamValuesBegin2(string handle, out string transactionId, out byte[] errorBuffer)`. +Live test against `localhost`: + +- ✅ `OpenSucceeded: True` — Hist auth chain + Open2 still work end-to-end +- ✅ Trx channel opens, `Trx.GetV` returns interface version 2 +- ✅ Wire path is recognized — server processes the call (no + `ActionNotSupportedException` after switching from the abbreviated + `AddNonS2B` to the default action name) +- ❌ Server returns structured error `04 33 00 00 00` = + type 4 (CustomError) + code 51 (`UnknownClient`) for all four handle + formats tried (contextKey GUID upper, storageSessionId upper, contextKey + lower, ClientHandle as string) +- ❌ Adding the full priming chain (Stat.GetV ×2, Stat.GETHI ×2, UpdC3, + 6× Stat.GetSystemParameter, AllowRenameTags, Trx.GetV, Stat.GetV, + Retr.GetV) doesn't change the result — Trx still rejects with + `UnknownClient` + +`ITransactionServiceContract2` exposes only `GetV`, `ForwardSnapshot*`, +and `AddNonStreamValues*`. There is no `ValidateClient`, `RegisterClient`, +or `Open` on Trx. So the client-with-Trx registration must happen via +some cross-service side effect we haven't identified. + +**Important takeaway:** the wire path works at the WCF protocol layer. +We're past the "is this even reachable" question. The remaining gap is +finding what populates Trx's session table — likely: + +1. `RTag2` on /Hist with a tag whose registration cascades to Trx +2. Some `IStorageServiceContract` op that we haven't tried +3. An aspect of the C++ HistorianClient initialization that doesn't + show up in the IL we've inspected (e.g., the + `aahClientCommon.CClientCommon` calls during InitializeProxy) + +A future session that wants to push further should: +1. Add `RTag2` for the sandbox tag and retry Begin2 — quick experiment +2. If that fails, try sending the IStorageServiceContract.AddT or + similar to "introduce" the tag to Trx +3. If that fails, do an IL walk of `aahClientCommon.CClientCommon` + methods called between Open2 and AddNonStreamValuesBegin in a + working native scenario (using a system tag the wrapper would + accept — or capturing actual on-wire bytes via the IL-rewrite + instrumentation if possible) + ## Decision Do **not** add public `WriteRevisionsAsync` / `BeginRevisionAsync` to diff --git a/src/AVEVA.Historian.Client/Wcf/Contracts/ITransactionServiceContract.cs b/src/AVEVA.Historian.Client/Wcf/Contracts/ITransactionServiceContract.cs index b7d35be..3b67f79 100644 --- a/src/AVEVA.Historian.Client/Wcf/Contracts/ITransactionServiceContract.cs +++ b/src/AVEVA.Historian.Client/Wcf/Contracts/ITransactionServiceContract.cs @@ -1,3 +1,4 @@ +using System.Runtime.InteropServices; using System.ServiceModel; namespace AVEVA.Historian.Client.Wcf.Contracts; @@ -26,3 +27,40 @@ internal interface ITransactionServiceContract [OperationContract] uint AddNonStreamValuesEnd(uint handle, string transactionId, bool commit); } + +/// +/// V2 surface — discovered by inspecting CHistoryConnectionWCF.AddNonStreamValuesBegin's +/// IL (token 0x06004051), which calls +/// ITransactionServiceContract2::AddNonStreamValuesBegin2(string, ref string, ref byte[]) +/// before falling back to V1. The V2 ops use the GUID-string handle pattern matching +/// other V2 ops on /Hist (EnsT2, AddS2, RTag2) plus an out-byte[] errorBuffer. +/// +[ServiceContract(Name = HistorianWcfServiceNames.Transaction, Namespace = HistorianWcfServiceNames.Namespace)] +internal interface ITransactionServiceContract2 +{ + [OperationContract(Name = "GetV")] + uint GetInterfaceVersion(out uint version); + + [OperationContract] + [return: MarshalAs(UnmanagedType.U1)] + bool AddNonStreamValuesBegin2( + string handle, + out string transactionId, + out byte[] errorBuffer); + + [OperationContract] + [return: MarshalAs(UnmanagedType.U1)] + bool AddNonStreamValues2( + string handle, + string transactionId, + [MessageParameter(Name = "pBuf")] byte[] buffer, + out byte[] errorBuffer); + + [OperationContract] + [return: MarshalAs(UnmanagedType.U1)] + bool AddNonStreamValuesEnd2( + string handle, + string transactionId, + [MarshalAs(UnmanagedType.U1)] bool commit, + out byte[] errorBuffer); +} diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianWcfRevisionOrchestrator.cs b/src/AVEVA.Historian.Client/Wcf/HistorianWcfRevisionOrchestrator.cs new file mode 100644 index 0000000..48a7c40 --- /dev/null +++ b/src/AVEVA.Historian.Client/Wcf/HistorianWcfRevisionOrchestrator.cs @@ -0,0 +1,232 @@ +using System.Buffers.Binary; +using System.ServiceModel; +using System.ServiceModel.Channels; +using AVEVA.Historian.Client.Wcf.Contracts; + +namespace AVEVA.Historian.Client.Wcf; + +/// +/// Drives the AddNonStreamValuesBegin / AddNonStreamValues / AddNonStreamValuesEnd +/// WCF op group on the /Trx service end-to-end. The native AVEVA wrapper's +/// equivalent surface (HistorianAccess.AddRevisionValues*) is gated by the +/// C++ HistorianClient's per-connection cache and rejects all writes from a +/// managed client with err 129 TagNotFoundInCache. This SDK orchestrator +/// bypasses the wrapper entirely — talks WCF directly — to test whether the SERVER +/// gates on the same condition. +/// +/// Live behavior is unverified. The first iteration is probe-only: open the auth +/// chain, drive the standard write priming, call AddNonStreamValuesBegin and +/// surface whatever the server returns. +/// +internal sealed class HistorianWcfRevisionOrchestrator +{ + private readonly HistorianClientOptions _options; + + public HistorianWcfRevisionOrchestrator(HistorianClientOptions options) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + } + + public Task ProbeBeginAsync(CancellationToken cancellationToken) + => Task.Run(() => ProbeBegin(cancellationToken), cancellationToken); + + private HistorianRevisionProbeResult ProbeBegin(CancellationToken cancellationToken) + { + Guid contextKey = Guid.NewGuid(); + var (histBinding, histEndpoint, _, _) = HistorianWcfBindingFactory.CreateBindingPair(_options); + Binding auxBinding = HistorianWcfBindingFactory.CreateAuxiliaryBinding(_options); + EndpointAddress transactionEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Transaction); + + HistorianRevisionProbeResult result = new(); + + HistorianWcfAuthChainHelper.OpenAuthenticatedConnection( + _options, histBinding, histEndpoint, contextKey, cancellationToken, + connectionMode: HistorianWcfAuthChainHelper.NativeIntegratedWriteEnabledConnectionMode, + additionalSetup: (historyChannel, context) => + { + result.OpenSucceeded = true; + result.ClientHandle = context.ClientHandle; + result.StorageSessionId = context.StorageSessionId; + + // Run the same priming chain that EnsT2/DelT use — without it, the Trx + // service rejects calls with err 51 UnknownClient because the client + // hasn't registered itself across the auxiliary services. + EndpointAddress statusEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Status); + EndpointAddress retrievalEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Retrieval); + RunPrimingChain(historyChannel, context, auxBinding, statusEndpoint, transactionEndpoint, retrievalEndpoint); + + ChannelFactory trxFactory = new(auxBinding, transactionEndpoint); + HistorianWcfClientCredentialsHelper.Configure(trxFactory, _options); + ITransactionServiceContract2 trxChannel = trxFactory.CreateChannel(); + ICommunicationObject trxCo = (ICommunicationObject)trxChannel; + try + { + // Get interface version first to register the client in the Trx service's + // session table (matches the cross-service GetV priming pattern used by + // RunWritePriming for EnsT2/DelT). + try + { + uint trxRc = trxChannel.GetInterfaceVersion(out uint trxVersion); + result.TrxInterfaceVersionReturnCode = trxRc; + result.TrxInterfaceVersion = trxVersion; + } + catch (Exception ex) + { + result.TrxInterfaceVersionException = $"{ex.GetType().Name}: {ex.Message}"; + } + + // Probe V2 AddNonStreamValuesBegin2. Try BOTH possible handle formats — + // the server returns 0433000000 (UnknownClient = 51) when the wrong one + // is sent. Capture which one (if any) is recognized. + foreach ((string label, string handle) in new[] + { + ("contextKey", contextKey.ToString("D").ToUpperInvariant()), + ("storageSessionId", context.StorageSessionId.ToString("D").ToUpperInvariant()), + ("contextKey-lower", contextKey.ToString("D")), + ("clientHandle-as-string", context.ClientHandle.ToString()), + }) + { + try + { + string? transactionId = null; + byte[]? errorBuffer = null; + bool ok = trxChannel.AddNonStreamValuesBegin2(handle, out transactionId, out errorBuffer); + result.BeginAttempts.Add(new HistorianRevisionBeginAttempt + { + HandleLabel = label, + HandleSent = handle, + Succeeded = ok, + TransactionId = transactionId, + ErrorHex = errorBuffer is null || errorBuffer.Length == 0 ? null : Convert.ToHexString(errorBuffer), + }); + if (ok && !string.IsNullOrEmpty(transactionId)) + { + result.BeginSucceeded = true; + result.BeginTransactionId = transactionId; + break; + } + } + catch (Exception ex) + { + result.BeginAttempts.Add(new HistorianRevisionBeginAttempt + { + HandleLabel = label, + HandleSent = handle, + Exception = $"{ex.GetType().Name}: {ex.Message}", + }); + } + } + } + finally + { + try { if (trxCo.State == CommunicationState.Faulted) trxCo.Abort(); else trxCo.Close(); } catch { try { trxCo.Abort(); } catch { } } + try { if (trxFactory.State == CommunicationState.Faulted) trxFactory.Abort(); else trxFactory.Close(); } catch { try { trxFactory.Abort(); } catch { } } + } + }); + + return result; + } + + /// + /// Mirrors HistorianWcfTagWriteOrchestrator.RunWritePriming. The cross-service GetV + /// calls + UpdC3 register the client in each aux service's session table so that + /// subsequent ops (like AddNonStreamValuesBegin2 on /Trx) recognize the handle. + /// + private static void RunPrimingChain( + IHistoryServiceContract2 historyChannel, + HistorianWcfAuthChainHelper.OpenConnectionContext context, + Binding auxBinding, + EndpointAddress statusEndpoint, + EndpointAddress transactionEndpoint, + EndpointAddress retrievalEndpoint) + { + string handle = context.StorageSessionId.ToString("D").ToUpperInvariant(); + + ChannelFactory statusFactory = new(auxBinding, statusEndpoint); + IStatusServiceContract2 statusChannel = statusFactory.CreateChannel(); + ChannelFactory transactionFactory = new(auxBinding, transactionEndpoint); + ITransactionServiceContract transactionChannel = transactionFactory.CreateChannel(); + ChannelFactory retrievalFactory = new(auxBinding, retrievalEndpoint); + IRetrievalServiceContract4 retrievalChannel = retrievalFactory.CreateChannel(); + + try + { + TryRun(() => statusChannel.GetInterfaceVersion(out _)); + TryRun(() => statusChannel.GetInterfaceVersion(out _)); + byte[] historianVersionRequest = BuildGetHistorianInfoRequest("HistorianVersion"); + TryRun(() => statusChannel.GetHistorianInfo(handle, historianVersionRequest, out _, out _)); + TryRun(() => statusChannel.GetHistorianInfo(handle, historianVersionRequest, out _, out _)); + + byte[] clientStatus = BuildUpdC3ClientStatusBlob(); + TryRun(() => historyChannel.UpdateClientStatus3(handle, (uint)clientStatus.Length, ref clientStatus, out _, out _, out _, out _)); + + foreach (string parameterName in new[] { "AllowOriginals", "HistorianPartner", "HistorianVersion", "MaxCyclicStorageTimeout", "RealTimeWindow", "FutureTimeThreshold", "AllowRenameTags" }) + { + TryRun(() => statusChannel.GetSystemParameter(context.ClientHandle, parameterName, out _, out _, out _)); + } + TryRun(() => transactionChannel.GetInterfaceVersion(out _)); + TryRun(() => statusChannel.GetInterfaceVersion(out _)); + TryRun(() => retrievalChannel.GetInterfaceVersion(out _)); + } + finally + { + CloseSafely(retrievalChannel, retrievalFactory); + CloseSafely(transactionChannel, transactionFactory); + CloseSafely(statusChannel, statusFactory); + } + } + + private static byte[] BuildUpdC3ClientStatusBlob() + { + byte[] blob = new byte[81]; + blob[0] = 0x02; + blob[1] = 0x01; + blob[77] = 0x1E; + return blob; + } + + private static byte[] BuildGetHistorianInfoRequest(string parameterName) + { + byte[] nameBytes = System.Text.Encoding.Unicode.GetBytes(parameterName); + int payloadLength = nameBytes.Length > 0 ? nameBytes.Length - 1 : 0; + byte[] buffer = new byte[8 + payloadLength]; + BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(0, 2), 0x6753); + BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(2, 2), 0x0002); + BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(4, 4), (uint)parameterName.Length); + Buffer.BlockCopy(nameBytes, 0, buffer, 8, payloadLength); + return buffer; + } + + private static void TryRun(Action a) { try { a(); } catch { } } + + private static void CloseSafely(object channel, ICommunicationObject factory) + { + try { if (channel is ICommunicationObject co) { if (co.State == CommunicationState.Faulted) co.Abort(); else co.Close(); } } catch { } + try { if (factory.State == CommunicationState.Faulted) factory.Abort(); else factory.Close(); } catch { } + } +} + +internal sealed class HistorianRevisionProbeResult +{ + public bool OpenSucceeded { get; set; } + public uint ClientHandle { get; set; } + public Guid StorageSessionId { get; set; } + public uint? TrxInterfaceVersionReturnCode { get; set; } + public uint? TrxInterfaceVersion { get; set; } + public string? TrxInterfaceVersionException { get; set; } + public string? BeginTransactionId { get; set; } + public bool BeginSucceeded { get; set; } + public string? BeginErrorHex { get; set; } + public string? BeginException { get; set; } + public List BeginAttempts { get; } = new(); +} + +internal sealed class HistorianRevisionBeginAttempt +{ + 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/HistorianWcfRevisionProbeTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianWcfRevisionProbeTests.cs new file mode 100644 index 0000000..a10ac71 --- /dev/null +++ b/tests/AVEVA.Historian.Client.Tests/HistorianWcfRevisionProbeTests.cs @@ -0,0 +1,60 @@ +using System.Runtime.Versioning; +using AVEVA.Historian.Client; +using AVEVA.Historian.Client.Models; +using AVEVA.Historian.Client.Wcf; +using Xunit.Abstractions; + +namespace AVEVA.Historian.Client.Tests; + +/// +/// Probes the SDK-direct WCF revision-write path (D2 new path). Calls +/// AddNonStreamValuesBegin through +/// against the live local Historian and surfaces what the server returns. The +/// underlying native wrapper is gated client-side by err 129 TagNotFoundInCache; +/// this test bypasses the wrapper entirely and asks the SERVER directly. Gated on +/// HISTORIAN_HOST=localhost; skips otherwise. +/// +[SupportedOSPlatform("windows")] +public sealed class HistorianWcfRevisionProbeTests +{ + private readonly ITestOutputHelper _output; + + public HistorianWcfRevisionProbeTests(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + public async Task AddNonStreamValuesBegin_ProbeReturnsServerResult() + { + string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST"); + if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows()) + { + return; + } + + HistorianClientOptions options = new() + { + Host = host, + IntegratedSecurity = true, + Transport = HistorianTransport.LocalPipe, + }; + + HistorianWcfRevisionOrchestrator orchestrator = new(options); + HistorianRevisionProbeResult result = await orchestrator.ProbeBeginAsync(CancellationToken.None); + + _output.WriteLine($"OpenSucceeded: {result.OpenSucceeded}"); + _output.WriteLine($"ClientHandle: {result.ClientHandle}"); + _output.WriteLine($"StorageSessionId: {result.StorageSessionId}"); + _output.WriteLine($"TrxInterfaceVersion: {result.TrxInterfaceVersion} (rc={result.TrxInterfaceVersionReturnCode}) ex={result.TrxInterfaceVersionException}"); + _output.WriteLine($"BeginSucceeded: {result.BeginSucceeded}"); + _output.WriteLine($"BeginTransactionId: {result.BeginTransactionId}"); + foreach (HistorianRevisionBeginAttempt attempt in result.BeginAttempts) + { + _output.WriteLine($" attempt[{attempt.HandleLabel}] handle={attempt.HandleSent} ok={attempt.Succeeded} tx={attempt.TransactionId} err={attempt.ErrorHex} ex={attempt.Exception}"); + } + + Assert.True(result.OpenSucceeded, "Auth chain failed; revision probe never reached the Trx endpoint."); + // Don't assert BeginSucceeded — we're surfacing whatever the server says, not requiring success. + } +}