fix(historian): read-only historian bindings + ProvisionTags policy; provision OPC UA type names (#899, #898)
v2-ci / unit-tests (push) Has been cancelled
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:
+2
-1
@@ -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
|
||||
|
||||
+16
-7
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user