Merge #600.1/#603: provision changed-to-historized tags; retire dormant VirtualTagEngine; cache ScriptSandbox references; one LocalDb replication predicate for registration and drain gate
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -347,7 +347,7 @@ public sealed class ScriptAnalysisService
|
||||
: $"**Tag path** `{Code(info.Path)}`\n\n{info.Kind} · Type **{info.DataType}**"
|
||||
+ (info.DriverInstanceId is null ? "" : $" · Driver `{Code(info.DriverInstanceId)}`");
|
||||
// Truthfulness: ctx.SetVirtualTag(...) is a no-op in the live single-tag evaluator
|
||||
// (RoslynVirtualTagEvaluator drops cross-tag writes; the cascade VirtualTagEngine is dormant).
|
||||
// (RoslynVirtualTagEvaluator drops cross-tag writes).
|
||||
if (isSetVirtualTag)
|
||||
tagMd += "\n\n⚠ Cross-tag `SetVirtualTag` writes are currently dropped in single-tag mode " +
|
||||
"(the cascade engine is not wired into the host); this write will not take effect at runtime.";
|
||||
|
||||
@@ -40,7 +40,7 @@ public static class LocalDbRegistration
|
||||
public const string LocalDbSectionPath = "LocalDb";
|
||||
|
||||
/// <summary>Configuration section holding <c>ReplicationOptions</c>.</summary>
|
||||
public const string ReplicationSectionPath = "LocalDb:Replication";
|
||||
public const string ReplicationSectionPath = Runtime.LocalDbReplicationPolicy.ReplicationSectionPath;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration key carrying the dedicated h2c sync listener's port. Zero or absent means
|
||||
|
||||
@@ -170,12 +170,9 @@ public static class LocalDbSetup
|
||||
/// <returns><c>true</c> when this node is configured to replicate.</returns>
|
||||
public static bool ReplicationIsConfigured(IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
var section = configuration.GetSection(LocalDbRegistration.ReplicationSectionPath);
|
||||
|
||||
return !string.IsNullOrWhiteSpace(section["PeerAddress"])
|
||||
|| !string.IsNullOrWhiteSpace(section["ApiKey"]);
|
||||
// Shared with the alarm-historian drain gate (#603): one predicate, so the nodes that capture
|
||||
// changes and the nodes that stand down for a peer can never disagree.
|
||||
return Runtime.LocalDbReplicationPolicy.IsConfigured(configuration);
|
||||
}
|
||||
|
||||
/// <summary>Opts every replicated table into change capture, baselining what is already there.</summary>
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace ZB.MOM.WW.OtOpcUa.Host.Engines;
|
||||
///
|
||||
/// Single-tag mode: cross-tag <c>ctx.SetVirtualTag</c> writes are dropped (logged) because
|
||||
/// fan-out between actors is owned by <c>DependencyMuxActor</c>, not by the eval engine.
|
||||
/// Cycle detection + cascade ordering live in <see cref="VirtualTagEngine"/>; this adapter
|
||||
/// Cycle detection lives in the deploy gate (<c>DependencyGraph</c>); this adapter
|
||||
/// stays single-tag scoped to keep <see cref="VirtualTagActor"/>'s message loop simple.
|
||||
///
|
||||
/// <para>
|
||||
|
||||
@@ -579,7 +579,14 @@ public sealed class AddressSpaceApplier
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Auto-provision the historian for the added historized equipment tags. Runs on the OPC UA
|
||||
/// Resolve the historian name EXACTLY as MaterialiseRawSubtree does: a null/blank override falls
|
||||
/// back to the RawPath (== NodeId).
|
||||
/// </summary>
|
||||
private static string ResolveHistorianName(RawTagPlan tag) =>
|
||||
string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.NodeId : tag.HistorianTagname;
|
||||
|
||||
/// <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
|
||||
/// list only and the gateway round-trip is dispatched fire-and-forget. The whole hook is wrapped
|
||||
/// in try/catch — a synchronously-throwing provisioner (or any request-building fault) is
|
||||
@@ -597,22 +604,39 @@ public sealed class AddressSpaceApplier
|
||||
foreach (var tag in plan.AddedRawTags)
|
||||
{
|
||||
if (!tag.IsHistorized || tag.Alarm is not null) continue;
|
||||
AddRequest(tag);
|
||||
}
|
||||
|
||||
// Parse the driver-agnostic data type from the tag's DataType string. An unparseable
|
||||
// type is skipped (logged at Debug) rather than faulting the hook.
|
||||
// #600.1: a historization flip (false->true) or a historian-name / data-type change on an
|
||||
// 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.
|
||||
foreach (var d in plan.ChangedRawTags)
|
||||
{
|
||||
var cur = d.Current;
|
||||
if (!cur.IsHistorized || cur.Alarm is not null) continue;
|
||||
var prev = d.Previous;
|
||||
var wasProvisioned = prev.IsHistorized && prev.Alarm is null;
|
||||
if (wasProvisioned
|
||||
&& ResolveHistorianName(prev) == ResolveHistorianName(cur)
|
||||
&& string.Equals(prev.DataType, cur.DataType, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
AddRequest(cur);
|
||||
}
|
||||
|
||||
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))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"AddressSpaceApplier: skipping historian provisioning for an added historized tag whose data type '{DataType}' is not a DriverDataType",
|
||||
"AddressSpaceApplier: skipping historian provisioning for a historized tag whose data type '{DataType}' is not a DriverDataType",
|
||||
tag.DataType);
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve the historian name EXACTLY as MaterialiseRawSubtree does: a null/blank override
|
||||
// falls back to the RawPath (== NodeId).
|
||||
var historianName = string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.NodeId : tag.HistorianTagname;
|
||||
(requests ??= new List<HistorianTagProvisionRequest>()).Add(
|
||||
new HistorianTagProvisionRequest(historianName, dataType, EngineeringUnit: null, Description: tag.Name));
|
||||
new HistorianTagProvisionRequest(ResolveHistorianName(tag), dataType, EngineeringUnit: null, Description: tag.Name));
|
||||
}
|
||||
|
||||
if (requests is null) return;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime;
|
||||
|
||||
/// <summary>
|
||||
/// The ONE definition of "this node's LocalDb is replicated to a peer" (#603). Two consumers must agree
|
||||
/// on it, and used to carry separate predicates that diverged: the Host's table-registration step
|
||||
/// (<c>LocalDbSetup.ReplicationIsConfigured</c> - which nodes capture changes) and the alarm-historian
|
||||
/// drain gate (<c>AddAlarmHistorian</c> - which nodes stand down in favour of a peer). Lives in Runtime
|
||||
/// because Host references Runtime and not the reverse.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Predicate: <c>LocalDb:Replication:PeerAddress</c> OR <c>LocalDb:Replication:ApiKey</c> is
|
||||
/// non-blank.</b> The OR is required: only the dialling half of a pair sets <c>PeerAddress</c>, while
|
||||
/// the passive half carries the shared <c>ApiKey</c> (plus a listen port) and nothing else.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Why the drain gate takes the same predicate (it previously used PeerAddress OR
|
||||
/// <c>SyncListenPort</c>).</b> The gate's question is "does a peer hold the same rows?". The passive
|
||||
/// sync endpoint is fail-closed on the key, so a node with a listen port but no <c>ApiKey</c> rejects
|
||||
/// every inbound stream and shares its queue with nobody - gating it would park its alarm events
|
||||
/// behind a Primary that cannot deliver them. Conversely an <c>ApiKey</c>-only passive node IS
|
||||
/// registered and does receive its peer's rows, so it must be gated or both halves drain. The port
|
||||
/// is not evidence of a peer; the key is.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class LocalDbReplicationPolicy
|
||||
{
|
||||
/// <summary>Configuration section holding the LocalDb replication options.</summary>
|
||||
public const string ReplicationSectionPath = "LocalDb:Replication";
|
||||
|
||||
/// <summary>Whether this node's LocalDb replicates to a pair peer.</summary>
|
||||
/// <param name="configuration">The application configuration.</param>
|
||||
/// <returns><c>true</c> when <c>PeerAddress</c> or <c>ApiKey</c> is set under the replication section.</returns>
|
||||
public static bool IsConfigured(IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
var section = configuration.GetSection(ReplicationSectionPath);
|
||||
return !string.IsNullOrWhiteSpace(section["PeerAddress"])
|
||||
|| !string.IsNullOrWhiteSpace(section["ApiKey"]);
|
||||
}
|
||||
}
|
||||
@@ -177,12 +177,11 @@ public static class ServiceCollectionExtensions
|
||||
// possibly deliver its events. Scoping the gate to "is my queue actually shared?" is what
|
||||
// keeps the two scopes from disagreeing.
|
||||
// BOTH halves of a pair share the queue, but only one of them dials: the initiator sets
|
||||
// Replication:PeerAddress, its partner only sets SyncListenPort and waits. Testing the dial
|
||||
// side alone would leave the listening half permanently ungated — one drainer per pair by
|
||||
// accident rather than by role, and the wrong one whenever the roles swap.
|
||||
var replicated =
|
||||
!string.IsNullOrWhiteSpace(configuration["LocalDb:Replication:PeerAddress"])
|
||||
|| !string.IsNullOrWhiteSpace(configuration["LocalDb:SyncListenPort"]);
|
||||
// Replication:PeerAddress, its partner carries the shared ApiKey and waits. The predicate is
|
||||
// LocalDbReplicationPolicy - the same one LocalDbSetup uses to decide which nodes capture
|
||||
// changes at all. It deliberately does NOT count SyncListenPort: the passive endpoint is
|
||||
// fail-closed on the key, so a port with no ApiKey shares nothing and must keep draining.
|
||||
var replicated = LocalDbReplicationPolicy.IsConfigured(configuration);
|
||||
|
||||
if (!replicated)
|
||||
{
|
||||
|
||||
@@ -366,7 +366,7 @@ public sealed class VirtualTagActor : ReceiveActor
|
||||
_virtualTagId, _hasLastValue ? _lastValue : null, timestampUtc, CorrelationId.NewId(), OpcUaStatus.BadInternalError));
|
||||
}
|
||||
|
||||
/// <summary>Inputs-ready gate (mirrors the dormant <c>VirtualTagEngine.AreInputsReady</c>): true once
|
||||
/// <summary>Inputs-ready gate (the retired cascade engine's rule): true once
|
||||
/// every declared dependency ref has been seen at least once. Called once per failure — the ref list
|
||||
/// is small, so no caching. A vacuously-true empty ref list means a no-dependency script degrades on
|
||||
/// its first failure.</summary>
|
||||
|
||||
Reference in New Issue
Block a user