Implement EnsureTagAsync (live-verified) + DeleteTagAsync (DelT semantics partial)

New SDK surface:
  HistorianClient.EnsureTagAsync(HistorianTagDefinition)
  HistorianClient.DeleteTagAsync(string tagName)

Plumbing:
  src/AVEVA.Historian.Client/Models/HistorianTagDefinition.cs
    Public input model — TagName/Description/EngineeringUnit/DataType/MinEU/MaxEU.
    Currently only HistorianDataType.Float is live-verified.

  src/AVEVA.Historian.Client/Wcf/HistorianTagWriteProtocol.cs
    SerializeAnalogCTagMetadata produces 146-byte payload byte-for-byte
    identical to the captured native EnsT2(Float) request.
    SerializeDeleteTagNames produces ushort 0x6751 + ushort 1 + uint count
    + per-tag (uint charCount + UTF-16 chars).

  src/AVEVA.Historian.Client/Wcf/HistorianWcfTagWriteOrchestrator.cs
    Both EnsT2 and DelT run the full Stat-priming chain captured for the
    analog flow (UpdC3 + Stat.GetV ×3 + Stat.GETHI ×2 + 7× GetSystemParameter
    + Trx.GetV + Retr.GetV).

  src/AVEVA.Historian.Client/Wcf/HistorianWcfTagClient.cs
    MapDataType extended to accept tag-origin marker 0xC7 (SDK-created tags).

Tests:
  5 golden-byte tests (HistorianTagWriteProtocolTests):
    SerializeAnalogCTagMetadata byte-for-byte match against captured 146-byte fixture
    SerializeAnalogCTagMetadata produces different bytes for different inputs
    SerializeDeleteTagNames single-tag matches captured shape
    SerializeDeleteTagNames multi-tag appends each
    SerializeDeleteTagNames empty list throws

  1 live integration test (gated by HISTORIAN_WRITE_SANDBOX_TAG):
    EnsureTagAsync_AndDeleteTagAsync_RoundTrip_AgainstLocalHistorian
    EnsureTagAsync creates the sandbox tag, GetTagMetadataAsync reads it
    back. 130/130 tests pass.

Harness improvements:
  --write-delete-after now runs DelT independently of AddStreamedValue
  outcome.
  HistorianTagStatusList constructed correctly for DeleteTags reflection
  call (previous StringCollection attempt failed with TypeMismatch).

Known DelT gap: SDK's DeleteTagAsync returns true but server-side
cascading deletion does not always complete (the row remains in
Runtime.dbo.Tag). The captured native flow's DelT removes the tag
cleanly (verified via harness --write-delete-after), so something
around the WCF DelT call is missing from our orchestrator. Documented
as known issue with SMC-based cleanup as workaround.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
dohertj2
2026-05-04 08:33:21 -04:00
parent b3d22befd0
commit cfc8d44e3a
9 changed files with 664 additions and 19 deletions
@@ -121,6 +121,42 @@ public sealed class HistorianClient : IAsyncDisposable
return _protocol.GetSystemParameterAsync(name, cancellationToken);
}
/// <summary>
/// Creates or updates the named tag in the Historian Runtime database via
/// <c>EnsureTags2</c>. Currently only <see cref="HistorianDataType.Float"/> is
/// live-verified. Note: writing data values to the new tag (via a separate
/// AddStreamedValue/AddS2 path) is NOT supported by the SDK — see
/// <c>docs/plans/write-commands-reverse-engineering.md</c> for the architectural
/// finding.
/// </summary>
public Task<bool> EnsureTagAsync(HistorianTagDefinition definition, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(definition);
if (!OperatingSystem.IsWindows())
{
throw new ProtocolEvidenceMissingException("EnsureTagAsync requires Windows for the SSPI auth path");
}
return new HistorianWcfTagWriteOrchestrator(_options).EnsureTagAsync(definition, cancellationToken);
}
/// <summary>
/// Deletes the named tag via <c>DeleteTags</c>. **Known issue (2026-05-04):**
/// the SDK's DelT call returns true but the server-side cascading deletion does
/// not always complete (the row remains in <c>Runtime.dbo.Tag</c>). The
/// captured native flow's DelT removes the tag cleanly, so additional priming
/// or a side call between WCF DelT and server cascade is missing. Use the SMC
/// fallback to clean up sandbox tags until this is resolved.
/// </summary>
public Task<bool> DeleteTagAsync(string tagName, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tagName);
if (!OperatingSystem.IsWindows())
{
throw new ProtocolEvidenceMissingException("DeleteTagAsync requires Windows for the SSPI auth path");
}
return new HistorianWcfTagWriteOrchestrator(_options).DeleteTagAsync(tagName, cancellationToken);
}
public ValueTask DisposeAsync()
{
return ValueTask.CompletedTask;