diff --git a/docs/plans/hcal-capability-matrix.md b/docs/plans/hcal-capability-matrix.md index 283ec1f..d6f8220 100644 --- a/docs/plans/hcal-capability-matrix.md +++ b/docs/plans/hcal-capability-matrix.md @@ -73,7 +73,7 @@ blob needs RE). | Create analog tag | `AddTag` | `History.EnsureTags` (EnsT2) | ✅ | DONE | Float/Double/Int2/Int4/UInt2/UInt4 + scaling | | Create string/discrete tag | `AddTag` | `History.EnsureTags` | ⬜ | GATED/BOUNDED | native AddTag rejects these types server-side; needs different metadata path | | Delete tag(s) | `DeleteTags` | `History.DeleteTags` | ✅ | DONE | | -| Rename tag(s) | `RenameTags` | (History op) | ⬜ | BOUNDED | `AllowRenameTags` param already probed | +| Rename tag(s) | `RenameTags` | `History.StartJob` (StJb) | ✅ | DONE | `RenameTagsAsync`/`RenameTagAsync`; async rename job via StJb; gated by `AllowRenameTags`. See `wcf-rename-tags.md` | | Add/Delete extended properties | `AddTagExtendedProperties`, `DeleteTagExtendedPropertiesByName` | `History.AddTagExtendedProperties` / `DeleteTagExtendedProperties` | ⬜ | BOUNDED | gRPC op + TEP serialize | | Add/Delete localized properties | `AddTagLocalizedProperties`, `DeleteTagLocalizedPropertiesByName` | `History.AddTagLocalizedProperties` / `DeleteTagLocalizedProperties` | ⬜ | BOUNDED | | diff --git a/docs/plans/hcal-roadmap.md b/docs/plans/hcal-roadmap.md index d777c22..6565a7d 100644 --- a/docs/plans/hcal-roadmap.md +++ b/docs/plans/hcal-roadmap.md @@ -111,7 +111,7 @@ read/browse/status surface is Windows-free and the gRPC stack is the default pat ### 1c. Bounded config writes (S–M each) | ID | Capability | gRPC op | Payload | Notes | |---|---|---|---|---| -| R1.10 | `RenameTagsAsync` | History rename op | rename request buffer | `AllowRenameTags` already probed | +| ~~R1.10~~ | `RenameTagsAsync` | History `StartJob` (StJb) | ✅ **DONE (2026-06-21), live-verified.** Rename has no dedicated op — the batch of (old,new) name pairs rides the generic **`StartJob`/StJb** job buffer (string handle = uppercase storage GUID, Open2 write mode); server returns a job id and applies renames async. jobBuffer = `byte[7] zero prefix + uint32 pairCount + per pair(uint32 oldCharCount+UTF-16 + uint32 newCharCount+UTF-16)`. Gated server-side by **`AllowRenameTags`** (default 0; needs a Historian config reload to enable — storage-engine-only restart is insufficient). Shipped: `RenameTagAsync`/`RenameTagsAsync` → `HistorianTagRenameResult`, `HistorianTagRenameProtocol`, golden `WcfTagRenameProtocolTests` (pins server-accepted buffer), gated live test. See `docs/reverse-engineering/wcf-rename-tags.md`. | | R1.11 | Extended-property **write** | `History.AddTagExtendedProperties` (+ groups) / `DeleteTagExtendedProperties` | TEP serialize | mirror analog CTagMetadata discipline | | R1.12 | Localized-property **write** | `History.AddTagLocalizedProperties` / `DeleteTagLocalizedProperties` | localized serialize | | | R1.13 | Non-analog tag create (string/discrete) | `History.EnsureTags` | distinct CTagMetadata variant | ⚠ native AddTag rejected some types — confirm server path first; may be GATED | diff --git a/docs/reverse-engineering/wcf-rename-tags.md b/docs/reverse-engineering/wcf-rename-tags.md new file mode 100644 index 0000000..4920b02 --- /dev/null +++ b/docs/reverse-engineering/wcf-rename-tags.md @@ -0,0 +1,82 @@ +# Tag rename over 2020 WCF — StartJob (StJb) rename job (HCAL R1.10) + +**Status: ✅ DONE + live-verified (2026-06-21).** `HistorianClient.RenameTagsAsync` / +`RenameTagAsync` renames tags by submitting an asynchronous rename **job** to the Historian. Decoded +from an instrumented native `RenameTags` capture and verified end-to-end from the pure-managed .NET 10 +client against the local 2020 Historian (sandbox tag created → renamed → new name visible → cleaned up). + +## The op — rename rides the generic job framework + +There is **no dedicated rename WCF operation**. The native `RenameTags(Tuple[] pairs, +ref HistorianTagRenameStatus, out error)` packs the batch into the generic History **`StartJob`** +(`StJb`) buffer; the server returns a job id and applies the renames in the background. The native +client then polls `GetJobStatus` (`GtJb`) until the job reports done. + +``` +bool StartJob(string handle, byte[] jobBuffer, out string jobId, out byte[] errorBuffer) // StJb +bool GetJobStatus(string handle, string jobId, out byte[] jobStatus, out byte[] errorBuffer) // GtJb +``` + +Both already existed in `IHistoryServiceContract2`. `StartJob` takes a **string handle** = the Open2 +storage-session GUID formatted `storageSessionId.ToString("D").ToUpperInvariant()` (the same uppercase +handle used by the other string-handle ops). The connection must be **write-enabled** (Open2 mode +`0x401`); the SDK reuses the write orchestrator's open + priming chain. + +## The rename jobBuffer (decoded + server-validated) + +``` +byte[7] reserved / job-descriptor prefix (all zero in every capture) +uint32 pairCount +repeated pairCount times: + uint32 oldNameCharCount + UTF-16LE oldName (the tag being renamed) + uint32 newNameCharCount + UTF-16LE newName (its new name) +``` + +Char counts are UTF-16 code-unit counts. Pair order is **(old, new)**. ⚠️ A **raw** instrument +capture mangles the buffer's final byte with MDAS chunk markers (`9E`/`9F`) — the same hazard noted +for R1.1. So the golden fixture is the **clean** byte[] the SDK hands the WCF channel, dumped via the +`AVEVA_HISTORIAN_RENAME_DUMP` env hook on `HistorianWcfTagWriteOrchestrator`. That exact buffer was +accepted by the live server and the tag was renamed, so it is server-validated, not hand-stitched. + +## Server gate — `AllowRenameTags` + +Rename is gated by the **`AllowRenameTags`** system parameter (default **0/disabled**). When disabled, +the **native** client library rejects the call *before the wire* (`error 132 OperationNotEnabled`, +component `aahClientCommon::CClientCommon::RenameTags`); the managed SDK has no such pre-check, so a +disabled gate surfaces as `StartJob` returning false (reported as `Accepted = false`). + +To enable for testing: `EXEC Runtime.dbo.aaSystemParameterUpdate @name='AllowRenameTags', @value=1` +**and reload the Historian config** — the running services cache system parameters, so the value only +takes effect after the Historian reloads (a Historian restart; a storage-engine-only restart is **not** +enough — the value is served from the `InSQLConfiguration` cache). Restore to `0` when done. + +## Async completion + +`RenameTagsAsync` submits the job and returns `HistorianTagRenameResult { Accepted, JobId, PairCount, +Error }`. The renames apply asynchronously server-side (observed: the native `GetTagRenameStatus` went +`Pending=true` → `false` within ~1.5 s for a single rename on the local box). The SDK does **not** poll +`GtJb` for completion: only the *pending* `jobstatus` buffer (6 zero bytes) was captured — the +done-state encoding was not, so polling is intentionally left out rather than guessing it. The gated +live test confirms completion by polling the new name's metadata after submission. + +## Shipped surface + +- `HistorianClient.RenameTagAsync(old, new)` / `RenameTagsAsync(IReadOnlyList<(string,string)>)` + → `HistorianTagRenameResult`. +- `HistorianTagRenameProtocol.SerializeRenameJob` (the jobBuffer serializer); + `HistorianWcfTagWriteOrchestrator.RenameTags`/`SendStartJobRename` (open → write-priming → StJb). +- Golden `WcfTagRenameProtocolTests` (pins the server-accepted buffer + layout); gated live test + `RenameTagsAsync_AgainstLocalHistorian_RenamesSandboxTag` (needs `HISTORIAN_HOST=localhost`, + `HISTORIAN_RENAME_SANDBOX=RetestSdkWrite…`, and `AllowRenameTags` enabled). + +## Capture / decode tooling + +`scripts/Capture-RenameTags.ps1` (native-harness `rename` scenario + instrument-wcf-{write,read}message; +sandbox-guarded create→rename→cleanup) and `scripts/decode-rename-capture.py`. + +## Scope notes + +- String-valued, original tag renames only. The multi-pair batch framing is captured (count-prefixed) + and unit-tested; the live test exercises a single pair. +- `RenameSourceTags` (replication/source-server rename) is **not** shipped — different op signature + (adds a source-server string), not captured. diff --git a/scripts/Capture-RenameTags.ps1 b/scripts/Capture-RenameTags.ps1 new file mode 100644 index 0000000..d64f9ca --- /dev/null +++ b/scripts/Capture-RenameTags.ps1 @@ -0,0 +1,133 @@ +<# +.SYNOPSIS + Captures the native AVEVA client's RenameTags wire traffic (HCAL roadmap R1.10) so the + StJb (StartJob) rename jobBuffer + GtJb (GetJobStatus) response can be decoded instead of guessed. + +.DESCRIPTION + Drives the .NET-Framework NativeTraceHarness's `rename` scenario against the live Historian + with an IL-rewritten copy of aahClientManaged.dll whose ClientMessageEncoder.WriteMessage AND + ReadMessage are instrumented to log every MDAS body. The harness opens a WRITE-enabled + connection, creates a sandbox source tag (RetestSdkWrite...), calls + HistorianAccess.RenameTags([(from,to)], ref status, out err), and polls GetTagRenameStatus. + + Rename maps to the generic job framework: StJb(handle, jobBuffer) -> jobId, then + GtJb(handle, jobId) -> jobStatus. Decode with scripts/decode-rename-capture.py: find the + WCF.WriteMessage.Body whose op is StJb -> its jobBuffer carries the (old,new) name pairs; the + paired ReadMessage carries the jobId; the GtJb request/response carry the status. + + SAFETY: sandbox-guarded — both names MUST start with 'RetestSdkWrite'. The default run renames + RetestSdkWriteRenameSrc -> RetestSdkWriteRenameDst and (unless -SkipCleanup) deletes the + destination tag afterward via a second harness pass. + +.NOTES + Artifacts are diagnostic and gitignored. Sanitize before copying anything into docs/ -- + never commit raw capture NDJSON, credentials, hostnames, or customer tag names. +#> +[CmdletBinding()] +param( + [string]$ServerName = "localhost", + [int]$TcpPort = 32568, + [string]$RenameFrom = "RetestSdkWriteRenameSrc", + [string]$RenameTo = "RetestSdkWriteRenameDst", + [switch]$SkipCleanup, + [string]$Configuration = "Debug" +) + +$ErrorActionPreference = "Stop" +$repoRoot = Split-Path -Parent $PSScriptRoot +Set-Location $repoRoot + +if (-not $RenameFrom.StartsWith("RetestSdkWrite") -or -not $RenameTo.StartsWith("RetestSdkWrite")) { + throw "Both -RenameFrom and -RenameTo must start with 'RetestSdkWrite' (sandbox guard)." +} + +$reProj = Join-Path $repoRoot "tools\AVEVA.Historian.ReverseEngineering\AVEVA.Historian.ReverseEngineering.csproj" +$harnessProj = Join-Path $repoRoot "tools\AVEVA.Historian.NativeTraceHarness\AVEVA.Historian.NativeTraceHarness.csproj" +$instrProj = Join-Path $repoRoot "tools\AVEVA.Historian.ReverseInstrumentation\AVEVA.Historian.ReverseInstrumentation.csproj" + +$captureDir = Join-Path $repoRoot "artifacts\reverse-engineering\instrumented-wcf-rename" +$currentCopy = Join-Path $captureDir "current-copy" +$instrDll = Join-Path $captureDir "aahClientManaged.dll" +$capturePath = Join-Path $captureDir "rename-capture-latest.ndjson" + +Write-Host "== Building tooling ($Configuration) ==" -ForegroundColor Cyan +dotnet build $reProj -c $Configuration --nologo -v q | Out-Null +dotnet build $instrProj -c $Configuration --nologo -v q | Out-Null +dotnet build $harnessProj -c $Configuration --nologo -v q | Out-Null + +$instrSourceDll = Get-ChildItem -Recurse (Join-Path $repoRoot "tools\AVEVA.Historian.ReverseInstrumentation\bin\$Configuration") ` + -Filter "AVEVA.Historian.ReverseInstrumentation.dll" | Select-Object -First 1 -ExpandProperty FullName +if (-not $instrSourceDll) { throw "ReverseInstrumentation.dll not found under bin\$Configuration." } + +Write-Host "== Instrumenting WriteMessage + ReadMessage ==" -ForegroundColor Cyan +New-Item -ItemType Directory -Force -Path $captureDir | Out-Null +$writeOnly = Join-Path $captureDir "aahClientManaged.write.dll" +dotnet run --no-build -c $Configuration --project $reProj -- ` + instrument-wcf-writemessage (Join-Path $repoRoot "current\aahClientManaged.dll") $writeOnly | Out-Null +dotnet run --no-build -c $Configuration --project $reProj -- ` + instrument-wcf-readmessage $writeOnly $instrDll | Out-Null + +Write-Host "== Staging current-copy ==" -ForegroundColor Cyan +robocopy (Join-Path $repoRoot "current") $currentCopy /MIR /NJH /NJS /NDL /NP /NC /NS | Out-Null +Copy-Item -Force $instrDll (Join-Path $currentCopy "aahClientManaged.dll") +Copy-Item -Force $instrSourceDll (Join-Path $currentCopy "AVEVA.Historian.ReverseInstrumentation.dll") + +$harnessDll = Join-Path $currentCopy "aahClientManaged.dll" +if (Test-Path $capturePath) { Remove-Item -Force $capturePath } +$env:AVEVA_HISTORIAN_RE_CAPTURE = $capturePath + +Write-Host "== Capturing rename ($RenameFrom -> $RenameTo) ==" -ForegroundColor Green +$harnessArgs = @( + "--scenario", "rename", + "--server-name", $ServerName, + "--tcp-port", "$TcpPort", + "--rename-from", $RenameFrom, + "--rename-to", $RenameTo, + "--current-dir", $currentCopy, + "--managed-dll-path", $harnessDll +) + +$harnessJson = $null +try { + $prevEap = $ErrorActionPreference + $ErrorActionPreference = "Continue" + $harnessJson = & dotnet run --no-build -c $Configuration --project $harnessProj -- @harnessArgs 2>&1 +} catch { + Write-Host " (rename raised: $($_.Exception.Message))" -ForegroundColor Yellow +} finally { + $ErrorActionPreference = $prevEap +} + +Remove-Item Env:\AVEVA_HISTORIAN_RE_CAPTURE -ErrorAction SilentlyContinue + +$recCount = if (Test-Path $capturePath) { (Get-Content $capturePath | Where-Object { $_.Trim() }).Count } else { 0 } +Write-Host "`n== Capture summary ==" -ForegroundColor Cyan +Write-Host " -> $recCount records -> $capturePath" +Write-Host "Harness output (RenameTagsReturned / Rows):" -ForegroundColor Cyan +$harnessJson | Select-Object -Last 30 + +# Best-effort cleanup: delete the destination sandbox tag so reruns start clean. +if (-not $SkipCleanup) { + Write-Host "`n== Cleanup: deleting $RenameTo ==" -ForegroundColor Cyan + $cleanupArgs = @( + "--scenario", "write", + "--server-name", $ServerName, + "--tcp-port", "$TcpPort", + "--write-sandbox-tag", $RenameTo, + "--write-skip-add-tag", + "--write-skip-add-value", + "--write-delete-after", + "--current-dir", $currentCopy, + "--managed-dll-path", $harnessDll + ) + try { + $ErrorActionPreference = "Continue" + & dotnet run --no-build -c $Configuration --project $harnessProj -- @cleanupArgs 2>&1 | Select-Object -Last 4 + } catch { + Write-Host " (cleanup raised: $($_.Exception.Message))" -ForegroundColor Yellow + } finally { + $ErrorActionPreference = "Stop" + } +} + +Write-Host "`nDecode with: python scripts\decode-rename-capture.py" -ForegroundColor Cyan diff --git a/scripts/decode-rename-capture.py b/scripts/decode-rename-capture.py new file mode 100644 index 0000000..befcdeb --- /dev/null +++ b/scripts/decode-rename-capture.py @@ -0,0 +1,129 @@ +"""Decode the RenameTags WCF request/response (HCAL R1.10). + +Reads the chained WriteMessage+ReadMessage capture produced by scripts/Capture-RenameTags.ps1 +and locates the rename exchange. Rename maps to the generic job framework: + + StJb (StartJob): WriteMessage carries op "StJb" + a string handle + the rename jobBuffer + (the (old,new) name pairs). ReadMessage carries the returned jobId string. + GtJb (GetJobStatus): WriteMessage carries op "GtJb" + handle + jobId. ReadMessage carries + the job-status buffer. + +We flag bodies by the StJb/GtJb op and by the sandbox names, then dump the buffers so the +jobBuffer layout (batch count + old/new UTF-16 framing) can be read off directly. + +Output is diagnostic. Sanitize before copying into docs/. +""" +import base64 +import json +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +CAPDIR = REPO_ROOT / "artifacts" / "reverse-engineering" / "instrumented-wcf-rename" +CAP = CAPDIR / "rename-capture-latest.ndjson" + +# Sandbox names used by the default capture run (not secret). +FROM = "RetestSdkWriteRenameSrc" +TO = "RetestSdkWriteRenameDst" +OP_STJB = b"StJb" +OP_GTJB = b"GtJb" + + +def hexdump(label, buf, base=0): + print(f"=== {label}: {len(buf)} bytes ===") + for off in range(0, len(buf), 16): + c = buf[off:off + 16] + hp = " ".join(f"{x:02X}" for x in c) + ap = "".join(chr(x) if 32 <= x < 127 else "." for x in c) + print(f" {base + off:04X} {hp:<48} |{ap}|") + print() + + +def ascii_strings(buf, minlen=3): + out, cur, start = [], [], 0 + for i, x in enumerate(buf): + if 32 <= x < 127: + if not cur: + start = i + cur.append(chr(x)) + else: + if len(cur) >= minlen: + out.append((start, "".join(cur))) + cur = [] + if len(cur) >= minlen: + out.append((start, "".join(cur))) + return out + + +def u16_strings(buf, minlen=3): + out, i = [], 0 + while i < len(buf) - 1: + j, chars = i, [] + while j < len(buf) - 1 and 32 <= buf[j] < 127 and buf[j + 1] == 0: + chars.append(chr(buf[j])) + j += 2 + if len(chars) >= minlen: + out.append((i, "".join(chars))) + i = j + else: + i += 1 + return out + + +def main() -> int: + if not CAP.exists(): + print(f"Missing capture: {CAP}\nRun scripts/Capture-RenameTags.ps1 first.") + return 1 + + records = [] + for line in CAP.open(encoding="utf-8-sig"): + if line.strip(): + records.append(json.loads(line)) + + from_u16, to_u16 = FROM.encode("utf-16-le"), TO.encode("utf-16-le") + from_a, to_a = FROM.encode("ascii"), TO.encode("ascii") + + print(f"== {len(records)} MDAS bodies captured ==") + for idx, rec in enumerate(records): + body = base64.b64decode(rec["Base64"]) + flags = [] + if OP_STJB in body: + flags.append("StJb") + if OP_GTJB in body: + flags.append("GtJb") + if from_u16 in body or from_a in body: + flags.append("FROM") + if to_u16 in body or to_a in body: + flags.append("TO") + print(f" [{idx:02d}] {rec.get('Phase'):26s} len={len(body):5d} {','.join(flags)}") + + def find(predicate): + hits = [] + for idx, rec in enumerate(records): + body = base64.b64decode(rec["Base64"]) + if predicate(rec, body): + hits.append((idx, rec, body)) + return hits + + print("\n== StJb request(s): WriteMessage bodies tagged StJb ==") + for idx, rec, body in find(lambda r, b: r.get("Phase") == "WCF.WriteMessage.Body" and OP_STJB in b): + hexdump(f"[{idx}] StJb WriteMessage", body) + print(" UTF-16 strings:") + for off, s in u16_strings(body): + print(f" 0x{off:04X} {s!r}") + print(" ASCII strings:") + for off, s in ascii_strings(body): + print(f" 0x{off:04X} {s!r}") + print() + + print("\n== StJb / GtJb response(s) + GtJb request(s) ==") + for idx, rec, body in find(lambda r, b: (OP_STJB in b or OP_GTJB in b) and r.get("Phase") == "WCF.ReadMessage.Body"): + hexdump(f"[{idx}] {rec.get('Phase')}", body) + print(" strings:", [s for _, s in ascii_strings(body)][:16]) + print() + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/AVEVA.Historian.Client/HistorianClient.cs b/src/AVEVA.Historian.Client/HistorianClient.cs index 05e68b3..950d0cc 100644 --- a/src/AVEVA.Historian.Client/HistorianClient.cs +++ b/src/AVEVA.Historian.Client/HistorianClient.cs @@ -149,6 +149,32 @@ public sealed class HistorianClient : IAsyncDisposable return new HistorianWcfTagWriteOrchestrator(_options).DeleteTagAsync(tagName, cancellationToken); } + /// + /// Renames one tag, submitting an asynchronous rename job via the History StartJob (StJb) + /// operation. Convenience wrapper over for a single (old,new) pair. + /// Requires the server's AllowRenameTags system parameter to be enabled. + /// + public Task RenameTagAsync(string oldName, string newName, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(oldName); + ArgumentException.ThrowIfNullOrWhiteSpace(newName); + return RenameTagsAsync([(oldName, newName)], cancellationToken); + } + + /// + /// 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 StartJob (StJb) operation and + /// the returned reports whether the server accepted/queued + /// the job (and its job id); the renames apply in the background. The server's + /// AllowRenameTags system parameter must be enabled or the server rejects the job. See + /// docs/reverse-engineering/wcf-rename-tags.md. + /// + public Task 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; diff --git a/src/AVEVA.Historian.Client/Models/HistorianTagRenameResult.cs b/src/AVEVA.Historian.Client/Models/HistorianTagRenameResult.cs new file mode 100644 index 0000000..181229a --- /dev/null +++ b/src/AVEVA.Historian.Client/Models/HistorianTagRenameResult.cs @@ -0,0 +1,29 @@ +namespace AVEVA.Historian.Client.Models; + +/// +/// Result of . Tag rename on the Historian is an +/// asynchronous server-side job: the client submits the rename batch via the History +/// StartJob (StJb) operation and the server returns a job id, then applies the renames in the +/// background (the native client polls GetJobStatus/GtJb until the job reports done). +/// +/// 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 AllowRenameTags must be enabled, or the native client +/// library rejects the call before it reaches the wire (error 132 OperationNotEnabled). +/// +public sealed record HistorianTagRenameResult +{ + /// True when the server accepted and queued the rename job (StartJob returned success + /// with an empty error buffer). + public required bool Accepted { get; init; } + + /// The server-assigned job id for the submitted rename batch (the strJobid + /// returned by StartJob). if the server returned no parseable id. + public Guid JobId { get; init; } + + /// Number of (old,new) name pairs submitted in the batch. + public int PairCount { get; init; } + + /// Server error text when is false; otherwise null. + public string? Error { get; init; } +} diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianTagRenameProtocol.cs b/src/AVEVA.Historian.Client/Wcf/HistorianTagRenameProtocol.cs new file mode 100644 index 0000000..adcf7a8 --- /dev/null +++ b/src/AVEVA.Historian.Client/Wcf/HistorianTagRenameProtocol.cs @@ -0,0 +1,66 @@ +using System.Buffers.Binary; +using System.Text; + +namespace AVEVA.Historian.Client.Wcf; + +/// +/// Serializes the History StartJob (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 StartJob job buffer and the server returns a job id (the rename then +/// completes asynchronously; see ). +/// +/// Wire layout (decoded from an instrumented native RenameTags capture, see +/// docs/reverse-engineering/wcf-rename-tags.md): +/// +/// 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) +/// +/// 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. +/// +internal static class HistorianTagRenameProtocol +{ + /// + /// 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. + /// + 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 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 u32) + { + byte[] bytes = Encoding.Unicode.GetBytes(value); + BinaryPrimitives.WriteUInt32LittleEndian(u32, (uint)value.Length); + ms.Write(u32); + ms.Write(bytes, 0, bytes.Length); + } +} diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianWcfTagWriteOrchestrator.cs b/src/AVEVA.Historian.Client/Wcf/HistorianWcfTagWriteOrchestrator.cs index b43f72a..479d488 100644 --- a/src/AVEVA.Historian.Client/Wcf/HistorianWcfTagWriteOrchestrator.cs +++ b/src/AVEVA.Historian.Client/Wcf/HistorianWcfTagWriteOrchestrator.cs @@ -41,6 +41,22 @@ internal sealed class HistorianWcfTagWriteOrchestrator return Task.Run(() => DeleteTag(tagName), cancellationToken); } + public Task 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; + } + + /// + /// Submits the rename batch via the History StartJob (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 AllowRenameTags 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). + /// + 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 ? "" : 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.", + }; + } + + /// Env-gated dump of the clean rename job buffer (base64) for golden-fixture capture, + /// mirroring the AVEVA_HISTORIAN_SQL_DUMP hook — avoids hand-stitching MDAS chunk markers + /// out of a raw instrument capture. + 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, diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianClientIntegrationTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianClientIntegrationTests.cs index be7e6ac..7b7450b 100644 --- a/tests/AVEVA.Historian.Client.Tests/HistorianClientIntegrationTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/HistorianClientIntegrationTests.cs @@ -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] diff --git a/tests/AVEVA.Historian.Client.Tests/WcfTagRenameProtocolTests.cs b/tests/AVEVA.Historian.Client.Tests/WcfTagRenameProtocolTests.cs new file mode 100644 index 0000000..88471da --- /dev/null +++ b/tests/AVEVA.Historian.Client.Tests/WcfTagRenameProtocolTests.cs @@ -0,0 +1,62 @@ +using System.Buffers.Binary; +using System.Text; +using AVEVA.Historian.Client.Wcf; + +namespace AVEVA.Historian.Client.Tests; + +/// +/// Golden-byte tests for the History StartJob (StJb) rename job buffer (HCAL R1.10). +/// The reference buffer is the exact byte[] the SDK handed the WCF channel in the live +/// RenameTagsAsync_AgainstLocalHistorian_RenamesSandboxTag run — the server accepted it and +/// the tag was renamed, so it is server-validated, not hand-derived from a chunk-mangled capture. +/// +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(() => + HistorianTagRenameProtocol.SerializeRenameJob(Array.Empty<(string, string)>())); + } +} diff --git a/tools/AVEVA.Historian.NativeTraceHarness/Program.cs b/tools/AVEVA.Historian.NativeTraceHarness/Program.cs index c29be6d..c59dc24 100644 --- a/tools/AVEVA.Historian.NativeTraceHarness/Program.cs +++ b/tools/AVEVA.Historian.NativeTraceHarness/Program.cs @@ -93,7 +93,7 @@ internal static class Program object connectionArgs = Activator.CreateInstance(connectionArgsType)!; SetProperty(connectionArgs, "ServerName", serverName); SetProperty(connectionArgs, "TcpPort", checked((ushort)tcpPort)); - SetProperty(connectionArgs, "ReadOnly", !IsWriteScenario(scenario)); + SetProperty(connectionArgs, "ReadOnly", !(IsWriteScenario(scenario) || IsRenameScenario(scenario))); SetProperty(connectionArgs, "IntegratedSecurity", integratedSecurity); SetProperty(connectionArgs, "ConnectionType", Enum.Parse(connectionType, IsEventScenario(scenario) ? "Event" : "Process")); if (directConnection) @@ -683,6 +683,104 @@ internal static class Program }); } } + else if (openSuccess && status.ConnectedToServer && IsRenameScenario(scenario)) + { + // R1.10 capture: drive HistorianAccess.RenameTags(Tuple[] pairs, ref + // HistorianTagRenameStatus, out err) so instrument-wcf-{write,read}message can observe + // the StJb (StartJob) jobBuffer that encodes the rename request + the GtJb + // (GetJobStatus) response. Sandbox-guarded: both names must start with RetestSdkWrite. + string renameFrom = GetArg(args, "--rename-from") ?? "RetestSdkWriteRenameSrc"; + string renameTo = GetArg(args, "--rename-to") ?? "RetestSdkWriteRenameDst"; + if (!renameFrom.StartsWith("RetestSdkWrite", StringComparison.Ordinal) + || !renameTo.StartsWith("RetestSdkWrite", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "Rename scenario refuses names that don't start with 'RetestSdkWrite'. Pass --rename-from/--rename-to RetestSdkWrite..."); + } + + var renameRows = new List(); + + // 1) Ensure the source sandbox tag exists (AddTag), unless --rename-skip-create. + if (!HasFlag(args, "--rename-skip-create")) + { + Type tagDefType = GetType(assembly, "ArchestrA.HistorianTag"); + Type tagDataTypeEnum = GetType(assembly, "ArchestrA.HistorianDataType"); + Type tagStorageTypeEnum = GetType(assembly, "ArchestrA.HistorianStorageType"); + object tag = Activator.CreateInstance(tagDefType)!; + SetProperty(tag, "TagName", renameFrom); + SetProperty(tag, "TagDescription", "SDK rename-RE sandbox tag"); + SetProperty(tag, "EngineeringUnit", "test"); + SetProperty(tag, "TagDataType", Enum.Parse(tagDataTypeEnum, "Float", ignoreCase: true)); + SetProperty(tag, "TagStorageType", Enum.Parse(tagStorageTypeEnum, "Cyclic", ignoreCase: true)); + SetProperty(tag, "MinEU", 0.0); + SetProperty(tag, "MaxEU", 100.0); + SetProperty(tag, "MinRaw", 0.0); + SetProperty(tag, "MaxRaw", 100.0); + SetProperty(tag, "StorageRate", 1000u); + SetProperty(tag, "ApplyScaling", false); + object addError = Activator.CreateInstance(errorType)!; + MethodInfo addTagMethod = accessType.GetMethod("AddTag", new[] { tagDefType, typeof(uint).MakeByRefType(), errorType.MakeByRefType() }) + ?? throw new MissingMethodException("HistorianAccess.AddTag"); + object?[] addTagArgs = [tag, 0u, addError]; + bool addOk = (bool)addTagMethod.Invoke(access, addTagArgs)!; + renameRows.Add(new + { + Kind = "AddTag", + Success = addOk, + ErrorDescription = GetPropertyText(addTagArgs[2]!, "ErrorDescription"), + }); + } + + // 2) RenameTags([(from, to)]) — batch of (old, new) name pairs. + Type renameStatusType = GetType(assembly, "ArchestrA.HistorianTagRenameStatus"); + MethodInfo renameMethod = accessType.GetMethods() + .First(m => m.Name == "RenameTags" && m.GetParameters().Length == 3); + Array pairs = Array.CreateInstance(typeof(Tuple), 1); + pairs.SetValue(Tuple.Create(renameFrom, renameTo), 0); + object renameStatus = Activator.CreateInstance(renameStatusType)!; + object renameError = Activator.CreateInstance(errorType)!; + object?[] renameArgs = [pairs, renameStatus, renameError]; + WriteRuntimeMethodPointerSnapshot(assembly, runtimeMethodPointerOutput, runtimeMethodPointerFilters, repoRoot, scenario, "before-rename-tags"); + bool renameOk = (bool)renameMethod.Invoke(access, renameArgs)!; + renameStatus = renameArgs[1]!; + renameError = renameArgs[2]!; + renameRows.Add(new + { + Kind = "RenameTags", + Success = renameOk, + Status = SnapshotObject(renameStatus), + ErrorDescription = GetPropertyText(renameError, "ErrorDescription"), + ErrorCode = GetPropertyText(renameError, "ErrorCode"), + ErrorType = GetPropertyText(renameError, "ErrorType"), + }); + + // 3) Poll GetTagRenameStatus(ref status) a few times (job is async server-side). + MethodInfo? statusMethod = accessType.GetMethods() + .FirstOrDefault(m => m.Name == "GetTagRenameStatus" && m.GetParameters().Length == 1); + if (renameOk && statusMethod is not null) + { + for (int i = 0; i < 6; i++) + { + object?[] stArgs = [renameStatus]; + object? stRet = statusMethod.Invoke(access, stArgs); + renameStatus = stArgs[0]!; + renameRows.Add(new { Kind = "GetTagRenameStatus", Iteration = i, Returned = stRet?.ToString(), Status = SnapshotObject(renameStatus) }); + bool pending = GetPropertyValue(renameStatus, "Pending") as bool? ?? false; + if (!pending) break; + System.Threading.Thread.Sleep(500); + } + } + + Console.WriteLine(Serialize(new + { + Scenario = scenario, + RenameFrom = renameFrom, + RenameTo = renameTo, + RenameTagsReturned = renameOk, + Rows = renameRows, + })); + return 0; + } else if (openSuccess && status.ConnectedToServer && IsTagScenario(scenario)) { object query = accessType.GetMethod("CreateTagQuery", Type.EmptyTypes)!.Invoke(access, Array.Empty())!; @@ -1245,6 +1343,20 @@ internal static class Program || scenario.Equals("tag-write", StringComparison.OrdinalIgnoreCase); } + /// + /// Tag-rename scenario (R1.10 capture): opens a write-enabled connection and calls + /// RenameTags(Tuple<string,string>[] pairs, ref HistorianTagRenameStatus, out err) + /// so instrument-wcf-{write,read}message can observe the StJb (StartJob) jobBuffer that + /// encodes the rename request and the GtJb (GetJobStatus) response. Sandbox-guarded: + /// both names must start with RetestSdkWrite. + /// + private static bool IsRenameScenario(string scenario) + { + return scenario.Equals("rename", StringComparison.OrdinalIgnoreCase) + || scenario.Equals("rename-tag", StringComparison.OrdinalIgnoreCase) + || scenario.Equals("rename-tags", StringComparison.OrdinalIgnoreCase); + } + private static Dictionary SnapshotObject(object target) { Dictionary snapshot = new(StringComparer.OrdinalIgnoreCase);