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:
@@ -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 { }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user