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:
@@ -73,7 +73,7 @@ blob needs RE).
|
|||||||
| Create analog tag | `AddTag` | `History.EnsureTags` (EnsT2) | ✅ | DONE | Float/Double/Int2/Int4/UInt2/UInt4 + scaling |
|
| 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 |
|
| 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 | |
|
| 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 extended properties | `AddTagExtendedProperties`, `DeleteTagExtendedPropertiesByName` | `History.AddTagExtendedProperties` / `DeleteTagExtendedProperties` | ⬜ | BOUNDED | gRPC op + TEP serialize |
|
||||||
| Add/Delete localized properties | `AddTagLocalizedProperties`, `DeleteTagLocalizedPropertiesByName` | `History.AddTagLocalizedProperties` / `DeleteTagLocalizedProperties` | ⬜ | BOUNDED | |
|
| Add/Delete localized properties | `AddTagLocalizedProperties`, `DeleteTagLocalizedPropertiesByName` | `History.AddTagLocalizedProperties` / `DeleteTagLocalizedProperties` | ⬜ | BOUNDED | |
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
### 1c. Bounded config writes (S–M each)
|
||||||
| ID | Capability | gRPC op | Payload | Notes |
|
| 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.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.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 |
|
| 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 |
|
||||||
|
|||||||
@@ -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<string,string>[] 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.
|
||||||
@@ -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
|
||||||
@@ -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())
|
||||||
@@ -149,6 +149,32 @@ public sealed class HistorianClient : IAsyncDisposable
|
|||||||
return new HistorianWcfTagWriteOrchestrator(_options).DeleteTagAsync(tagName, cancellationToken);
|
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()
|
public ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
return ValueTask.CompletedTask;
|
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);
|
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)
|
private bool EnsureTag(HistorianTagDefinition definition)
|
||||||
{
|
{
|
||||||
Guid contextKey = Guid.NewGuid();
|
Guid contextKey = Guid.NewGuid();
|
||||||
@@ -86,6 +102,73 @@ internal sealed class HistorianWcfTagWriteOrchestrator
|
|||||||
return result;
|
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(
|
private static bool SendEnsureTags2(
|
||||||
IHistoryServiceContract2 historyChannel,
|
IHistoryServiceContract2 historyChannel,
|
||||||
HistorianWcfAuthChainHelper.OpenConnectionContext context,
|
HistorianWcfAuthChainHelper.OpenConnectionContext context,
|
||||||
|
|||||||
@@ -447,6 +447,75 @@ public sealed class HistorianClientIntegrationTests
|
|||||||
Assert.True(deleted, "DeleteTagAsync returned false against the live Historian.");
|
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
|
// 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.
|
// sandbox tag name is suffixed per case so the runs don't collide. Always cleans up.
|
||||||
[Theory]
|
[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)>()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -93,7 +93,7 @@ internal static class Program
|
|||||||
object connectionArgs = Activator.CreateInstance(connectionArgsType)!;
|
object connectionArgs = Activator.CreateInstance(connectionArgsType)!;
|
||||||
SetProperty(connectionArgs, "ServerName", serverName);
|
SetProperty(connectionArgs, "ServerName", serverName);
|
||||||
SetProperty(connectionArgs, "TcpPort", checked((ushort)tcpPort));
|
SetProperty(connectionArgs, "TcpPort", checked((ushort)tcpPort));
|
||||||
SetProperty(connectionArgs, "ReadOnly", !IsWriteScenario(scenario));
|
SetProperty(connectionArgs, "ReadOnly", !(IsWriteScenario(scenario) || IsRenameScenario(scenario)));
|
||||||
SetProperty(connectionArgs, "IntegratedSecurity", integratedSecurity);
|
SetProperty(connectionArgs, "IntegratedSecurity", integratedSecurity);
|
||||||
SetProperty(connectionArgs, "ConnectionType", Enum.Parse(connectionType, IsEventScenario(scenario) ? "Event" : "Process"));
|
SetProperty(connectionArgs, "ConnectionType", Enum.Parse(connectionType, IsEventScenario(scenario) ? "Event" : "Process"));
|
||||||
if (directConnection)
|
if (directConnection)
|
||||||
@@ -683,6 +683,104 @@ internal static class Program
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else if (openSuccess && status.ConnectedToServer && IsRenameScenario(scenario))
|
||||||
|
{
|
||||||
|
// R1.10 capture: drive HistorianAccess.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 + 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<object>();
|
||||||
|
|
||||||
|
// 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<string, string>), 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))
|
else if (openSuccess && status.ConnectedToServer && IsTagScenario(scenario))
|
||||||
{
|
{
|
||||||
object query = accessType.GetMethod("CreateTagQuery", Type.EmptyTypes)!.Invoke(access, Array.Empty<object>())!;
|
object query = accessType.GetMethod("CreateTagQuery", Type.EmptyTypes)!.Invoke(access, Array.Empty<object>())!;
|
||||||
@@ -1245,6 +1343,20 @@ internal static class Program
|
|||||||
|| scenario.Equals("tag-write", StringComparison.OrdinalIgnoreCase);
|
|| scenario.Equals("tag-write", StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tag-rename scenario (R1.10 capture): opens a write-enabled connection and calls
|
||||||
|
/// <c>RenameTags(Tuple<string,string>[] pairs, ref HistorianTagRenameStatus, out err)</c>
|
||||||
|
/// so instrument-wcf-{write,read}message can observe the <c>StJb</c> (StartJob) jobBuffer that
|
||||||
|
/// encodes the rename request and the <c>GtJb</c> (GetJobStatus) response. Sandbox-guarded:
|
||||||
|
/// both names must start with <c>RetestSdkWrite</c>.
|
||||||
|
/// </summary>
|
||||||
|
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<string, object?> SnapshotObject(object target)
|
private static Dictionary<string, object?> SnapshotObject(object target)
|
||||||
{
|
{
|
||||||
Dictionary<string, object?> snapshot = new(StringComparer.OrdinalIgnoreCase);
|
Dictionary<string, object?> snapshot = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|||||||
Reference in New Issue
Block a user