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
@@ -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]