fix(historian): read-only historian bindings + ProvisionTags policy; provision OPC UA type names (#899, #898)
v2-ci / unit-tests (push) Has been cancelled

#899 — EnsureTags is an upsert, so binding a node to an existing AVEVA tag via
historianTagname re-typed/re-described it on every add and host start.
- TagConfig `historianReadOnly: true` (TagConfigIntent -> RawTagPlan.HistorianReadOnly):
  HistoryRead from an existing historian tag that OtOpcUa never provisions and never
  records into (AddressSpaceApplier.IsOwnedHistorizedValue gates both the EnsureTags
  hook and the recorder feed). Node-inert: surgical, not mirrored on UNS refs, in the
  RawTagPlan equality/hash. A non-bool value is the deploy error HistorianReadOnlyInvalid.
  AdminUI: CSV column HistorianReadOnly (strict), TagHistorizeConfig reads via the
  runtime parse.
- ServerHistorian:ProvisionTags = CreateOrUpdate (default, unchanged) | CreateOnly
  (Gateway only: TagExistsAsync first, existing tags left alone, failed lookup not
  sent) | Off. Unknown value / CreateOnly on a SQL backend fail host start.
- Default for an explicit historianTagname stays OWNED: overrides pin owned history
  across renames, a read-only default would silently stop provisioning + recording.

#898 — the hook parsed Tag.DataType (an OPC UA name) as DriverDataType, so historized
Float/Double/Byte/SByte tags were never provisioned. New shared
HistorianDataTypeNames.TryResolve: Float->Float32, Double->Float64, Byte/SByte->Int16
(lossless, provisioned as Int2); unmapped names logged at Information. Gateway now skips
Int64/UInt64 (HistorianGateway cannot create Int8/UInt8: lossless-cast-or-reject).

UPGRADE: the first host start after this provisions (upserts) every existing historized
Float/Double/Byte/SByte tag. Mark foreign-tag bindings historianReadOnly, or set
ServerHistorian:ProvisionTags=CreateOnly/Off, before upgrading (docs/Historian.md).

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
This commit is contained in:
Joseph Doherty
2026-11-13 01:16:23 -05:00
parent eec87d3f74
commit 6c4d29902e
36 changed files with 1015 additions and 60 deletions
@@ -126,6 +126,7 @@ public static class GatewayHistorian
return new GatewayTagProvisioner(
HistorianGatewayClientAdapter.Create(options, loggerFactory),
logger);
logger,
createOnly: options.ProvisionPolicy == HistorianProvisionPolicy.CreateOnly);
}
}
@@ -1,3 +1,4 @@
using Grpc.Core;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.HistorianGateway.Contracts.Grpc;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -10,6 +11,14 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway;
/// Non-historizable driver types are skipped (never built into a definition); the historizable
/// ones are mapped via <see cref="HistorianTypeMapper"/> and batched into a single
/// <c>EnsureTags</c> call.
/// <para>
/// <b><c>EnsureTags</c> is an upsert</b>: it re-types and re-describes an EXISTING historian tag. Under
/// <c>ServerHistorian:ProvisionTags=CreateOnly</c> (lmxopcua#899) each historizable request is looked up first
/// (<see cref="IHistorianGatewayClient.TagExistsAsync"/> — a read) and only a tag the historian reports
/// ABSENT is sent; an existing tag is left as it is and counted in <see cref="HistorianProvisionResult.Skipped"/>.
/// A lookup that fails is counted <see cref="HistorianProvisionResult.Failed"/> and the tag is NOT sent — the
/// policy fails closed, never into an upsert.
/// </para>
/// </summary>
/// <remarks>
/// <b>Non-blocking.</b> A historian that is unreachable or errors must never fail an address-space
@@ -37,18 +46,30 @@ public sealed class GatewayTagProvisioner : IHistorianProvisioning, IAsyncDispos
/// </summary>
private const double DefaultIntegralDivisor = 1.0;
/// <summary>Concurrent existence lookups under <c>CreateOnly</c> (a deploy's first plan carries every tag).</summary>
private const int ExistenceLookupParallelism = 4;
private readonly IHistorianGatewayClient _client;
private readonly ILogger<GatewayTagProvisioner> _logger;
private readonly bool _createOnly;
/// <summary>Creates the provisioner over a gateway client seam.</summary>
/// <param name="client">The gateway client used for the <c>EnsureTags</c> path.</param>
/// <param name="logger">Logger for skip/failure diagnostics (never logs tag values).</param>
public GatewayTagProvisioner(IHistorianGatewayClient client, ILogger<GatewayTagProvisioner> logger)
/// <param name="createOnly">
/// <c>ServerHistorian:ProvisionTags=CreateOnly</c>: send only tags the historian reports absent, so an existing
/// tag is never modified. <see langword="false"/> (default) = the create-or-update upsert.
/// </param>
public GatewayTagProvisioner(IHistorianGatewayClient client, ILogger<GatewayTagProvisioner> logger, bool createOnly = false)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_createOnly = createOnly;
}
/// <summary>Whether this provisioner runs under <c>ServerHistorian:ProvisionTags=CreateOnly</c> — test/diagnostic.</summary>
internal bool CreateOnly => _createOnly;
/// <inheritdoc />
public async Task<HistorianProvisionResult> EnsureTagsAsync(
IReadOnlyList<HistorianTagProvisionRequest> requests, CancellationToken ct)
@@ -57,7 +78,9 @@ public sealed class GatewayTagProvisioner : IHistorianProvisioning, IAsyncDispos
var definitions = new List<HistorianTagDefinition>(requests.Count);
var skipped = 0;
var lookupFailed = 0;
var candidates = new List<HistorianTagProvisionRequest>(requests.Count);
foreach (var request in requests)
{
if (!HistorianTypeMapper.IsHistorizable(request.DataType))
@@ -69,6 +92,25 @@ public sealed class GatewayTagProvisioner : IHistorianProvisioning, IAsyncDispos
continue;
}
candidates.Add(request);
}
if (_createOnly && candidates.Count > 0)
{
var (absent, existing, failed) = await FilterToAbsentAsync(candidates, ct).ConfigureAwait(false);
skipped += existing;
lookupFailed = failed;
if (existing > 0 || failed > 0)
{
_logger.LogInformation(
"Historian provisioning (CreateOnly): {Existing} existing tag(s) left unchanged, {Failed} not sent because the lookup failed.",
existing, failed);
}
candidates = absent;
}
foreach (var request in candidates)
{
definitions.Add(new HistorianTagDefinition
{
TagName = request.TagName,
@@ -83,10 +125,11 @@ public sealed class GatewayTagProvisioner : IHistorianProvisioning, IAsyncDispos
});
}
// Every request was non-historizable — nothing to send. Skip the empty gateway round-trip.
// Nothing left to send (every request non-historizable, or kept/unresolved under CreateOnly). Skip the
// empty gateway round-trip.
if (definitions.Count == 0)
{
return new HistorianProvisionResult(requests.Count, Ensured: 0, Skipped: skipped, Failed: 0);
return new HistorianProvisionResult(requests.Count, Ensured: 0, Skipped: skipped, Failed: lookupFailed);
}
try
@@ -94,7 +137,7 @@ public sealed class GatewayTagProvisioner : IHistorianProvisioning, IAsyncDispos
var results = await _client.EnsureTagsAsync(definitions, ct).ConfigureAwait(false);
var ensured = results.Results.Count(r => r.Success);
var failed = Math.Max(0, definitions.Count - ensured);
return new HistorianProvisionResult(requests.Count, ensured, skipped, failed);
return new HistorianProvisionResult(requests.Count, ensured, skipped, failed + lookupFailed);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
@@ -102,7 +145,7 @@ public sealed class GatewayTagProvisioner : IHistorianProvisioning, IAsyncDispos
// the unsent batch as Failed (it didn't land) but log quietly and never throw, keeping
// the non-blocking contract.
_logger.LogDebug("Tag provisioning cancelled at shutdown; deferred.");
return new HistorianProvisionResult(requests.Count, Ensured: 0, Skipped: skipped, Failed: definitions.Count);
return new HistorianProvisionResult(requests.Count, Ensured: 0, Skipped: skipped, Failed: definitions.Count + lookupFailed);
}
catch (Exception exception)
{
@@ -111,10 +154,65 @@ public sealed class GatewayTagProvisioner : IHistorianProvisioning, IAsyncDispos
_logger.LogWarning(
"EnsureTags failed for {Count} historian tag(s) ({Exception}); provisioning deferred.",
definitions.Count, exception.GetType().Name);
return new HistorianProvisionResult(requests.Count, Ensured: 0, Skipped: skipped, Failed: definitions.Count);
return new HistorianProvisionResult(requests.Count, Ensured: 0, Skipped: skipped, Failed: definitions.Count + lookupFailed);
}
}
/// <summary>
/// <c>CreateOnly</c>: keeps only the requests whose tag the historian reports absent. An existing tag, or one whose
/// lookup failed (anything but a clean "absent" / gRPC <c>NotFound</c>), is dropped — fail closed.
/// </summary>
private async Task<(List<HistorianTagProvisionRequest> Absent, int Existing, int Failed)> FilterToAbsentAsync(
List<HistorianTagProvisionRequest> candidates, CancellationToken ct)
{
var verdicts = new bool?[candidates.Count]; // true = absent, false = exists, null = lookup failed
try
{
await Parallel.ForAsync(0, candidates.Count,
new ParallelOptions { MaxDegreeOfParallelism = ExistenceLookupParallelism, CancellationToken = ct },
async (i, token) =>
{
try
{
verdicts[i] = !await _client.TagExistsAsync(candidates[i].TagName, token).ConfigureAwait(false);
}
catch (Exception ex) when (IsNotFound(ex))
{
verdicts[i] = true;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
verdicts[i] = null;
_logger.LogDebug("Historian tag lookup failed ({Exception}); the tag is not provisioned.", ex.GetType().Name);
}
}).ConfigureAwait(false);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// Shutdown: whatever was not looked up stays null (failed, not sent).
}
var absent = new List<HistorianTagProvisionRequest>(candidates.Count);
int existing = 0, failed = 0;
for (var i = 0; i < candidates.Count; i++)
{
switch (verdicts[i])
{
case true: absent.Add(candidates[i]); break;
case false: existing++; break;
default: failed++; break;
}
}
return (absent, existing, failed);
}
private static bool IsNotFound(Exception ex)
{
for (Exception? e = ex; e is not null; e = e.InnerException)
if (e is RpcException { StatusCode: StatusCode.NotFound }) return true;
return false;
}
/// <summary>
/// Disposes the underlying gateway client (and its gRPC channel). The DI container owns this
/// provisioner as a singleton (<c>AddHistorianProvisioning</c>), so this fires once at host
@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping;
/// <see cref="HistorianDataType"/> for tag provisioning + historical writes.
/// </summary>
/// <remarks>
/// Only the nine numeric types are historizable on the gateway's analog write path. Two of them
/// Seven numeric types are historizable on the gateway's analog write path. Two of them
/// fall back to a wider/adjacent historian type because the exact one's write path is not usable
/// upstream: <see cref="DriverDataType.UInt16"/> maps to <see cref="HistorianDataType.Uint4"/> (the
/// historian's <c>UInt2</c> write path is not proven), and <see cref="DriverDataType.Boolean"/> maps
@@ -17,6 +17,15 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping;
/// stub), so Boolean historizes as a small 0/1 integer instead. String / DateTime / Reference are not
/// historized in v1 and throw <see cref="NotSupportedException"/>; callers that want to skip them
/// without catching an exception should consult <see cref="IsHistorizable(DriverDataType)"/> first.
/// <para>
/// <see cref="DriverDataType.Int64"/> / <see cref="DriverDataType.UInt64"/> are not historizable either
/// (lmxopcua#898): HistorianGateway's type policy is lossless-cast-or-reject, its client cannot create
/// <c>Int8</c>/<c>UInt8</c> tags, and no creatable type holds a 64-bit integer losslessly (<c>Double</c> loses
/// precision above 2^53), so <c>EnsureTags(Int8/Uint8)</c> fails with <c>FailedPrecondition</c>. They used to be
/// sent and counted <c>failed</c>; they are now skipped up front and counted <c>skipped</c>. The OPC UA
/// <c>Byte</c>/<c>SByte</c> names reach this mapper already widened to <see cref="DriverDataType.Int16"/>
/// (<see cref="HistorianDataTypeNames"/>), so they provision as <c>Int2</c>.
/// </para>
/// </remarks>
internal static class HistorianTypeMapper
{
@@ -32,12 +41,14 @@ internal static class HistorianTypeMapper
DriverDataType.Int16 => HistorianDataType.Int2,
DriverDataType.Int32 => HistorianDataType.Int4,
DriverDataType.Int64 => HistorianDataType.Int8,
DriverDataType.UInt16 => HistorianDataType.Uint4, // UInt2 write path deferred upstream → widen
DriverDataType.UInt32 => HistorianDataType.Uint4,
DriverDataType.UInt64 => HistorianDataType.Uint8,
DriverDataType.Float32 => HistorianDataType.Float,
DriverDataType.Float64 => HistorianDataType.Double,
DriverDataType.Int64 or DriverDataType.UInt64 =>
throw new NotSupportedException(
$"DriverDataType.{dataType} is not historized on the Gateway backend " +
"(the historian cannot create Int8/UInt8 tags and no creatable type holds a 64-bit integer losslessly)."),
DriverDataType.String or DriverDataType.DateTime or DriverDataType.Reference =>
throw new NotSupportedException(
$"DriverDataType.{dataType} is not historized in v1 " +
@@ -47,20 +58,18 @@ internal static class HistorianTypeMapper
};
/// <summary>
/// True when <paramref name="dataType"/> is one of the nine historizable numeric types — lets the
/// True when <paramref name="dataType"/> is one of the seven historizable numeric types — lets the
/// provisioning hook skip deferred types without catching <see cref="NotSupportedException"/>.
/// </summary>
/// <param name="dataType">The driver-agnostic data type.</param>
/// <returns>True if the type is one of the nine historizable numeric types; otherwise false.</returns>
/// <returns>True if the type is one of the seven historizable numeric types; otherwise false.</returns>
public static bool IsHistorizable(DriverDataType dataType) => dataType switch
{
DriverDataType.Boolean
or DriverDataType.Int16
or DriverDataType.Int32
or DriverDataType.Int64
or DriverDataType.UInt16
or DriverDataType.UInt32
or DriverDataType.UInt64
or DriverDataType.Float32
or DriverDataType.Float64 => true,
_ => false,