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
@@ -42,7 +42,7 @@ public static class RawTagCsvMapper
// Root TagConfig keys owned by the flag columns — stripped from the export residual so the
// TagConfigJson fallback never duplicates a value already carried by a dedicated column.
private static readonly string[] FlagKeys =
["isHistorized", "historianTagname", "isArray", "arrayLength", "alarm"];
["isHistorized", "historianTagname", "historianReadOnly", "isArray", "arrayLength", "alarm"];
// ---------------------------------------------------------------------------------------- Import
@@ -213,7 +213,8 @@ public static class RawTagCsvMapper
// Historize flags.
var isHistorized = ParseFlag("IsHistorized", Cell("IsHistorized"));
json = TagHistorizeConfig.Set(json, isHistorized, Cell("HistorianTagname"));
json = TagHistorizeConfig.Set(json, isHistorized, Cell("HistorianTagname"),
ParseFlag("HistorianReadOnly", Cell("HistorianReadOnly")));
// Array flags.
var isArray = ParseFlag("IsArray", Cell("IsArray"));
@@ -406,6 +407,7 @@ public static class RawTagCsvMapper
tag.PollGroupId ?? "",
hist.IsHistorized ? "true" : "",
hist.HistorianTagname,
hist.HistorianReadOnly ? "true" : "",
array.IsArray ? "true" : "",
array.ArrayLength?.ToString(CultureInfo.InvariantCulture) ?? "",
alarm.IsAlarm ? alarm.AlarmType : "",
@@ -433,6 +433,7 @@ public static class CsvColumnMap
"PollGroup",
"IsHistorized",
"HistorianTagname",
"HistorianReadOnly",
"IsArray",
"ArrayLength",
"Alarm.AlarmType",
@@ -1,10 +1,15 @@
using ZB.MOM.WW.OtOpcUa.Commons.Types;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// <summary>
/// Pure, driver-agnostic merge helper for the two server-side HistoryRead intent keys at the ROOT of a
/// tag's <c>TagConfig</c> JSON: <c>isHistorized</c> (camelCase bool — omit/false default) and
/// <c>historianTagname</c> (camelCase string override — omit when blank). These map to what the server's
/// <c>AddressSpaceComposer.ExtractTagHistorize</c> reads (see <c>docs/Historian.md</c>).
/// Pure, driver-agnostic merge helper for the server-side HistoryRead intent keys at the ROOT of a
/// tag's <c>TagConfig</c> JSON: <c>isHistorized</c> (camelCase bool — omit/false default),
/// <c>historianTagname</c> (camelCase string override — omit when blank) and <c>historianReadOnly</c>
/// (lmxopcua#899 — camelCase bool, omit/false default: the history is read from an existing historian tag
/// OtOpcUa never provisions or records into). These map to what the server's <see cref="TagConfigIntent"/>
/// reads (see <c>docs/Historian.md</c>); <see cref="Read"/> uses that same parse, so what the AdminUI shows is
/// what the runtime does.
///
/// <para>
/// This is the TagModal-merge seam: the TagModal owns the canonical TagConfig JSON; the driver's typed
@@ -17,10 +22,11 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// </summary>
public static class TagHistorizeConfig
{
/// <summary>The two history-intent values parsed from a TagConfig JSON's root.</summary>
/// <summary>The history-intent values parsed from a TagConfig JSON's root.</summary>
/// <param name="IsHistorized">Whether the server exposes OPC UA HistoryRead over this tag's node.</param>
/// <param name="HistorianTagname">Optional historian tagname override; <c>""</c> means default to the tag's FullName.</param>
public readonly record struct HistorizeState(bool IsHistorized, string HistorianTagname);
/// <param name="HistorianReadOnly">Whether the historian tag is a read-only binding OtOpcUa never provisions or records into.</param>
public readonly record struct HistorizeState(bool IsHistorized, string HistorianTagname, bool HistorianReadOnly = false);
/// <summary>Reads the history-intent keys from a TagConfig JSON, defaulting any absent key
/// (null/blank/malformed input ⇒ <c>(false, "")</c>).</summary>
@@ -31,7 +37,9 @@ public static class TagHistorizeConfig
var o = TagConfigJson.ParseOrNew(json);
return new HistorizeState(
TagConfigJson.GetBool(o, "isHistorized"),
TagConfigJson.GetString(o, "historianTagname") ?? "");
TagConfigJson.GetString(o, "historianTagname") ?? "",
// The runtime's own parse (only a JSON true counts), not a lenient re-read.
TagConfigIntent.Parse(json).HistorianReadOnly);
}
/// <summary>Merges the two history-intent keys into <paramref name="json"/>, preserving every other
@@ -41,13 +49,17 @@ public static class TagHistorizeConfig
/// <param name="json">The tag's existing TagConfig JSON, or null/empty to start from a new object.</param>
/// <param name="isHistorized">Whether the server should expose OPC UA HistoryRead over this tag's node.</param>
/// <param name="historianTagname">Optional historian tagname override; null/blank omits the key.</param>
/// <returns>The merged TagConfig JSON with the two history-intent keys applied.</returns>
public static string Set(string? json, bool isHistorized, string? historianTagname)
/// <param name="historianReadOnly"><see langword="true"/> writes <c>historianReadOnly: true</c>, <see langword="false"/>
/// drops the key (absent ⇒ owned), <see langword="null"/> leaves whatever the JSON already carries untouched.</param>
/// <returns>The merged TagConfig JSON with the history-intent keys applied.</returns>
public static string Set(string? json, bool isHistorized, string? historianTagname, bool? historianReadOnly = null)
{
var o = TagConfigJson.ParseOrNew(json);
TagConfigJson.Set(o, "isHistorized", isHistorized ? true : null);
TagConfigJson.Set(o, "historianTagname",
string.IsNullOrWhiteSpace(historianTagname) ? null : historianTagname.Trim());
if (historianReadOnly is { } readOnly)
TagConfigJson.Set(o, TagConfigIntent.HistorianReadOnlyKey, readOnly ? true : null);
return TagConfigJson.Serialize(o);
}
}
@@ -63,6 +63,18 @@ public sealed class ServerHistorianOptionsValidator : OptionsValidatorBase<Serve
$"ServerHistorian:Backend '{options.Backend}' is not a known historian backend. "
+ $"Allowed values: {string.Join(", ", HistorianBackendRegistry.KnownBackends)}.");
builder.RequireThat(
ServerHistorianOptions.TryParseProvisionPolicy(options.ProvisionTags, out var provisionPolicy),
$"ServerHistorian:ProvisionTags '{options.ProvisionTags}' is not a known provisioning policy. "
+ $"Allowed values: {string.Join(", ", Enum.GetNames<HistorianProvisionPolicy>())}.");
// CreateOnly needs an existence lookup only the Gateway provisioner makes; the SQL backends' stores are
// OtOpcUa's own tables (lmxopcua#899).
builder.RequireThat(
!options.Enabled || provisionPolicy != HistorianProvisionPolicy.CreateOnly || options.IsGatewayBackend,
$"ServerHistorian:ProvisionTags=CreateOnly is supported only by the Gateway backend (Backend is '{options.Backend}'). "
+ "Use CreateOrUpdate or Off, or opt individual tags out with historianReadOnly.");
var alarmHistorianEnabled = _configuration.GetValue<bool>("AlarmHistorian:Enabled");
var consumed = options.Enabled || alarmHistorianEnabled;
@@ -199,7 +199,13 @@ public sealed class AddressSpaceApplier
// notifier wiring, and an alarm-bearing delta never reaches here — the classifier routes it
// to Rebuild). So a delta whose ONLY difference is the referencing set needs NOTHING said to
// the sink: compare the two sides with the referencing set copied across, and skip on equal.
if ((d.Previous with { ReferencingEquipmentPaths = d.Current.ReferencingEquipmentPaths }).Equals(d.Current))
// HistorianReadOnly (lmxopcua#899) is node-inert the same way: it only steers the provisioning
// hook and the recorder feed below, never the node's attributes.
if ((d.Previous with
{
ReferencingEquipmentPaths = d.Current.ReferencingEquipmentPaths,
HistorianReadOnly = d.Current.HistorianReadOnly,
}).Equals(d.Current))
{
continue;
}
@@ -621,6 +627,15 @@ public sealed class AddressSpaceApplier
private static string ResolveHistorianName(RawTagPlan tag) =>
string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.NodeId : tag.HistorianTagname;
/// <summary>
/// A historized value tag (not a native-alarm condition) whose historian tag OtOpcUa OWNS — i.e. not a
/// read-only binding (<see cref="RawTagPlan.HistorianReadOnly"/>, lmxopcua#899). Only these are provisioned
/// (<c>EnsureTags</c>) and fed to the continuous-historization recorder; a read-only binding still serves
/// HistoryRead from the existing historian tag, which OtOpcUa never creates, modifies or writes.
/// </summary>
private static bool IsOwnedHistorizedValue(RawTagPlan tag) =>
tag.IsHistorized && tag.Alarm is null && !tag.HistorianReadOnly;
/// <summary>
/// Auto-provision the historian for the added (and newly-historized / renamed-historian) raw tags. Runs on the OPC UA
/// publish actor's pinned thread, so the synchronous portion is kept to building the request
@@ -639,7 +654,7 @@ public sealed class AddressSpaceApplier
// and skip them.
foreach (var tag in plan.AddedRawTags)
{
if (!tag.IsHistorized || tag.Alarm is not null) continue;
if (!IsOwnedHistorizedValue(tag)) continue;
AddRequest(tag);
}
@@ -647,12 +662,14 @@ public sealed class AddressSpaceApplier
// EXISTING raw tag never appears in AddedRawTags, so EnsureTags never fired for it. EnsureTags is
// an upsert, so re-ensuring is safe; an unchanged pair is a no-op. Only raw deltas are used - a
// UNS mirror delta must not double-provision the same historian tag.
// A read-only binding (lmxopcua#899) is never provisioned; flipping read-only OFF counts as becoming
// owned, so it provisions like a historization flip.
foreach (var d in plan.ChangedRawTags)
{
var cur = d.Current;
if (!cur.IsHistorized || cur.Alarm is not null) continue;
if (!IsOwnedHistorizedValue(cur)) continue;
var prev = d.Previous;
var wasProvisioned = prev.IsHistorized && prev.Alarm is null;
var wasProvisioned = IsOwnedHistorizedValue(prev);
if (wasProvisioned
&& ResolveHistorianName(prev) == ResolveHistorianName(cur)
&& string.Equals(prev.DataType, cur.DataType, StringComparison.OrdinalIgnoreCase))
@@ -662,12 +679,15 @@ public sealed class AddressSpaceApplier
void AddRequest(RawTagPlan tag)
{
// An unparseable data type is skipped (logged at Debug) rather than faulting the hook.
if (!Enum.TryParse<DriverDataType>(tag.DataType, ignoreCase: true, out var dataType))
// Tag.DataType is an OPC UA built-in name (Float, Double, Byte, …); resolve it with the shared
// map (lmxopcua#898 — a bare Enum.TryParse<DriverDataType> silently skipped Float/Double/Byte/SByte).
// A type with no historian mapping (Guid, ByteString, …) is skipped rather than faulting the hook,
// at Information so a historized tag that never provisions is visible without Debug logging.
if (!HistorianDataTypeNames.TryResolve(tag.DataType, out var dataType))
{
_logger.LogDebug(
"AddressSpaceApplier: skipping historian provisioning for a historized tag whose data type '{DataType}' is not a DriverDataType",
tag.DataType);
_logger.LogInformation(
"AddressSpaceApplier: not provisioning historized tag {Node}: data type '{DataType}' has no historian mapping",
tag.NodeId, tag.DataType);
return;
}
@@ -786,12 +806,15 @@ public sealed class AddressSpaceApplier
/// <c>HistorianName</c> = the value the EnsureTags hook / materialiser write under (a non-alarm
/// historized value variable's <c>HistorianTagname</c> override, else its <c>FullName</c>). The
/// two diverge ONLY when an override is set. Returns <c>null</c> when the tag is not a historized
/// value variable (not historized, or a native-alarm condition node).
/// value variable OtOpcUa records (not historized, a native-alarm condition node, or a read-only binding,
/// lmxopcua#899).
/// </summary>
/// <param name="tag">The raw tag to resolve a historized ref for.</param>
/// <returns>The resolved historized ref pair, or <c>null</c> when the tag is not a historized value variable.</returns>
private static HistorizedTagRef? HistorizedRef(RawTagPlan tag) =>
tag.IsHistorized && tag.Alarm is null
// lmxopcua#899: a read-only binding is never recorded into — the recorder's WriteLiveValues would write
// into a historian tag OtOpcUa does not own. A flip of the flag is a ref appearing/disappearing below.
IsOwnedHistorizedValue(tag)
// v3: the RawPath (== NodeId) is BOTH the mux ref (the driver fans values by RawPath) and the
// default historian tagname; an override diverges only the historian name.
? new HistorizedTagRef(
@@ -143,7 +143,7 @@ public static class AddressSpaceChangeClassifier
}).Equals(d.Current);
// v3 surgical raw-tag eligibility: a NON-alarm raw tag whose only differences are the in-place-updatable
// attributes (Writable / IsHistorized / HistorianTagname / DataType / IsArray / ArrayLength / ArrayWritable)
// attributes (Writable / IsHistorized / HistorianTagname / HistorianReadOnly / DataType / IsArray / ArrayLength / ArrayWritable)
// and/or its ReferencingEquipmentPaths. ArrayWritable only feeds the node's writability — the
// same AccessLevel + OnWriteValue swap Writable already drives in place — so it must be listed here or
// every array-tag edit that flips it would fall through to a full rebuild. The referencing set is node-inert for a VALUE tag — it only drives native-alarm
@@ -160,6 +160,9 @@ public static class AddressSpaceChangeClassifier
Writable = d.Current.Writable,
IsHistorized = d.Current.IsHistorized,
HistorianTagname = d.Current.HistorianTagname,
// lmxopcua#899: node-inert — the node serves HistoryRead the same either way; only the
// provisioning hook and the recorder feed read it.
HistorianReadOnly = d.Current.HistorianReadOnly,
DataType = d.Current.DataType,
IsArray = d.Current.IsArray,
ArrayLength = d.Current.ArrayLength,
@@ -155,6 +155,11 @@ public sealed record RawContainerNode(
/// every other tag. AVEVA stores the alarm's history under that reference's object, so the node manager adds the
/// object to the event-history sources of every folder the condition fires on (Gitea #886). It is not in the
/// surgical whitelist either: an alarm-bearing tag's delta rebuilds anyway.
/// <see cref="HistorianReadOnly"/> (lmxopcua#899, <c>TagConfig.historianReadOnly</c>) marks a historized tag whose
/// history is read from an existing historian tag OtOpcUa does not own: the node still serves HistoryRead, but the
/// applier neither provisions the historian tag (<c>EnsureTags</c>) nor feeds it to the continuous-historization
/// recorder. It is node-inert, so it is in the surgical whitelist, and a UNS reference node does not mirror it
/// (UNS nodes neither provision nor record).
/// <para><b>ADDING A FIELD IS A FOUR-PLACE CONTRACT — the hand-written equality below does NOT close
/// over new members.</b> Because <see cref="ReferencingEquipmentPaths"/> forces a custom
/// <c>Equals</c>/<c>GetHashCode</c>, the compiler's positional equality is suppressed, so a field added
@@ -184,7 +189,8 @@ public sealed record RawTagPlan(
AddressSpaceRealm Realm = AddressSpaceRealm.Raw,
bool ArrayWritable = false,
bool ScalarRefusesArrayValue = false,
string? NativeAlarmReference = null)
string? NativeAlarmReference = null,
bool HistorianReadOnly = false)
{
/// <inheritdoc />
// Structural equality: the auto-generated record equality compares ReferencingEquipmentPaths (an
@@ -209,6 +215,7 @@ public sealed record RawTagPlan(
ArrayWritable == other.ArrayWritable &&
ScalarRefusesArrayValue == other.ScalarRefusesArrayValue &&
NativeAlarmReference == other.NativeAlarmReference &&
HistorianReadOnly == other.HistorianReadOnly &&
ReferencingEquipmentPaths.SequenceEqual(other.ReferencingEquipmentPaths, StringComparer.Ordinal);
/// <inheritdoc />
@@ -219,6 +226,7 @@ public sealed record RawTagPlan(
hash.Add(Name); hash.Add(DataType); hash.Add(Writable); hash.Add(Alarm);
hash.Add(IsHistorized); hash.Add(HistorianTagname); hash.Add(IsArray); hash.Add(ArrayLength);
hash.Add(Realm); hash.Add(ArrayWritable); hash.Add(ScalarRefusesArrayValue); hash.Add(NativeAlarmReference);
hash.Add(HistorianReadOnly);
foreach (var p in ReferencingEquipmentPaths) hash.Add(p, StringComparer.Ordinal);
return hash.ToHashCode();
}
@@ -847,7 +855,8 @@ public static class AddressSpaceComposer
ArrayLength: intent.ArrayLength,
ArrayWritable: arrayWritable,
ScalarRefusesArrayValue: scalarRefusesArray,
NativeAlarmReference: nativeAlarmReference));
NativeAlarmReference: nativeAlarmReference,
HistorianReadOnly: intent.HistorianReadOnly));
}
containers.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId));
@@ -106,6 +106,41 @@ public sealed class ServerHistorianOptions
/// </summary>
public bool GatewayAtTimeFromRaw { get; init; } = true;
/// <summary>
/// What the tag provisioning hook may do to the historian's tags (lmxopcua#899): <c>CreateOrUpdate</c> (default —
/// the behaviour before #899: <c>EnsureTags</c> is an upsert that creates a missing tag and re-types /
/// re-describes an existing one), <c>CreateOnly</c> (<c>Gateway</c> backend only: each tag is looked up first and
/// an EXISTING one is left exactly as it is; only missing tags are created) or <c>Off</c> (no provisioning at all;
/// HistoryRead and continuous historization are unaffected). Case-insensitive; an unknown value fails host start.
/// A tag's own <c>historianReadOnly: true</c> opts it out of provisioning (and recording) under every policy.
/// </summary>
public string ProvisionTags { get; init; } = nameof(HistorianProvisionPolicy.CreateOrUpdate);
/// <summary>
/// <see cref="ProvisionTags"/> parsed; an unknown value reads as <see cref="HistorianProvisionPolicy.CreateOrUpdate"/>
/// here (the options validator fails host start on it first).
/// </summary>
public HistorianProvisionPolicy ProvisionPolicy =>
TryParseProvisionPolicy(ProvisionTags, out var policy) ? policy : HistorianProvisionPolicy.CreateOrUpdate;
/// <summary>Parses a <see cref="ProvisionTags"/> value (case-insensitive, trimmed; blank ⇒ the default).</summary>
/// <param name="value">The configured value.</param>
/// <param name="policy">The parsed policy.</param>
/// <returns><see langword="true"/> when the value names a policy (or is blank).</returns>
public static bool TryParseProvisionPolicy(string? value, out HistorianProvisionPolicy policy)
{
policy = HistorianProvisionPolicy.CreateOrUpdate;
var v = value?.Trim();
if (string.IsNullOrEmpty(v)) return true;
foreach (var candidate in Enum.GetValues<HistorianProvisionPolicy>())
{
if (!string.Equals(v, candidate.ToString(), StringComparison.OrdinalIgnoreCase)) continue;
policy = candidate;
return true;
}
return false;
}
/// <summary>
/// <c>Gateway</c> backend only: the <c>ServerHistorian:Gateway</c> sub-section — knobs that exist because of the
/// HistorianGateway's wire contract (see <see cref="GatewayHistorianSettings"/>). Ignored by the other backends.
@@ -303,3 +338,16 @@ public sealed class GatewayHistorianSettings
/// </summary>
public TimeSpan ProbeInterval { get; init; } = TimeSpan.FromSeconds(30);
}
/// <summary>What the historian tag provisioning hook may do (<see cref="ServerHistorianOptions.ProvisionTags"/>, lmxopcua#899).</summary>
public enum HistorianProvisionPolicy
{
/// <summary>Create a missing tag and update (re-type, re-describe) an existing one — the upsert, and the default.</summary>
CreateOrUpdate,
/// <summary>Create a missing tag; never touch an existing one (<c>Gateway</c> backend only).</summary>
CreateOnly,
/// <summary>No provisioning.</summary>
Off,
}
@@ -283,6 +283,8 @@ public static class ServiceCollectionExtensions
{
var opts = configuration.GetSection(ServerHistorianOptions.SectionName).Get<ServerHistorianOptions>();
if (opts is not { Enabled: true }) return services; // leave the Null default from AddOtOpcUaRuntime
// ServerHistorian:ProvisionTags=Off (lmxopcua#899): keep the Null default — reads and recording are unaffected.
if (opts.ProvisionPolicy == HistorianProvisionPolicy.Off) return services;
// Last-registration-wins over the TryAddSingleton Null default seeded by AddOtOpcUaRuntime.
services.AddSingleton<IHistorianProvisioning>(sp => provisioningFactory(opts, sp));