D2 (new path): SDK-direct WCF revision orchestrator + probe
Implemented HistorianWcfRevisionOrchestrator that talks WCF directly to /Trx, bypassing the native wrapper entirely. Probes AddNonStreamValuesBegin2 against the live local Historian and surfaces what the server returns. Internal-only API; no public surface added — the path isn't viable yet. Findings (live test against localhost): - ✅ The wire path is reachable. After moving from V1 (uint handle, no errorBuffer) to V2 (string handle GUID, out errorBuffer), the server recognizes the call (no ContractFilter mismatch, no exception). - ✅ Server processes the call and returns a structured 5-byte error buffer: 04 33 00 00 00 = type 4 (CustomError) + code 51 (UnknownClient). - ❌ Tried four handle formats (contextKey upper/lower, storageSessionId upper, ClientHandle as decimal string) — all return the same UnknownClient. - ❌ Adding the full priming chain (Stat.GetV ×2, Stat.GETHI ×2, UpdC3, 6× Stat.GetSystemParameter, AllowRenameTags, Trx.GetV, Stat.GetV, Retr.GetV) — same result. ITransactionServiceContract2 has no Validate/Register/Open op of its own. The client-with-Trx registration must happen via some cross- service side effect we haven't isolated. Important takeaway: the wire-format mismatch is solved (contract method names + parameter shapes match what the server expects). The remaining gap is a single missing initialization step. Documented in docs/plans/revision-write-path.md as concrete next-session steps. 178/178 tests pass (one new probe test added). Probe is gated on HISTORIAN_HOST=localhost. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// V2 surface — discovered by inspecting CHistoryConnectionWCF.AddNonStreamValuesBegin's
|
||||
/// IL (token 0x06004051), which calls
|
||||
/// <c>ITransactionServiceContract2::AddNonStreamValuesBegin2(string, ref string, ref byte[])</c>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <remarks>
|
||||
/// Drives the AddNonStreamValuesBegin / AddNonStreamValues / AddNonStreamValuesEnd
|
||||
/// WCF op group on the <c>/Trx</c> service end-to-end. The native AVEVA wrapper's
|
||||
/// equivalent surface (<c>HistorianAccess.AddRevisionValues*</c>) is gated by the
|
||||
/// C++ <c>HistorianClient</c>'s per-connection cache and rejects all writes from a
|
||||
/// managed client with err 129 <c>TagNotFoundInCache</c>. 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.
|
||||
/// </remarks>
|
||||
internal sealed class HistorianWcfRevisionOrchestrator
|
||||
{
|
||||
private readonly HistorianClientOptions _options;
|
||||
|
||||
public HistorianWcfRevisionOrchestrator(HistorianClientOptions options)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
public Task<HistorianRevisionProbeResult> 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<ITransactionServiceContract2> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<IStatusServiceContract2> statusFactory = new(auxBinding, statusEndpoint);
|
||||
IStatusServiceContract2 statusChannel = statusFactory.CreateChannel();
|
||||
ChannelFactory<ITransactionServiceContract> transactionFactory = new(auxBinding, transactionEndpoint);
|
||||
ITransactionServiceContract transactionChannel = transactionFactory.CreateChannel();
|
||||
ChannelFactory<IRetrievalServiceContract4> 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<HistorianRevisionBeginAttempt> 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; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <remarks>
|
||||
/// Probes the SDK-direct WCF revision-write path (D2 new path). Calls
|
||||
/// <c>AddNonStreamValuesBegin</c> through <see cref="HistorianWcfRevisionOrchestrator"/>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user