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:
Joseph Doherty
2026-05-05 02:51:26 -04:00
parent b5e5f5485b
commit b40e6948e2
4 changed files with 375 additions and 0 deletions
+45
View File
@@ -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.
}
}