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:
@@ -447,6 +447,75 @@ public sealed class HistorianClientIntegrationTests
|
||||
Assert.True(deleted, "DeleteTagAsync returned false against the live Historian.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenameTagsAsync_AgainstLocalHistorian_RenamesSandboxTag()
|
||||
{
|
||||
// Safety: localhost only, names must start with "RetestSdkWrite". Requires the server's
|
||||
// AllowRenameTags system parameter to be enabled (otherwise StartJob returns false). Gated
|
||||
// on HISTORIAN_RENAME_SANDBOX so it stays skipped unless explicitly enabled.
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
|
||||
string? sandbox = Environment.GetEnvironmentVariable("HISTORIAN_RENAME_SANDBOX");
|
||||
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(sandbox) || !sandbox.StartsWith("RetestSdkWrite", StringComparison.Ordinal))
|
||||
{
|
||||
return; // safety gate
|
||||
}
|
||||
|
||||
string src = sandbox + "Src";
|
||||
string dst = sandbox + "Dst";
|
||||
|
||||
HistorianClient client = new(new HistorianClientOptions
|
||||
{
|
||||
Host = host,
|
||||
IntegratedSecurity = true,
|
||||
Transport = HistorianTransport.LocalPipe
|
||||
});
|
||||
|
||||
// Fresh source tag.
|
||||
await client.EnsureTagAsync(new AVEVA.Historian.Client.Models.HistorianTagDefinition
|
||||
{
|
||||
TagName = src,
|
||||
Description = "SDK rename live test",
|
||||
EngineeringUnit = "test",
|
||||
DataType = AVEVA.Historian.Client.Models.HistorianDataType.Float,
|
||||
MinEU = 0.0,
|
||||
MaxEU = 100.0,
|
||||
}, CancellationToken.None);
|
||||
|
||||
try
|
||||
{
|
||||
AVEVA.Historian.Client.Models.HistorianTagRenameResult result =
|
||||
await client.RenameTagAsync(src, dst, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Accepted, "RenameTagsAsync was not accepted by the server (is AllowRenameTags enabled?).");
|
||||
Assert.NotEqual(Guid.Empty, result.JobId);
|
||||
Assert.Equal(1, result.PairCount);
|
||||
|
||||
// Rename completes asynchronously; poll the new name's metadata briefly.
|
||||
bool renamed = false;
|
||||
for (int i = 0; i < 10 && !renamed; i++)
|
||||
{
|
||||
await Task.Delay(500);
|
||||
try
|
||||
{
|
||||
var md = await client.GetTagMetadataAsync(dst, CancellationToken.None);
|
||||
renamed = md is not null && string.Equals(md.Name, dst, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
catch { /* not yet visible */ }
|
||||
}
|
||||
Assert.True(renamed, $"Renamed tag '{dst}' did not become visible after the job completed.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Clean up whichever name ended up in the DB.
|
||||
try { await client.DeleteTagAsync(dst, CancellationToken.None); } catch { }
|
||||
try { await client.DeleteTagAsync(src, CancellationToken.None); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
// Round-trip every live-verified analog data type + the non-default-range case. The
|
||||
// sandbox tag name is suffixed per case so the runs don't collide. Always cleans up.
|
||||
[Theory]
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using AVEVA.Historian.Client.Wcf;
|
||||
|
||||
namespace AVEVA.Historian.Client.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Golden-byte tests for the History <c>StartJob</c> (StJb) rename job buffer (HCAL R1.10).
|
||||
/// The reference buffer is the exact byte[] the SDK handed the WCF channel in the live
|
||||
/// <c>RenameTagsAsync_AgainstLocalHistorian_RenamesSandboxTag</c> run — the server accepted it and
|
||||
/// the tag was renamed, so it is server-validated, not hand-derived from a chunk-mangled capture.
|
||||
/// </summary>
|
||||
public sealed class WcfTagRenameProtocolTests
|
||||
{
|
||||
// Server-accepted clean jobBuffer for pairs [("RetestSdkWriteSdkRenameSrc","RetestSdkWriteSdkRenameDst")],
|
||||
// dumped via AVEVA_HISTORIAN_RENAME_DUMP during the live rename test.
|
||||
private const string ServerAcceptedJobBufferBase64 =
|
||||
"AAAAAAAAAAEAAAAaAAAAUgBlAHQAZQBzAHQAUwBkAGsAVwByAGkAdABlAFMAZABrAFIAZQBuAGEAbQBlAFMAcgBjABoAAABSAGUAdABlAHMAdABTAGQAawBXAHIAaQB0AGUAUwBkAGsAUgBlAG4AYQBtAGUARABzAHQA";
|
||||
|
||||
[Fact]
|
||||
public void SerializeRenameJob_MatchesServerAcceptedBuffer()
|
||||
{
|
||||
byte[] expected = Convert.FromBase64String(ServerAcceptedJobBufferBase64);
|
||||
byte[] actual = HistorianTagRenameProtocol.SerializeRenameJob(
|
||||
[("RetestSdkWriteSdkRenameSrc", "RetestSdkWriteSdkRenameDst")]);
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeRenameJob_SinglePair_HasExpectedLayout()
|
||||
{
|
||||
byte[] buf = HistorianTagRenameProtocol.SerializeRenameJob([("ReactorTempA", "ReactorTempB")]);
|
||||
|
||||
// 7-byte zero prefix + uint32 pairCount + (uint32 charCount + UTF-16)×2.
|
||||
Assert.Equal(7 + 4 + 4 + 24 + 4 + 24, buf.Length);
|
||||
for (int i = 0; i < 7; i++) Assert.Equal(0, buf[i]);
|
||||
Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(buf.AsSpan(7, 4)));
|
||||
Assert.Equal(12u, BinaryPrimitives.ReadUInt32LittleEndian(buf.AsSpan(11, 4)));
|
||||
Assert.Equal("ReactorTempA", Encoding.Unicode.GetString(buf.AsSpan(15, 24)));
|
||||
Assert.Equal(12u, BinaryPrimitives.ReadUInt32LittleEndian(buf.AsSpan(39, 4)));
|
||||
Assert.Equal("ReactorTempB", Encoding.Unicode.GetString(buf.AsSpan(43, 24)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeRenameJob_MultiplePairs_EncodesCountAndOrder()
|
||||
{
|
||||
byte[] buf = HistorianTagRenameProtocol.SerializeRenameJob(
|
||||
[("AaOld", "AaNew"), ("BbOld", "BbNew")]);
|
||||
|
||||
Assert.Equal(2u, BinaryPrimitives.ReadUInt32LittleEndian(buf.AsSpan(7, 4)));
|
||||
// First pair old name immediately follows the count + its length field.
|
||||
Assert.Equal(5u, BinaryPrimitives.ReadUInt32LittleEndian(buf.AsSpan(11, 4)));
|
||||
Assert.Equal("AaOld", Encoding.Unicode.GetString(buf.AsSpan(15, 10)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeRenameJob_EmptyBatch_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
HistorianTagRenameProtocol.SerializeRenameJob(Array.Empty<(string, string)>()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user