Merge re/r1.10-rename-tags: RenameTagsAsync via History StartJob

# Conflicts:
#	docs/plans/hcal-capability-matrix.md
#	docs/plans/hcal-roadmap.md
#	src/AVEVA.Historian.Client/Wcf/HistorianWcfTagWriteOrchestrator.cs
#	tests/AVEVA.Historian.Client.Tests/HistorianClientIntegrationTests.cs
#	tools/AVEVA.Historian.NativeTraceHarness/Program.cs
This commit is contained in:
Joseph Doherty
2026-06-21 16:31:44 -04:00
10 changed files with 694 additions and 0 deletions
@@ -254,6 +254,32 @@ public sealed class HistorianClient : IAsyncDisposable
return new HistorianWcfTagWriteOrchestrator(_options).DeleteTagAsync(tagName, cancellationToken);
}
/// <summary>
/// Renames one tag, submitting an asynchronous rename job via the History <c>StartJob</c> (StJb)
/// operation. Convenience wrapper over <see cref="RenameTagsAsync"/> for a single (old,new) pair.
/// Requires the server's <c>AllowRenameTags</c> system parameter to be enabled.
/// </summary>
public Task<HistorianTagRenameResult> RenameTagAsync(string oldName, string newName, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(oldName);
ArgumentException.ThrowIfNullOrWhiteSpace(newName);
return RenameTagsAsync([(oldName, newName)], cancellationToken);
}
/// <summary>
/// Renames a batch of tags. Each pair is (current name, new name). Rename is an asynchronous
/// server-side job: the batch is submitted via the History <c>StartJob</c> (StJb) operation and
/// the returned <see cref="HistorianTagRenameResult"/> reports whether the server accepted/queued
/// the job (and its job id); the renames apply in the background. The server's
/// <c>AllowRenameTags</c> system parameter must be enabled or the server rejects the job. See
/// <c>docs/reverse-engineering/wcf-rename-tags.md</c>.
/// </summary>
public Task<HistorianTagRenameResult> RenameTagsAsync(IReadOnlyList<(string OldName, string NewName)> pairs, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(pairs);
return new HistorianWcfTagWriteOrchestrator(_options).RenameTagsAsync(pairs, cancellationToken);
}
public ValueTask DisposeAsync()
{
return ValueTask.CompletedTask;
@@ -0,0 +1,29 @@
namespace AVEVA.Historian.Client.Models;
/// <summary>
/// Result of <see cref="HistorianClient.RenameTagsAsync"/>. Tag rename on the Historian is an
/// asynchronous server-side <em>job</em>: the client submits the rename batch via the History
/// <c>StartJob</c> (StJb) operation and the server returns a job id, then applies the renames in the
/// background (the native client polls <c>GetJobStatus</c>/<c>GtJb</c> until the job reports done).
///
/// <para><see cref="Accepted"/> reflects whether the server accepted and queued the job. The renames
/// themselves complete asynchronously (observed: well under a couple of seconds for a small batch on
/// the local server). The server gate <c>AllowRenameTags</c> must be enabled, or the native client
/// library rejects the call before it reaches the wire (error 132 OperationNotEnabled).</para>
/// </summary>
public sealed record HistorianTagRenameResult
{
/// <summary>True when the server accepted and queued the rename job (StartJob returned success
/// with an empty error buffer).</summary>
public required bool Accepted { get; init; }
/// <summary>The server-assigned job id for the submitted rename batch (the <c>strJobid</c>
/// returned by StartJob). <see cref="Guid.Empty"/> if the server returned no parseable id.</summary>
public Guid JobId { get; init; }
/// <summary>Number of (old,new) name pairs submitted in the batch.</summary>
public int PairCount { get; init; }
/// <summary>Server error text when <see cref="Accepted"/> is false; otherwise null.</summary>
public string? Error { get; init; }
}
@@ -0,0 +1,66 @@
using System.Buffers.Binary;
using System.Text;
namespace AVEVA.Historian.Client.Wcf;
/// <summary>
/// Serializes the History <c>StartJob</c> (StJb) request buffer for a tag-rename job (HCAL R1.10).
///
/// Tag rename has no dedicated WCF operation — the native client packs a batch of (old,new) name
/// pairs into the generic <c>StartJob</c> job buffer and the server returns a job id (the rename then
/// completes asynchronously; see <see cref="Models.HistorianTagRenameResult"/>).
///
/// Wire layout (decoded from an instrumented native <c>RenameTags</c> capture, see
/// <c>docs/reverse-engineering/wcf-rename-tags.md</c>):
/// <code>
/// byte[7] reserved/job-descriptor prefix (all zero in every capture)
/// uint32 pairCount
/// repeated pairCount times:
/// uint32 oldNameCharCount + UTF-16LE oldName (no terminator)
/// uint32 newNameCharCount + UTF-16LE newName (no terminator)
/// </code>
/// Char counts are UTF-16 code-unit counts (byte length / 2). The pair order is (old, new) — the tag
/// being renamed first, its new name second.
/// </summary>
internal static class HistorianTagRenameProtocol
{
/// <summary>
/// Captured fixed prefix that leads the rename job buffer (7 zero bytes in every observed
/// capture). Treated as an opaque job-descriptor constant rather than guessing its sub-fields.
/// </summary>
private static readonly byte[] JobBufferPrefix = new byte[7];
public static byte[] SerializeRenameJob(IReadOnlyList<(string OldName, string NewName)> pairs)
{
ArgumentNullException.ThrowIfNull(pairs);
if (pairs.Count == 0)
{
throw new ArgumentException("At least one (old,new) name pair is required.", nameof(pairs));
}
using MemoryStream ms = new();
ms.Write(JobBufferPrefix, 0, JobBufferPrefix.Length);
Span<byte> u32 = stackalloc byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(u32, checked((uint)pairs.Count));
ms.Write(u32);
foreach ((string oldName, string newName) in pairs)
{
ArgumentException.ThrowIfNullOrWhiteSpace(oldName, nameof(pairs));
ArgumentException.ThrowIfNullOrWhiteSpace(newName, nameof(pairs));
WriteCountedUtf16(ms, oldName, u32);
WriteCountedUtf16(ms, newName, u32);
}
return ms.ToArray();
}
private static void WriteCountedUtf16(MemoryStream ms, string value, Span<byte> u32)
{
byte[] bytes = Encoding.Unicode.GetBytes(value);
BinaryPrimitives.WriteUInt32LittleEndian(u32, (uint)value.Length);
ms.Write(u32);
ms.Write(bytes, 0, bytes.Length);
}
}
@@ -417,4 +417,87 @@ internal sealed class HistorianWcfTagWriteOrchestrator
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 { }
}
public Task<HistorianTagRenameResult> RenameTagsAsync(
IReadOnlyList<(string OldName, string NewName)> pairs, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(pairs);
if (pairs.Count == 0)
{
throw new ArgumentException("At least one (old,new) name pair is required.", nameof(pairs));
}
foreach ((string oldName, string newName) in pairs)
{
ArgumentException.ThrowIfNullOrWhiteSpace(oldName, nameof(pairs));
ArgumentException.ThrowIfNullOrWhiteSpace(newName, nameof(pairs));
}
return Task.Run(() => RenameTags(pairs), cancellationToken);
}
private HistorianTagRenameResult RenameTags(IReadOnlyList<(string OldName, string NewName)> pairs)
{
Guid contextKey = Guid.NewGuid();
var (histBinding, histEndpoint, _, _) = HistorianWcfBindingFactory.CreateBindingPair(_options);
Binding auxBinding = HistorianWcfBindingFactory.CreateAuxiliaryBinding(_options);
EndpointAddress statusEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Status);
EndpointAddress transactionEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Transaction);
EndpointAddress retrievalEndpoint = _options.Transport == HistorianTransport.LocalPipe
? HistorianWcfBindingFactory.CreatePipeEndpointAddress(_options.Host, HistorianWcfServiceNames.Retrieval)
: HistorianWcfBindingFactory.CreateEndpointAddress(_options.Host, _options.Port, HistorianWcfServiceNames.Retrieval);
HistorianTagRenameResult result = new() { Accepted = false, PairCount = pairs.Count };
HistorianWcfAuthChainHelper.OpenAuthenticatedConnection(
_options, histBinding, histEndpoint, contextKey, CancellationToken.None,
connectionMode: HistorianWcfAuthChainHelper.NativeIntegratedWriteEnabledConnectionMode,
additionalSetup: (historyChannel, context) =>
{
RunWritePriming(historyChannel, context, auxBinding, statusEndpoint, transactionEndpoint, retrievalEndpoint);
result = SendStartJobRename(historyChannel, context, pairs);
});
return result;
}
/// <summary>
/// Submits the rename batch via the History <c>StartJob</c> (StJb) op with the uppercase
/// storage-session GUID handle. The server queues a job and returns its id; the renames apply
/// asynchronously. Requires the server gate <c>AllowRenameTags</c> to be enabled (otherwise the
/// native path would reject with err 132 before the wire — here the managed client has no such
/// pre-check, so a disabled gate surfaces as StartJob returning false).
/// </summary>
private static HistorianTagRenameResult SendStartJobRename(
IHistoryServiceContract2 historyChannel,
HistorianWcfAuthChainHelper.OpenConnectionContext context,
IReadOnlyList<(string OldName, string NewName)> pairs)
{
string handle = context.StorageSessionId.ToString("D").ToUpperInvariant();
byte[] jobBuffer = HistorianTagRenameProtocol.SerializeRenameJob(pairs);
DumpRenameJobIfRequested(jobBuffer);
bool ok = historyChannel.StartJob(handle, jobBuffer, out string jobId, out byte[] errorBuffer);
WriteDiag("StJb", $"Returned={ok} JobId={jobId} JobBufferLen={jobBuffer.Length} ErrLen={errorBuffer?.Length ?? -1} ErrHex={(errorBuffer is null ? "<null>" : Convert.ToHexString(errorBuffer))}");
Guid parsedJobId = Guid.Empty;
if (!string.IsNullOrWhiteSpace(jobId))
{
Guid.TryParse(jobId.Trim().Trim('$', '{', '}'), out parsedJobId);
}
return new HistorianTagRenameResult
{
Accepted = ok,
JobId = parsedJobId,
PairCount = pairs.Count,
Error = ok ? null : "Server rejected the rename job (StartJob returned false). Check that the 'AllowRenameTags' system parameter is enabled.",
};
}
/// <summary>Env-gated dump of the clean rename job buffer (base64) for golden-fixture capture,
/// mirroring the <c>AVEVA_HISTORIAN_SQL_DUMP</c> hook — avoids hand-stitching MDAS chunk markers
/// out of a raw instrument capture.</summary>
private static void DumpRenameJobIfRequested(byte[] jobBuffer)
{
string? path = Environment.GetEnvironmentVariable("AVEVA_HISTORIAN_RENAME_DUMP");
if (string.IsNullOrWhiteSpace(path)) return;
try { File.AppendAllText(path, Convert.ToBase64String(jobBuffer) + Environment.NewLine); } catch { }
}
}