R1.10 RenameTagsAsync: async tag rename via History StartJob (StJb)

Tag rename has no dedicated WCF op — the (old,new) name batch rides the
generic History StartJob (StJb) job buffer; the server returns a job id and
applies renames asynchronously. Handle is the uppercase storage-session GUID,
Open2 in write mode; reuses the write orchestrator's open+priming chain.

jobBuffer layout (decoded + server-validated): byte[7] zero prefix + uint32
pairCount + per pair (uint32 oldCharCount + UTF-16 oldName + uint32
newCharCount + UTF-16 newName), order (old,new). The raw instrument capture
mangles the final byte with MDAS chunk markers (the R1.1 lesson), so the golden
fixture pins the CLEAN byte[] the SDK handed the channel (dumped via
AVEVA_HISTORIAN_RENAME_DUMP) — the exact buffer the live server accepted and
renamed with.

Gated server-side by the AllowRenameTags system parameter (default 0): when
disabled the native client rejects pre-wire (err 132); the managed SDK surfaces
it as StartJob=false -> Accepted=false. Enabling needs a Historian config
reload, not just a storage-engine restart.

Shipped: HistorianClient.RenameTagAsync/RenameTagsAsync -> HistorianTagRenameResult;
HistorianTagRenameProtocol; orchestrator RenameTags/SendStartJobRename; golden
WcfTagRenameProtocolTests (4, pins server-accepted buffer); gated live test
RenameTagsAsync_AgainstLocalHistorian_RenamesSandboxTag (passed end-to-end).
Native-harness `rename` scenario + Capture-RenameTags.ps1 + decode-rename-capture.py.
Doc: docs/reverse-engineering/wcf-rename-tags.md. 213 tests green.

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:
Joseph Doherty
2026-06-21 01:18:41 -04:00
parent 362fcb0ef4
commit bc353df8c4
12 changed files with 794 additions and 3 deletions
@@ -149,6 +149,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);
}
}
@@ -41,6 +41,22 @@ internal sealed class HistorianWcfTagWriteOrchestrator
return Task.Run(() => DeleteTag(tagName), cancellationToken);
}
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 bool EnsureTag(HistorianTagDefinition definition)
{
Guid contextKey = Guid.NewGuid();
@@ -86,6 +102,73 @@ internal sealed class HistorianWcfTagWriteOrchestrator
return result;
}
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 { }
}
private static bool SendEnsureTags2(
IHistoryServiceContract2 historyChannel,
HistorianWcfAuthChainHelper.OpenConnectionContext context,