docs(xmldoc): fill missing XML docs + strip tracking-ID comments across src
v2-ci / build (push) Failing after 41s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
v2-ci / build (push) Failing after 41s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
Adds <summary>/<param>/<returns>/<inheritdoc> where missing and removes project bookkeeping IDs (task/tracking refs) from shipped code comments, so the docs read cleanly and CommentChecker is quiet except for known false positives (PLC/protocol terms, event/IEqualityComparer inheritdoc). Doc/comment-only; no logic changed; solution builds clean.
This commit is contained in:
@@ -43,20 +43,16 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
|
||||
_subscriber = system.ActorOf(Props.Create(() => new SubscriberActor(this)), "clusterroleinfo-subscriber");
|
||||
}
|
||||
|
||||
/// <summary>Gets the local cluster node identifier.</summary>
|
||||
/// <inheritdoc />
|
||||
public CommonsNodeId LocalNode => _localNode;
|
||||
|
||||
/// <summary>Gets the set of roles assigned to the local node.</summary>
|
||||
/// <inheritdoc />
|
||||
public IReadOnlySet<string> LocalRoles => _localRoles;
|
||||
|
||||
/// <summary>Checks if the local node has a specific role.</summary>
|
||||
/// <param name="role">The role name to check.</param>
|
||||
/// <returns>True if the local node has the specified role; otherwise false.</returns>
|
||||
/// <inheritdoc />
|
||||
public bool HasRole(string role) => _localRoles.Contains(role);
|
||||
|
||||
/// <summary>Gets all cluster members that have a specific role.</summary>
|
||||
/// <param name="role">The role name.</param>
|
||||
/// <returns>A read-only list of node IDs with the specified role.</returns>
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<CommonsNodeId> MembersWithRole(string role)
|
||||
{
|
||||
lock (_lock)
|
||||
@@ -68,9 +64,7 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Gets the current leader node for a specific role.</summary>
|
||||
/// <param name="role">The role name.</param>
|
||||
/// <returns>The node ID of the current role leader, or null if no leader is elected.</returns>
|
||||
/// <inheritdoc />
|
||||
public CommonsNodeId? RoleLeader(string role)
|
||||
{
|
||||
lock (_lock)
|
||||
|
||||
@@ -9,6 +9,7 @@ public static class RoleParser
|
||||
|
||||
/// <summary>Parses a comma-separated string of role names into a validated array.</summary>
|
||||
/// <param name="raw">The raw role string to parse.</param>
|
||||
/// <returns>The distinct, lower-cased, validated role names.</returns>
|
||||
public static string[] Parse(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return Array.Empty<string>();
|
||||
|
||||
@@ -20,6 +20,7 @@ public static class ServiceCollectionExtensions
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to configure.</param>
|
||||
/// <param name="configuration">The application configuration containing cluster options.</param>
|
||||
/// <returns>The same service collection, for chaining.</returns>
|
||||
public static IServiceCollection AddOtOpcUaCluster(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddOptions<AkkaClusterOptions>()
|
||||
@@ -47,6 +48,7 @@ public static class ServiceCollectionExtensions
|
||||
/// </summary>
|
||||
/// <param name="builder">The Akka configuration builder to configure.</param>
|
||||
/// <param name="serviceProvider">The service provider for resolving cluster options.</param>
|
||||
/// <returns>The same builder, for chaining.</returns>
|
||||
public static AkkaConfigurationBuilder WithOtOpcUaClusterBootstrap(
|
||||
this AkkaConfigurationBuilder builder,
|
||||
IServiceProvider serviceProvider)
|
||||
|
||||
@@ -16,14 +16,22 @@ public interface IBrowseSession : IAsyncDisposable
|
||||
DateTime LastUsedUtc { get; }
|
||||
|
||||
/// <summary>Returns the top-level browse nodes.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>The top-level browse nodes.</returns>
|
||||
Task<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Returns the direct children of the node identified by
|
||||
/// <paramref name="nodeId"/>.</summary>
|
||||
/// <param name="nodeId">The node whose direct children are returned.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>The direct children of the node.</returns>
|
||||
Task<IReadOnlyList<BrowseNode>> ExpandAsync(string nodeId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Returns the attributes of the node identified by <paramref name="nodeId"/>.
|
||||
/// Empty for drivers whose tree is uniform (OPC UA Client). Galaxy uses this to populate
|
||||
/// the attribute side-panel after the user selects an object.</summary>
|
||||
/// <param name="nodeId">The node whose attributes are returned.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>The attributes of the node.</returns>
|
||||
Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -15,5 +15,6 @@ public interface IDriverBrowser
|
||||
/// <param name="configJson">Driver options serialized as JSON; same shape the runtime
|
||||
/// driver would consume.</param>
|
||||
/// <param name="cancellationToken">Cancellation for the connect phase only.</param>
|
||||
/// <returns>The opened browse session.</returns>
|
||||
Task<IBrowseSession> OpenAsync(string configJson, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -43,11 +43,7 @@ public sealed class NullVirtualTagEvaluator : IVirtualTagEvaluator
|
||||
{
|
||||
public static readonly NullVirtualTagEvaluator Instance = new();
|
||||
private NullVirtualTagEvaluator() { }
|
||||
/// <summary>Returns <see cref="VirtualTagEvalResult.NoChange"/> for every evaluation.</summary>
|
||||
/// <param name="virtualTagId">The virtual tag identifier (ignored).</param>
|
||||
/// <param name="expression">The expression string (ignored).</param>
|
||||
/// <param name="dependencies">The variable dependencies (ignored).</param>
|
||||
/// <returns>Always returns <see cref="VirtualTagEvalResult.NoChange"/>.</returns>
|
||||
/// <inheritdoc />
|
||||
public VirtualTagEvalResult Evaluate(string virtualTagId, string expression, IReadOnlyDictionary<string, object?> dependencies)
|
||||
=> VirtualTagEvalResult.NoChange;
|
||||
}
|
||||
|
||||
@@ -53,5 +53,6 @@ public interface IAdminOperationsClient
|
||||
/// <typeparam name="T">Expected reply type.</typeparam>
|
||||
/// <param name="message">The message to send.</param>
|
||||
/// <param name="ct">Cancellation token (caller-controlled timeout).</param>
|
||||
/// <returns>The reply of type <typeparamref name="T"/> received from the admin singleton.</returns>
|
||||
Task<T> AskAsync<T>(object message, CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -11,5 +11,6 @@ public interface IFleetDiagnosticsClient
|
||||
/// <summary>Gets diagnostics for the specified node.</summary>
|
||||
/// <param name="nodeId">The node ID to retrieve diagnostics for.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>The diagnostics snapshot for the requested node.</returns>
|
||||
Task<NodeDiagnosticsSnapshot> GetDiagnosticsAsync(NodeId nodeId, CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ public static class OtOpcUaTelemetry
|
||||
/// null when no listener is attached so the call site stays cheap on undecorated builds.
|
||||
/// </summary>
|
||||
/// <param name="deploymentId">The deployment identifier to tag the span with.</param>
|
||||
/// <returns>The started activity, or <c>null</c> when no listener is attached.</returns>
|
||||
public static Activity? StartDeployApplySpan(string deploymentId)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("otopcua.deploy.apply", ActivityKind.Internal);
|
||||
@@ -77,6 +78,7 @@ public static class OtOpcUaTelemetry
|
||||
}
|
||||
|
||||
/// <summary>Span wrapping a full OPC UA address-space rebuild (AddressSpace plan → apply).</summary>
|
||||
/// <returns>The started activity, or <c>null</c> when no listener is attached.</returns>
|
||||
public static Activity? StartAddressSpaceRebuildSpan()
|
||||
=> ActivitySource.StartActivity("otopcua.opcua.address_space_rebuild", ActivityKind.Internal);
|
||||
}
|
||||
|
||||
@@ -22,85 +22,49 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
|
||||
public void SetSink(IOpcUaAddressSpaceSink? sink) =>
|
||||
_inner = sink ?? NullOpcUaAddressSpaceSink.Instance;
|
||||
|
||||
/// <summary>Writes a value to the OPC UA address space through the inner sink.</summary>
|
||||
/// <param name="nodeId">The node ID of the variable.</param>
|
||||
/// <param name="value">The value to write.</param>
|
||||
/// <param name="quality">The OPC UA quality value.</param>
|
||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||
/// <inheritdoc />
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
|
||||
=> _inner.WriteValue(nodeId, value, quality, sourceTimestampUtc);
|
||||
|
||||
/// <summary>Writes a full alarm-condition state through the inner sink.</summary>
|
||||
/// <param name="alarmNodeId">The node ID of the alarm condition.</param>
|
||||
/// <param name="state">The full condition state to project onto the node.</param>
|
||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||
/// <inheritdoc />
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc)
|
||||
=> _inner.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc);
|
||||
|
||||
/// <summary>Materialises a real Part 9 alarm-condition node through the inner sink.</summary>
|
||||
/// <param name="alarmNodeId">The alarm node ID (== ScriptedAlarmId).</param>
|
||||
/// <param name="equipmentNodeId">The equipment folder node ID the condition parents under.</param>
|
||||
/// <param name="displayName">The human-readable condition name.</param>
|
||||
/// <param name="alarmType">The domain alarm type.</param>
|
||||
/// <param name="severity">The domain severity.</param>
|
||||
/// <param name="isNative">True for a driver-fed (native) equipment-tag alarm; false (default) for a scripted alarm.</param>
|
||||
/// <inheritdoc />
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false)
|
||||
=> _inner.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative);
|
||||
|
||||
/// <summary>Ensures a folder exists in the address space through the inner sink.</summary>
|
||||
/// <param name="folderNodeId">The node ID of the folder.</param>
|
||||
/// <param name="parentNodeId">The node ID of the parent folder, or null for root.</param>
|
||||
/// <param name="displayName">The display name of the folder.</param>
|
||||
/// <inheritdoc />
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
||||
=> _inner.EnsureFolder(folderNodeId, parentNodeId, displayName);
|
||||
|
||||
/// <summary>Ensures a variable exists in the address space through the inner sink.</summary>
|
||||
/// <param name="variableNodeId">The node ID of the variable.</param>
|
||||
/// <param name="parentFolderNodeId">The node ID of the parent folder, or null for root.</param>
|
||||
/// <param name="displayName">The display name of the variable.</param>
|
||||
/// <param name="dataType">The OPC UA data type of the variable.</param>
|
||||
/// <param name="writable">When true the node is created read/write; otherwise read-only.</param>
|
||||
/// <param name="historianTagname">null ⇒ not historized; non-null ⇒ create Historizing with the
|
||||
/// HistoryRead access bit and register the historian tagname.</param>
|
||||
/// <param name="isArray">When true the node is created as a 1-D array; when false (default) scalar.</param>
|
||||
/// <param name="arrayLength">The declared length of the 1-D array when <paramref name="isArray"/> is true.</param>
|
||||
/// <inheritdoc />
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
|
||||
=> _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength);
|
||||
|
||||
/// <summary>Rebuilds the address space through the inner sink.</summary>
|
||||
/// <inheritdoc />
|
||||
public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
|
||||
|
||||
/// <summary>Announces a runtime NodeAdded model-change (discovered-node injection) through the inner sink.</summary>
|
||||
/// <param name="affectedNodeId">The node under which discovered nodes were added.</param>
|
||||
/// <inheritdoc />
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId) => _inner.RaiseNodesAddedModelChange(affectedNodeId);
|
||||
|
||||
/// <summary>Forwards an in-place tag-attribute update (F10b) to the inner sink when it supports the
|
||||
/// surgical capability. Returns false otherwise — before the real <c>SdkAddressSpaceSink</c> is
|
||||
/// swapped in (inner is still the null sink), or any inner sink that isn't surgical — so the caller
|
||||
/// (AddressSpaceApplier) falls back to a full rebuild. Without this forward the surgical optimization is
|
||||
/// inert on every driver-role host, because actors inject THIS wrapper, not the inner sink. ALL six args
|
||||
/// (including the FB-7 DataType/array-shape ones) MUST be forwarded — a partial forward silently drops the
|
||||
/// shape update on every driver-role host.</summary>
|
||||
/// <param name="variableNodeId">The node ID of the variable to update in place.</param>
|
||||
/// <param name="writable">Whether the node should be read/write.</param>
|
||||
/// <param name="historianTagname">null ⇒ not historized; non-null ⇒ Historizing + historian binding.</param>
|
||||
/// <param name="dataType">The OPC UA built-in data type name to apply in place.</param>
|
||||
/// <param name="isArray">When true the node becomes a 1-D array; when false scalar.</param>
|
||||
/// <param name="arrayLength">The declared length of the 1-D array when <paramref name="isArray"/> is true.</param>
|
||||
/// <returns>True when the inner sink applied the update; false when it lacks the capability or the node is missing.</returns>
|
||||
/// <inheritdoc />
|
||||
// Forwards to the inner sink when it supports the surgical capability; returns false otherwise —
|
||||
// before the real SdkAddressSpaceSink is swapped in (inner is still the null sink), or any inner
|
||||
// sink that isn't surgical — so the caller (AddressSpaceApplier) falls back to a full rebuild.
|
||||
// Without this forward the surgical optimization is inert on every driver-role host, because
|
||||
// actors inject THIS wrapper, not the inner sink. ALL six args (including the DataType/array-shape
|
||||
// ones) MUST be forwarded — a partial forward silently drops the shape update on every driver-role host.
|
||||
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength)
|
||||
=> _inner is ISurgicalAddressSpaceSink surgical
|
||||
&& surgical.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength);
|
||||
|
||||
/// <summary>Forwards an in-place folder-display-name update (OpcUaServer-001 — UNS Area / Line
|
||||
/// rename) to the inner sink when it supports the surgical capability. Returns false otherwise —
|
||||
/// before the real <c>SdkAddressSpaceSink</c> is swapped in (inner is still the null sink), or any
|
||||
/// inner sink that isn't surgical — so the caller (AddressSpaceApplier) falls back to a full rebuild.
|
||||
/// Without this forward the rename-refresh optimization is inert on every driver-role host, because
|
||||
/// actors inject THIS wrapper, not the inner sink.</summary>
|
||||
/// <param name="folderNodeId">The folder node id whose display name to update in place.</param>
|
||||
/// <param name="displayName">The new display name to apply.</param>
|
||||
/// <returns>True when the inner sink applied the update; false when it lacks the capability or the folder is missing.</returns>
|
||||
/// <inheritdoc />
|
||||
// Forwards to the inner sink when it supports the surgical capability; returns false otherwise —
|
||||
// before the real SdkAddressSpaceSink is swapped in (inner is still the null sink), or any inner
|
||||
// sink that isn't surgical — so the caller (AddressSpaceApplier) falls back to a full rebuild.
|
||||
// Without this forward the rename-refresh optimization is inert on every driver-role host, because
|
||||
// actors inject THIS wrapper, not the inner sink.
|
||||
public bool UpdateFolderDisplayName(string folderNodeId, string displayName)
|
||||
=> _inner is ISurgicalAddressSpaceSink surgical
|
||||
&& surgical.UpdateFolderDisplayName(folderNodeId, displayName);
|
||||
|
||||
@@ -16,7 +16,6 @@ public sealed class DeferredServiceLevelPublisher : IServiceLevelPublisher
|
||||
public void SetInner(IServiceLevelPublisher? inner) =>
|
||||
_inner = inner ?? NullServiceLevelPublisher.Instance;
|
||||
|
||||
/// <summary>Publishes a service level value to the inner publisher.</summary>
|
||||
/// <param name="serviceLevel">The service level to publish.</param>
|
||||
/// <inheritdoc />
|
||||
public void Publish(byte serviceLevel) => _inner.Publish(serviceLevel);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ public sealed class NullServiceLevelPublisher : IServiceLevelPublisher
|
||||
/// <summary>Gets the last published service level value.</summary>
|
||||
public byte LastPublished { get; private set; }
|
||||
|
||||
/// <summary>Records the service level value without publishing.</summary>
|
||||
/// <param name="serviceLevel">The service level value (0-255).</param>
|
||||
/// <inheritdoc />
|
||||
public void Publish(byte serviceLevel) => LastPublished = serviceLevel;
|
||||
}
|
||||
|
||||
@@ -3,15 +3,18 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
public readonly record struct CorrelationId(Guid Value)
|
||||
{
|
||||
/// <summary>Creates a new CorrelationId with a randomly generated GUID.</summary>
|
||||
/// <returns>A new, randomly generated <see cref="CorrelationId"/>.</returns>
|
||||
public static CorrelationId NewId() => new(Guid.NewGuid());
|
||||
/// <inheritdoc />
|
||||
public override string ToString() => Value.ToString("N");
|
||||
/// <summary>Parses a lowercase hex string without hyphens into a CorrelationId.</summary>
|
||||
/// <param name="s">The string to parse.</param>
|
||||
/// <returns>The parsed <see cref="CorrelationId"/>.</returns>
|
||||
public static CorrelationId Parse(string s) => new(Guid.ParseExact(s, "N"));
|
||||
/// <summary>Attempts to parse a lowercase hex string without hyphens into a CorrelationId.</summary>
|
||||
/// <param name="s">The string to parse, or null.</param>
|
||||
/// <param name="id">The resulting CorrelationId if parsing succeeds.</param>
|
||||
/// <returns><c>true</c> if <paramref name="s"/> was successfully parsed.</returns>
|
||||
public static bool TryParse(string? s, out CorrelationId id)
|
||||
{
|
||||
if (Guid.TryParseExact(s, "N", out var g)) { id = new CorrelationId(g); return true; }
|
||||
|
||||
@@ -41,6 +41,7 @@ public static class EquipmentScriptPaths
|
||||
|
||||
/// <summary>True when the source uses the <c>{{equip}}</c> token anywhere.</summary>
|
||||
/// <param name="source">The script source to scan.</param>
|
||||
/// <returns>True if <paramref name="source"/> contains the <c>{{equip}}</c> token; otherwise false.</returns>
|
||||
public static bool ContainsEquipToken(string? source) =>
|
||||
!string.IsNullOrEmpty(source) && source.Contains(EquipToken, StringComparison.Ordinal);
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ public sealed class ClusterNode
|
||||
|
||||
/// <summary>
|
||||
/// OPC UA <c>ApplicationUri</c> — MUST be unique per node per OPC UA spec. Clients pin trust here.
|
||||
/// Fleet-wide unique index enforces no two nodes share a value (decision #86).
|
||||
/// Fleet-wide unique index enforces no two nodes share a value.
|
||||
/// Stored explicitly, NOT derived from <see cref="Host"/> at runtime — silent rewrite on
|
||||
/// hostname change would break all client trust.
|
||||
/// </summary>
|
||||
@@ -31,7 +31,7 @@ public sealed class ClusterNode
|
||||
|
||||
/// <summary>
|
||||
/// Per-node override JSON keyed by DriverInstanceId, merged onto cluster-level DriverConfig
|
||||
/// at apply time. Minimal by intent (decision #81). Nullable when no overrides exist.
|
||||
/// at apply time. Minimal by intent. Nullable when no overrides exist.
|
||||
/// </summary>
|
||||
public string? DriverConfigOverridesJson { get; set; }
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates a <see cref="ClusterNode"/> to the central config DB.
|
||||
/// Per decision #83 — credentials bind to NodeId, not ClusterId.
|
||||
/// Credentials bind to NodeId, not ClusterId.
|
||||
/// </summary>
|
||||
public sealed class ClusterNodeCredential
|
||||
{
|
||||
|
||||
@@ -11,8 +11,8 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Closes the data-layer piece of LMX follow-up #7 (per-AppEngine Admin dashboard
|
||||
/// drill-down). The publisher hosted service on the Server side subscribes to every
|
||||
/// Supports the per-AppEngine Admin dashboard drill-down. The publisher hosted
|
||||
/// service on the Server side subscribes to every
|
||||
/// registered driver's <c>OnHostStatusChanged</c> and upserts rows on transitions +
|
||||
/// periodic liveness heartbeats. <see cref="LastSeenUtc"/> advances on every
|
||||
/// heartbeat so the Admin UI can flag stale rows from a crashed Server.
|
||||
|
||||
@@ -14,7 +14,7 @@ public sealed class DriverInstance
|
||||
|
||||
/// <summary>
|
||||
/// Logical FK to <see cref="Namespace.NamespaceId"/>. Same-cluster binding enforced by
|
||||
/// <c>sp_ValidateDraft</c> per decision #122: Namespace.ClusterId must equal DriverInstance.ClusterId.
|
||||
/// <c>sp_ValidateDraft</c>: Namespace.ClusterId must equal DriverInstance.ClusterId.
|
||||
/// </summary>
|
||||
public required string NamespaceId { get; set; }
|
||||
|
||||
@@ -27,12 +27,12 @@ public sealed class DriverInstance
|
||||
/// <summary>Gets or sets a value indicating whether this driver instance is enabled.</summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>Schemaless per-driver-type JSON config. Validated against registered JSON schema at draft-publish time (decision #91).</summary>
|
||||
/// <summary>Schemaless per-driver-type JSON config. Validated against registered JSON schema at draft-publish time.</summary>
|
||||
public required string DriverConfig { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional per-instance overrides for the Phase 6.1 shared Polly resilience pipeline.
|
||||
/// Null = use the driver's tier defaults (decision #143). When populated, expected shape:
|
||||
/// Null = use the driver's tier defaults. When populated, expected shape:
|
||||
/// <code>
|
||||
/// {
|
||||
/// "bulkheadMaxConcurrent": 16,
|
||||
|
||||
@@ -2,8 +2,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// UNS level-5 entity. Only for drivers in Equipment-kind namespaces.
|
||||
/// Per decisions #109 (first-class), #116 (5-identifier model), #125 (system-generated EquipmentId),
|
||||
/// #138–139 (OPC 40010 Identification fields as first-class columns).
|
||||
/// </summary>
|
||||
public sealed class Equipment
|
||||
{
|
||||
@@ -12,7 +10,7 @@ public sealed class Equipment
|
||||
|
||||
/// <summary>
|
||||
/// System-generated stable internal logical ID. Format: <c>'EQ-' + first 12 hex chars of EquipmentUuid</c>.
|
||||
/// NEVER operator-supplied, NEVER in CSV imports, NEVER editable in Admin UI (decision #125).
|
||||
/// NEVER operator-supplied, NEVER in CSV imports, NEVER editable in Admin UI.
|
||||
/// </summary>
|
||||
public required string EquipmentId { get; set; }
|
||||
|
||||
@@ -34,7 +32,7 @@ public sealed class Equipment
|
||||
/// <summary>UNS level 5 segment, matches <c>^[a-z0-9-]{1,32}$</c>.</summary>
|
||||
public required string Name { get; set; }
|
||||
|
||||
// Operator-facing / external-system identifiers (decision #116)
|
||||
// Operator-facing / external-system identifiers
|
||||
|
||||
/// <summary>Operator colloquial id (e.g. "machine_001"). Unique within cluster. Required.</summary>
|
||||
public required string MachineCode { get; set; }
|
||||
@@ -45,7 +43,7 @@ public sealed class Equipment
|
||||
/// <summary>SAP PM equipment id. Unique fleet-wide via <see cref="ExternalIdReservation"/>.</summary>
|
||||
public string? SAPID { get; set; }
|
||||
|
||||
// OPC UA Companion Spec OPC 40010 Machinery Identification fields (decision #139).
|
||||
// OPC UA Companion Spec OPC 40010 Machinery Identification fields.
|
||||
// All nullable so equipment can be added before identity is fully captured.
|
||||
/// <summary>Gets or sets the manufacturer name for this equipment.</summary>
|
||||
public string? Manufacturer { get; set; }
|
||||
@@ -66,7 +64,7 @@ public sealed class Equipment
|
||||
/// <summary>Gets or sets the device manual URI for this equipment.</summary>
|
||||
public string? DeviceManualUri { get; set; }
|
||||
|
||||
/// <summary>Nullable hook for future schemas-repo template ID (decision #112).</summary>
|
||||
/// <summary>Nullable hook for future schemas-repo template ID.</summary>
|
||||
public string? EquipmentClassRef { get; set; }
|
||||
|
||||
/// <summary>Gets or sets whether this equipment is enabled.</summary>
|
||||
|
||||
@@ -46,8 +46,8 @@ public sealed class EquipmentImportBatch
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One staged row under an <see cref="EquipmentImportBatch"/>. Mirrors the decision #117
|
||||
/// + decision #139 columns from the CSV importer's output + an
|
||||
/// One staged row under an <see cref="EquipmentImportBatch"/>. Mirrors the required
|
||||
/// + optional columns from the CSV importer's output + an
|
||||
/// <see cref="IsAccepted"/> flag + a <see cref="RejectReason"/> string the preview modal
|
||||
/// renders.
|
||||
/// </summary>
|
||||
@@ -68,7 +68,6 @@ public sealed class EquipmentImportRow
|
||||
/// <summary>Gets or sets the reason this row was rejected, if applicable.</summary>
|
||||
public string? RejectReason { get; set; }
|
||||
|
||||
// Required (decision #117)
|
||||
/// <summary>Gets or sets the Z tag identifier.</summary>
|
||||
public required string ZTag { get; set; }
|
||||
|
||||
@@ -93,7 +92,6 @@ public sealed class EquipmentImportRow
|
||||
/// <summary>Gets or sets the UNS line name.</summary>
|
||||
public required string UnsLineName { get; set; }
|
||||
|
||||
// Optional (decision #139 — OPC 40010 Identification)
|
||||
/// <summary>Gets or sets the manufacturer name.</summary>
|
||||
public string? Manufacturer { get; set; }
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Fleet-wide rollback-safe reservation of ZTag and SAPID. Per decision #124 — NOT generation-versioned.
|
||||
/// Fleet-wide rollback-safe reservation of ZTag and SAPID. NOT generation-versioned.
|
||||
/// Exists outside generation flow specifically because old generations and disabled equipment can
|
||||
/// still hold the same external IDs; per-generation uniqueness indexes fail under rollback/re-enable.
|
||||
/// </summary>
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
/// applies fleet-wide.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Per <c>docs/v2/plan.md</c> decisions #105 and #150 — this entity is <b>control-plane
|
||||
/// <para>This entity is <b>control-plane
|
||||
/// only</b>. The OPC UA data-path evaluator does not read these rows; it reads
|
||||
/// <see cref="NodeAcl"/> joined directly against the session's resolved LDAP group
|
||||
/// memberships. Collapsing the two would let a user inherit tag permissions via an
|
||||
|
||||
@@ -3,7 +3,7 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// OPC UA namespace served by a cluster. Generation-versioned per decision #123 —
|
||||
/// OPC UA namespace served by a cluster. Generation-versioned —
|
||||
/// namespaces are content (affect what consumers see at the endpoint), not topology.
|
||||
/// </summary>
|
||||
public sealed class Namespace
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One ACL grant: an LDAP group gets a set of <see cref="NodePermissions"/> at a specific scope.
|
||||
/// Generation-versioned per decision #130. See <c>acl-design.md</c> for evaluation algorithm.
|
||||
/// Generation-versioned per decision. See <c>acl-design.md</c> for evaluation algorithm.
|
||||
/// </summary>
|
||||
public sealed class NodeAcl
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Per Phase 7 plan decision #8 — user-authored C# script source, referenced by
|
||||
/// User-authored C# script source, referenced by
|
||||
/// <see cref="VirtualTag"/> and <see cref="ScriptedAlarm"/>. One row per script,
|
||||
/// per generation. <c>SourceHash</c> is the compile-cache key.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Per Phase 7 plan decisions #5, #13, #15 — a scripted OPC UA Part 9 alarm whose
|
||||
/// A scripted OPC UA Part 9 alarm whose
|
||||
/// condition is the predicate <see cref="Script"/> referenced by
|
||||
/// <see cref="PredicateScriptId"/>. Materialized by <c>Core.ScriptedAlarms</c> as a
|
||||
/// concrete <c>AlarmConditionType</c> subtype per <see cref="AlarmType"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Message tokens (<c>{TagPath}</c>) resolved at emission time per plan decision #13.
|
||||
/// <see cref="HistorizeToAveva"/> (plan decision #15) gates whether transitions
|
||||
/// Message tokens (<c>{TagPath}</c>) resolved at emission time.
|
||||
/// <see cref="HistorizeToAveva"/> gates whether transitions
|
||||
/// route through the Core.AlarmHistorian SQLite queue + Galaxy.Host to the Aveva
|
||||
/// Historian alarm schema.
|
||||
/// </para>
|
||||
@@ -41,7 +41,7 @@ public sealed class ScriptedAlarm
|
||||
public required string PredicateScriptId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Plan decision #15 — when true, transitions route through the SQLite store-and-forward
|
||||
/// When true, transitions route through the SQLite store-and-forward
|
||||
/// queue to the Aveva Historian. Defaults on for scripted alarms because they are the
|
||||
/// primary motivation for the historian sink; operator can disable per alarm.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Per Phase 7 plan decision #14 — persistent runtime state for each scripted alarm.
|
||||
/// Survives process restart so operators don't re-ack and ack history survives for
|
||||
/// Persistent runtime state for each scripted alarm. Survives process restart so
|
||||
/// operators don't re-ack and ack history survives for
|
||||
/// GxP / 21 CFR Part 11 compliance. Keyed on <c>ScriptedAlarmId</c> logically (not
|
||||
/// per-generation) because ack state follows the alarm's stable identity across
|
||||
/// generations — a Modified alarm keeps its ack history.
|
||||
@@ -24,7 +24,7 @@ public sealed class ScriptedAlarmState
|
||||
/// <summary>Logical FK — matches <see cref="ScriptedAlarm.ScriptedAlarmId"/>. One row per alarm identity.</summary>
|
||||
public required string ScriptedAlarmId { get; set; }
|
||||
|
||||
/// <summary>Enabled/Disabled. Persists across restart per plan decision #14.</summary>
|
||||
/// <summary>Enabled/Disabled. Persists across restart.</summary>
|
||||
public required string EnabledState { get; set; } = "Enabled";
|
||||
|
||||
/// <summary>Unacknowledged / Acknowledged.</summary>
|
||||
|
||||
@@ -14,7 +14,7 @@ public sealed class ServerCluster
|
||||
/// <summary>Gets or sets the display name for the server cluster.</summary>
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>UNS level 1. Canonical org value: "zb" per decision #140.</summary>
|
||||
/// <summary>UNS level 1. Canonical org value: "zb".</summary>
|
||||
public required string Enterprise { get; set; }
|
||||
|
||||
/// <summary>UNS level 2, e.g. "warsaw-west".</summary>
|
||||
|
||||
@@ -51,7 +51,7 @@ public sealed class Tag
|
||||
/// </summary>
|
||||
public required TagAccessLevel AccessLevel { get; set; }
|
||||
|
||||
/// <summary>Per decisions #44–45 — opt-in for write retry eligibility.</summary>
|
||||
/// <summary>Opt-in for write retry eligibility.</summary>
|
||||
public bool WriteIdempotent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>UNS level-3 segment. Generation-versioned per decision #115.</summary>
|
||||
/// <summary>UNS level-3 segment. Generation-versioned.</summary>
|
||||
public sealed class UnsArea
|
||||
{
|
||||
/// <summary>Gets or sets the unique row identifier.</summary>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>UNS level-4 segment. Generation-versioned per decision #115.</summary>
|
||||
/// <summary>UNS level-4 segment. Generation-versioned.</summary>
|
||||
public sealed class UnsLine
|
||||
{
|
||||
/// <summary>Gets or sets the unique row identifier for this UNS line.</summary>
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Per Phase 7 plan decision #2 — a virtual (calculated) tag that lives in the
|
||||
/// Equipment tree alongside driver tags. Value is produced by the
|
||||
/// <see cref="Script"/> referenced by <see cref="ScriptId"/>.
|
||||
/// A virtual (calculated) tag that lives in the Equipment tree alongside driver tags.
|
||||
/// Value is produced by the <see cref="Script"/> referenced by <see cref="ScriptId"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="EquipmentId"/> is mandatory — virtual tags are always scoped to an
|
||||
/// Equipment node per plan decision #2 (unified Equipment tree, not a separate
|
||||
/// Equipment node (unified Equipment tree, not a separate
|
||||
/// /Virtual namespace). <see cref="DataType"/> matches the shape used by
|
||||
/// <c>Tag.DataType</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="ChangeTriggered"/> and <see cref="TimerIntervalMs"/> together realize
|
||||
/// plan decision #3 (change + timer). At least one must produce evaluations; the
|
||||
/// change + timer evaluation. At least one must produce evaluations; the
|
||||
/// Core.VirtualTags engine rejects an all-disabled tag at load time.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
|
||||
@@ -8,14 +8,14 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Per <c>docs/v2/plan.md</c> decision #150 the two concerns share zero runtime code path:
|
||||
/// Per <c>docs/v2/plan.md</c> the two concerns share zero runtime code path:
|
||||
/// the control plane (Admin UI) consumes <see cref="Entities.LdapGroupRoleMapping"/>; the
|
||||
/// data plane consumes <see cref="Entities.NodeAcl"/> rows directly. Having them in one
|
||||
/// table would collapse the distinction + let a user inherit tag permissions via their
|
||||
/// admin-role claim path.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Task 1.7 standardized the member names on the canonical control-plane role vocabulary
|
||||
/// The member names were standardized on the canonical control-plane role vocabulary
|
||||
/// (<c>ZB.MOM.WW.Auth</c> <c>CanonicalRole</c>): <c>ConfigViewer → Viewer</c>,
|
||||
/// <c>ConfigEditor → Designer</c>, <c>FleetAdmin → Administrator</c>. The appsettings-only
|
||||
/// <c>DriverOperator</c> string role likewise became <c>Operator</c>. These members persist
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
/// <summary>Credential kind for <see cref="Entities.ClusterNodeCredential"/>. Per decision #83.</summary>
|
||||
/// <summary>Credential kind for <see cref="Entities.ClusterNodeCredential"/>.</summary>
|
||||
public enum CredentialKind
|
||||
{
|
||||
SqlLogin,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
/// <summary>OPC UA namespace kind per decision #107. One of each kind per cluster per generation.</summary>
|
||||
/// <summary>OPC UA namespace kind. One of each kind per cluster per generation.</summary>
|
||||
public enum NamespaceKind
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
/// <summary>External-ID reservation kind. Per decision #124.</summary>
|
||||
/// <summary>External-ID reservation kind.</summary>
|
||||
public enum ReservationKind
|
||||
{
|
||||
ZTag,
|
||||
|
||||
@@ -3,7 +3,7 @@ using LiteDB;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||
|
||||
/// <summary>
|
||||
/// Generation-sealed LiteDB cache per <c>docs/v2/plan.md</c> decision #148 and Phase 6.1
|
||||
/// Generation-sealed LiteDB cache per <c>docs/v2/plan.md</c> and Phase 6.1
|
||||
/// Stream D.1. Each published generation writes one <b>read-only</b> LiteDB file under
|
||||
/// <c><cache-root>/<clusterId>/<generationId>.db</c>. A per-cluster
|
||||
/// <c>CURRENT</c> text file holds the currently-active generation id; it is updated
|
||||
@@ -32,7 +32,7 @@ public sealed class GenerationSealedCache
|
||||
// BsonMapper.Global is a process-wide singleton whose lazy per-type member registration is
|
||||
// not thread-safe across concurrently-constructed LiteDatabase instances; a seal racing a
|
||||
// read (or this cache racing LiteDbConfigCache) corrupts the global mapper, surfacing as
|
||||
// "Member … not found on BsonMapper" or a bogus duplicate-_id insert — Configuration-012.
|
||||
// "Member … not found on BsonMapper" or a bogus duplicate-_id insert.
|
||||
private static BsonMapper BuildMapper()
|
||||
{
|
||||
var mapper = new BsonMapper();
|
||||
|
||||
@@ -2,7 +2,7 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||
|
||||
/// <summary>
|
||||
/// Per-node local cache of the most-recently-applied generation(s). Used to bootstrap the
|
||||
/// address space when the central DB is unreachable (decision #79 — degraded-but-running).
|
||||
/// address space when the central DB is unreachable (degraded-but-running).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Concurrency contract:</b> implementations must serialize writes — specifically,
|
||||
@@ -21,10 +21,12 @@ public interface ILocalConfigCache
|
||||
/// <summary>Stores a generation snapshot in the local cache.</summary>
|
||||
/// <param name="snapshot">The generation snapshot to store.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task PutAsync(GenerationSnapshot snapshot, CancellationToken ct = default);
|
||||
/// <summary>Removes old generations, keeping only the most recent N.</summary>
|
||||
/// <param name="clusterId">The cluster identifier.</param>
|
||||
/// <param name="keepLatest">The number of latest generations to keep.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task PruneOldGenerationsAsync(string clusterId, int keepLatest = 10, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||
/// LiteDB-backed <see cref="ILocalConfigCache"/>. One file per node (default
|
||||
/// <c>config_cache.db</c>), one collection per snapshot. Corruption surfaces as
|
||||
/// <see cref="LocalConfigCacheCorruptException"/> on construction or read — callers should
|
||||
/// delete and re-fetch from the central DB (decision #80).
|
||||
/// delete and re-fetch from the central DB.
|
||||
/// </summary>
|
||||
public sealed class LiteDbConfigCache : ILocalConfigCache, IDisposable
|
||||
{
|
||||
@@ -17,7 +17,7 @@ public sealed class LiteDbConfigCache : ILocalConfigCache, IDisposable
|
||||
// instances. When several caches (this one + GenerationSealedCache) initialise in parallel
|
||||
// the global mapper races, surfacing as "Member ClusterId not found on BsonMapper" or a
|
||||
// bogus "duplicate key _id = 0" (the int auto-id mapping was lost so Insert writes a literal
|
||||
// 0 twice) — Configuration-012. Give each database a private, pre-registered mapper so member
|
||||
// 0 twice). Give each database a private, pre-registered mapper so member
|
||||
// resolution happens once, single-threaded, at construction and never touches the global.
|
||||
private static BsonMapper BuildMapper()
|
||||
{
|
||||
@@ -30,7 +30,7 @@ public sealed class LiteDbConfigCache : ILocalConfigCache, IDisposable
|
||||
private readonly ILiteCollection<GenerationSnapshot> _col;
|
||||
// PutAsync is a find-then-insert/update; without serialization, two concurrent puts for the
|
||||
// same (ClusterId, GenerationId) can both observe `existing is null` and both Insert,
|
||||
// producing duplicate rows (Configuration-005). Serialize writes through this semaphore so
|
||||
// producing duplicate rows. Serialize writes through this semaphore so
|
||||
// the read-modify-write block is atomic for a given instance. LiteDB itself only locks the
|
||||
// page-level write, not the find-then-insert window.
|
||||
private readonly SemaphoreSlim _writeGate = new(initialCount: 1, maxCount: 1);
|
||||
@@ -60,9 +60,7 @@ public sealed class LiteDbConfigCache : ILocalConfigCache, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Gets the most recent snapshot for the specified cluster.</summary>
|
||||
/// <param name="clusterId">The cluster ID.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <inheritdoc />
|
||||
public Task<GenerationSnapshot?> GetMostRecentAsync(string clusterId, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
@@ -73,15 +71,13 @@ public sealed class LiteDbConfigCache : ILocalConfigCache, IDisposable
|
||||
return Task.FromResult<GenerationSnapshot?>(snapshot);
|
||||
}
|
||||
|
||||
/// <summary>Stores a snapshot in the cache.</summary>
|
||||
/// <param name="snapshot">The snapshot to store.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <inheritdoc />
|
||||
public async Task PutAsync(GenerationSnapshot snapshot, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
// Serialize the find-then-insert/update so concurrent callers do not observe a stale
|
||||
// `existing is null` and both Insert (Configuration-005). LiteDB's per-call lock is
|
||||
// not enough — the read and the write are independent calls.
|
||||
// `existing is null` and both Insert. LiteDB's per-call lock is not enough — the
|
||||
// read and the write are independent calls.
|
||||
await _writeGate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
@@ -104,10 +100,7 @@ public sealed class LiteDbConfigCache : ILocalConfigCache, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Removes old generation snapshots, keeping only the latest ones.</summary>
|
||||
/// <param name="clusterId">The cluster ID.</param>
|
||||
/// <param name="keepLatest">Number of latest generations to keep.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <inheritdoc />
|
||||
public Task PruneOldGenerationsAsync(string clusterId, int keepLatest = 10, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
@@ -69,7 +69,7 @@ public sealed class ResilientConfigReader
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration-010: redact connection-string fragments (Password, User Id, Pwd, etc.)
|
||||
/// Redacts connection-string fragments (Password, User Id, Pwd, etc.)
|
||||
/// that a caller's exception message could carry. Conservative regex pass — anything
|
||||
/// matching <c>Key=Value</c> with a known credential key gets its value replaced.
|
||||
/// </summary>
|
||||
@@ -120,7 +120,7 @@ public sealed class ResilientConfigReader
|
||||
// that case, not propagate. Only rethrow if the caller actually requested cancellation.
|
||||
catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Configuration-010: do NOT pass the raw exception object — it carries the stack
|
||||
// Do NOT pass the raw exception object — it carries the stack
|
||||
// and inner-exception chain, and SqlException/wrapping delegates can surface
|
||||
// connection-string fragments (Password=…, User Id=…) embedded in messages.
|
||||
// Log only the exception type and a scrubbed message so secrets stay out of logs.
|
||||
|
||||
@@ -72,8 +72,6 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
|
||||
/// <summary>Gets the DbSet of data protection keys.</summary>
|
||||
public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();
|
||||
|
||||
/// <summary>Configures the entity model when the context is first created.</summary>
|
||||
/// <param name="modelBuilder">The model builder used to configure the context.</param>
|
||||
/// <inheritdoc />
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -151,13 +149,12 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
|
||||
.HasForeignKey(x => x.ClusterId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// Fleet-wide unique per decision #86
|
||||
// Fleet-wide unique.
|
||||
e.HasIndex(x => x.ApplicationUri).IsUnique().HasDatabaseName("UX_ClusterNode_ApplicationUri");
|
||||
e.HasIndex(x => x.ClusterId).HasDatabaseName("IX_ClusterNode_ClusterId");
|
||||
// v2: the "one Primary per cluster" filtered unique index (and the RedundancyRole
|
||||
// column it filtered on) are gone. Akka cluster leader-of-driver-role is the
|
||||
// authoritative primary signal (see RedundancyStateActor + ServiceLevelCalculator,
|
||||
// Task 35).
|
||||
// authoritative primary signal (see RedundancyStateActor + ServiceLevelCalculator).
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Services;
|
||||
/// <summary>
|
||||
/// CRUD surface for <see cref="LdapGroupRoleMapping"/> — the control-plane mapping from
|
||||
/// LDAP groups to Admin UI roles. Consumed only by Admin UI code paths; the OPC UA
|
||||
/// data-path evaluator MUST NOT depend on this interface (see decision #150 and the
|
||||
/// data-path evaluator MUST NOT depend on this interface (see the
|
||||
/// Phase 6.2 compliance check on control/data-plane separation).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
@@ -28,11 +28,13 @@ public interface ILdapGroupRoleMappingService
|
||||
/// </remarks>
|
||||
/// <param name="ldapGroups">The LDAP groups to search for.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The mappings whose LDAP group matches one of <paramref name="ldapGroups"/>.</returns>
|
||||
Task<IReadOnlyList<LdapGroupRoleMapping>> GetByGroupsAsync(
|
||||
IEnumerable<string> ldapGroups, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Enumerate every mapping; Admin UI listing only.</summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>Every LDAP group role mapping.</returns>
|
||||
Task<IReadOnlyList<LdapGroupRoleMapping>> ListAllAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Create a new grant.</summary>
|
||||
@@ -43,11 +45,13 @@ public interface ILdapGroupRoleMappingService
|
||||
/// </exception>
|
||||
/// <param name="row">The LDAP group role mapping to create.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The created mapping row.</returns>
|
||||
Task<LdapGroupRoleMapping> CreateAsync(LdapGroupRoleMapping row, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Delete a mapping by its surrogate key.</summary>
|
||||
/// <param name="id">The unique identifier of the mapping to delete.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task DeleteAsync(Guid id, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,10 +10,7 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Services;
|
||||
/// </summary>
|
||||
public sealed class LdapGroupRoleMappingService(OtOpcUaConfigDbContext db) : ILdapGroupRoleMappingService
|
||||
{
|
||||
/// <summary>Gets LDAP group role mappings for the specified groups.</summary>
|
||||
/// <param name="ldapGroups">The LDAP group names to query.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The matching role mappings.</returns>
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<LdapGroupRoleMapping>> GetByGroupsAsync(
|
||||
IEnumerable<string> ldapGroups, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -28,9 +25,7 @@ public sealed class LdapGroupRoleMappingService(OtOpcUaConfigDbContext db) : ILd
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Lists all LDAP group role mappings.</summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>All role mappings ordered by group and cluster ID.</returns>
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<LdapGroupRoleMapping>> ListAllAsync(CancellationToken cancellationToken)
|
||||
=> await db.LdapGroupRoleMappings
|
||||
.AsNoTracking()
|
||||
@@ -39,10 +34,7 @@ public sealed class LdapGroupRoleMappingService(OtOpcUaConfigDbContext db) : ILd
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
/// <summary>Creates a new LDAP group role mapping.</summary>
|
||||
/// <param name="row">The mapping to create.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The created mapping with generated ID and timestamp.</returns>
|
||||
/// <inheritdoc />
|
||||
public async Task<LdapGroupRoleMapping> CreateAsync(LdapGroupRoleMapping row, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(row);
|
||||
@@ -56,10 +48,7 @@ public sealed class LdapGroupRoleMappingService(OtOpcUaConfigDbContext db) : ILd
|
||||
return row;
|
||||
}
|
||||
|
||||
/// <summary>Deletes an LDAP group role mapping.</summary>
|
||||
/// <param name="id">The mapping identifier.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task that completes when the deletion is done.</returns>
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteAsync(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await db.LdapGroupRoleMappings.FindAsync([id], cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -5,7 +5,7 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Managed-code pre-publish validator per decision #91. Complements the structural checks in
|
||||
/// Managed-code pre-publish validator. Complements the structural checks in
|
||||
/// <c>sp_ValidateDraft</c> — this layer owns schema validation for JSON columns, UNS segment
|
||||
/// regex, EquipmentId derivation, cross-cluster checks, and anything else that's uncomfortable
|
||||
/// to express in T-SQL. Returns every failing rule in one pass (decision: surface all errors,
|
||||
@@ -21,6 +21,7 @@ public static class DraftValidator
|
||||
/// Validates a draft snapshot and returns all validation errors found in a single pass.
|
||||
/// </summary>
|
||||
/// <param name="draft">The draft snapshot to validate.</param>
|
||||
/// <returns>Every validation error found; empty when the draft is valid.</returns>
|
||||
public static IReadOnlyList<ValidationError> Validate(DraftSnapshot draft)
|
||||
{
|
||||
var errors = new List<ValidationError>();
|
||||
@@ -204,8 +205,9 @@ public static class DraftValidator
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Decision #125: EquipmentId = 'EQ-' + lowercase first 12 hex chars of the UUID.</summary>
|
||||
/// <summary>EquipmentId = 'EQ-' + lowercase first 12 hex chars of the UUID.</summary>
|
||||
/// <param name="uuid">The equipment UUID to derive the ID from.</param>
|
||||
/// <returns>The derived <c>EQ-</c>-prefixed EquipmentId.</returns>
|
||||
public static string DeriveEquipmentId(Guid uuid) =>
|
||||
"EQ-" + uuid.ToString("N")[..12].ToLowerInvariant();
|
||||
|
||||
@@ -222,7 +224,7 @@ public static class DraftValidator
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.3 Stream A.2 + task #148 part 2 — managed pre-publish guard for cluster
|
||||
/// Managed pre-publish guard for cluster
|
||||
/// topology vs. <see cref="ServerCluster.RedundancyMode"/>. The SQL
|
||||
/// <c>CK_ServerCluster_RedundancyMode_NodeCount</c> CHECK already enforces the
|
||||
/// (NodeCount, RedundancyMode) pair on the row itself, but it cannot see the
|
||||
@@ -240,6 +242,8 @@ public static class DraftValidator
|
||||
/// </remarks>
|
||||
/// <param name="cluster">The server cluster to validate.</param>
|
||||
/// <param name="clusterNodes">The cluster nodes to validate against the cluster configuration.</param>
|
||||
/// <returns>Every failing topology rule found; empty when the cluster's declared and enabled-node
|
||||
/// topology is consistent with its <see cref="ServerCluster.RedundancyMode"/>.</returns>
|
||||
public static IReadOnlyList<ValidationError> ValidateClusterTopology(
|
||||
ServerCluster cluster,
|
||||
IReadOnlyList<ClusterNode> clusterNodes)
|
||||
@@ -273,7 +277,7 @@ public static class DraftValidator
|
||||
cluster.ClusterId));
|
||||
|
||||
// v2: the v1 "exactly one Primary per cluster" invariant is gone. RedundancyRole was
|
||||
// dropped in Task 14d; in v2 the Akka cluster's role-leader-of-"driver" elects the
|
||||
// dropped; in v2 the Akka cluster's role-leader-of-"driver" elects the
|
||||
// primary at runtime, so there is no static configuration to validate here.
|
||||
|
||||
return errors;
|
||||
|
||||
@@ -6,10 +6,6 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// shape so the node-manager can pass through quality, source timestamp, and
|
||||
/// server timestamp without translation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per <c>docs/v2/plan.md</c> decision #13 — every driver maps to the same
|
||||
/// OPC UA StatusCode space; this DTO is the universal carrier.
|
||||
/// </remarks>
|
||||
/// <param name="Value">The raw value; null when <see cref="StatusCode"/> indicates Bad.</param>
|
||||
/// <param name="StatusCode">OPC UA status code (numeric value matches the OPC UA spec).</param>
|
||||
/// <param name="SourceTimestampUtc">Driver-side timestamp when the value was sampled at the source. Null if unavailable.</param>
|
||||
|
||||
@@ -27,14 +27,14 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// </param>
|
||||
/// <param name="WriteIdempotent">
|
||||
/// True when a timed-out or failed write to this attribute is safe to replay. Per
|
||||
/// <c>docs/v2/plan.md</c> decisions #44, #45, #143 — writes are NOT auto-retried by default
|
||||
/// <c>docs/v2/plan.md</c>, writes are NOT auto-retried by default
|
||||
/// because replaying a pulse / alarm-ack / counter-increment / recipe-step advance can
|
||||
/// duplicate field actions. Drivers flag only tags whose semantics make retry safe
|
||||
/// (holding registers with level-set values, set-point writes to analog tags) — the
|
||||
/// capability invoker respects this flag when deciding whether to apply Polly retry.
|
||||
/// </param>
|
||||
/// <param name="Source">
|
||||
/// Per ADR-002 — discriminates which runtime subsystem owns this node's dispatch.
|
||||
/// Discriminates which runtime subsystem owns this node's dispatch.
|
||||
/// Defaults to <see cref="NodeSourceKind.Driver"/> so existing callers are unchanged.
|
||||
/// </param>
|
||||
/// <param name="VirtualTagId">
|
||||
@@ -59,7 +59,7 @@ public sealed record DriverAttributeInfo(
|
||||
string? ScriptedAlarmId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Per ADR-002 — discriminates which runtime subsystem owns this node's Read/Write/
|
||||
/// Discriminates which runtime subsystem owns this node's Read/Write/
|
||||
/// Subscribe dispatch. <c>Driver</c> = a real IDriver capability surface;
|
||||
/// <c>Virtual</c> = a Phase 7 <see cref="DriverAttributeInfo"/>.VirtualTagId'd tag
|
||||
/// computed by the VirtualTagEngine; <c>ScriptedAlarm</c> = a scripted Part 9 alarm
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// <see cref="IAlarmSource"/>, <see cref="IHistoryProvider"/>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per <c>docs/v2/plan.md</c> decision #143 (per-capability retry policy): Read / HistoryRead /
|
||||
/// Per the per-capability retry policy in <c>docs/v2/plan.md</c>: Read / HistoryRead /
|
||||
/// Discover / Probe / AlarmSubscribe auto-retry; <see cref="Write"/> does NOT retry unless the
|
||||
/// tag-definition carries <see cref="WriteIdempotentAttribute"/>. Alarm-acknowledge is treated
|
||||
/// as a write for retry semantics (an alarm-ack is not idempotent at the plant-floor acknowledgement
|
||||
@@ -34,7 +34,7 @@ public enum DriverCapability
|
||||
/// <summary><see cref="IAlarmSource.SubscribeAlarmsAsync"/>. Retries by default.</summary>
|
||||
AlarmSubscribe,
|
||||
|
||||
/// <summary><see cref="IAlarmSource.AcknowledgeAsync"/>. Does NOT retry — ack is a write-shaped operation (decision #143).</summary>
|
||||
/// <summary><see cref="IAlarmSource.AcknowledgeAsync"/>. Does NOT retry — ack is a write-shaped operation.</summary>
|
||||
AlarmAcknowledge,
|
||||
|
||||
/// <summary><see cref="IHistoryProvider"/> reads (Raw/Processed/AtTime/Events). Retries by default.</summary>
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// failure (useful for diagnostics), and <see cref="DriverState.Degraded"/> /
|
||||
/// <see cref="DriverState.Reconnecting"/> / <see cref="DriverState.Faulted"/> states may all
|
||||
/// carry a non-null message. Callers must not key behaviour on the LastError-null ↔ Healthy
|
||||
/// pairing (Core.Abstractions-008).
|
||||
/// pairing.
|
||||
/// </param>
|
||||
public sealed record DriverHealth(
|
||||
DriverState State,
|
||||
|
||||
@@ -6,8 +6,6 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// supervision with process-level recycle is in play.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per <c>docs/v2/driver-stability.md</c> §2-4 and <c>docs/v2/plan.md</c> decisions #63-74.
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item><b>A</b> — managed, known-good SDK; low blast radius. In-process. Fast retries.
|
||||
/// Examples: OPC UA Client (OPCFoundation stack), S7 (S7NetPlus).</item>
|
||||
@@ -18,7 +16,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// </list>
|
||||
///
|
||||
/// <para>Process-kill protections (<c>MemoryRecycle</c>, <c>ScheduledRecycleScheduler</c>) are
|
||||
/// Tier C only per decisions #73-74 and #145 — killing an in-process Tier A/B driver also kills
|
||||
/// Tier C only — killing an in-process Tier A/B driver also kills
|
||||
/// every OPC UA session and every co-hosted driver, blast-radius worse than the leak.</para>
|
||||
/// </remarks>
|
||||
public enum DriverTier
|
||||
|
||||
@@ -6,9 +6,9 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// the registry to validate <c>DriverInstance.DriverType</c> values from the central config DB.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per <c>docs/v2/plan.md</c> decisions #91 (JSON content validation in Admin app, not SQL CLR)
|
||||
/// and #111 (driver type → namespace kind mapping enforced by sp_ValidateDraft).
|
||||
/// The registry is the source of truth for both checks.
|
||||
/// Per <c>docs/v2/plan.md</c> decisions on JSON content validation happening in the Admin app
|
||||
/// (not SQL CLR), and on driver type → namespace kind mapping being enforced by
|
||||
/// <c>sp_ValidateDraft</c>. The registry is the source of truth for both checks.
|
||||
///
|
||||
/// Thread-safety: registration is typically single-threaded at startup; lookups happen on
|
||||
/// every config-apply (multi-threaded). The check-then-act inside <see cref="Register"/> is
|
||||
@@ -28,7 +28,7 @@ public sealed class DriverTypeRegistry
|
||||
/// <remarks>
|
||||
/// The check-then-act (duplicate check → copy-on-write rebuild → swap) is performed under
|
||||
/// <see cref="_writeLock"/> so concurrent <see cref="Register"/> calls cannot silently
|
||||
/// discard each other's registrations — see Core.Abstractions-004.
|
||||
/// discard each other's registrations.
|
||||
/// </remarks>
|
||||
/// <param name="metadata">The driver type metadata to register.</param>
|
||||
public void Register(DriverTypeMetadata metadata)
|
||||
@@ -55,6 +55,7 @@ public sealed class DriverTypeRegistry
|
||||
|
||||
/// <summary>Look up a driver type by name. Throws if unknown.</summary>
|
||||
/// <param name="driverType">The driver type name to look up.</param>
|
||||
/// <returns>The registered metadata for the driver type.</returns>
|
||||
public DriverTypeMetadata Get(string driverType)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverType);
|
||||
@@ -69,6 +70,7 @@ public sealed class DriverTypeRegistry
|
||||
|
||||
/// <summary>Try to look up a driver type by name. Returns null if unknown (no exception).</summary>
|
||||
/// <param name="driverType">The driver type name to look up.</param>
|
||||
/// <returns>The registered metadata, or <see langword="null"/> if the type is not registered.</returns>
|
||||
public DriverTypeMetadata? TryGet(string driverType)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverType);
|
||||
@@ -76,6 +78,7 @@ public sealed class DriverTypeRegistry
|
||||
}
|
||||
|
||||
/// <summary>Snapshot of all registered driver types.</summary>
|
||||
/// <returns>A snapshot collection of all registered driver type metadata.</returns>
|
||||
public IReadOnlyCollection<DriverTypeMetadata> All() => _types.Values.ToList();
|
||||
}
|
||||
|
||||
@@ -86,8 +89,8 @@ public sealed class DriverTypeRegistry
|
||||
/// <param name="DeviceConfigJsonSchema">JSON Schema for <c>DeviceConfig</c> (multi-device drivers); null if the driver has no device layer.</param>
|
||||
/// <param name="TagConfigJsonSchema">JSON Schema for <c>TagConfig</c>; required for every driver since every driver has tags.</param>
|
||||
/// <param name="Tier">
|
||||
/// Stability tier per <c>docs/v2/driver-stability.md</c> §2-4 and <c>docs/v2/plan.md</c>
|
||||
/// decisions #63-74. Drives the shared resilience pipeline defaults
|
||||
/// Stability tier per <c>docs/v2/driver-stability.md</c> §2-4 and the tiering decisions in
|
||||
/// <c>docs/v2/plan.md</c>. Drives the shared resilience pipeline defaults
|
||||
/// (<see cref="Tier"/> × capability → <c>CapabilityPolicy</c>), the <c>MemoryTracking</c>
|
||||
/// hybrid-formula constants, and whether process-level <c>MemoryRecycle</c> / scheduled-
|
||||
/// recycle protections apply (Tier C only). Every registered driver type must declare one.
|
||||
@@ -100,7 +103,7 @@ public sealed record DriverTypeMetadata(
|
||||
string TagConfigJsonSchema,
|
||||
DriverTier Tier);
|
||||
|
||||
/// <summary>Bitmask of namespace kinds a driver type may populate. Per decision #111.</summary>
|
||||
/// <summary>Bitmask of namespace kinds a driver type may populate.</summary>
|
||||
[Flags]
|
||||
public enum NamespaceKindCompatibility
|
||||
{
|
||||
|
||||
@@ -16,6 +16,7 @@ public sealed class EquipmentTagRefResolver<TDef> where TDef : class
|
||||
private readonly Func<string, TDef?> _parseRef;
|
||||
private readonly ConcurrentDictionary<string, TDef?> _cache = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="EquipmentTagRefResolver{TDef}"/> class.</summary>
|
||||
/// <param name="byName">Authored tag-table lookup (returns null on miss).</param>
|
||||
/// <param name="parseRef">Parses an equipment-tag reference (TagConfig JSON) into a transient def, or null.</param>
|
||||
public EquipmentTagRefResolver(Func<string, TDef?> byName, Func<string, TDef?> parseRef)
|
||||
|
||||
@@ -28,6 +28,7 @@ public interface IHistorianDataSource : IDisposable
|
||||
/// <param name="endUtc">The end of the time range in UTC.</param>
|
||||
/// <param name="maxValuesPerNode">The maximum number of values to return per node.</param>
|
||||
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
|
||||
/// <returns>The raw historical samples for the tag over the requested range.</returns>
|
||||
Task<HistoryReadResult> ReadRawAsync(
|
||||
string fullReference,
|
||||
DateTime startUtc,
|
||||
@@ -46,6 +47,7 @@ public interface IHistorianDataSource : IDisposable
|
||||
/// <param name="interval">The interval for bucketing samples.</param>
|
||||
/// <param name="aggregate">The aggregation function to apply to each bucket.</param>
|
||||
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
|
||||
/// <returns>One aggregated sample per interval bucket over the requested range.</returns>
|
||||
Task<HistoryReadResult> ReadProcessedAsync(
|
||||
string fullReference,
|
||||
DateTime startUtc,
|
||||
@@ -63,6 +65,7 @@ public interface IHistorianDataSource : IDisposable
|
||||
/// <param name="fullReference">The full reference of the tag to read.</param>
|
||||
/// <param name="timestampsUtc">The list of timestamps to read values at.</param>
|
||||
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
|
||||
/// <returns>One sample per requested timestamp, in the same order as <paramref name="timestampsUtc"/>.</returns>
|
||||
Task<HistoryReadResult> ReadAtTimeAsync(
|
||||
string fullReference,
|
||||
IReadOnlyList<DateTime> timestampsUtc,
|
||||
@@ -77,7 +80,7 @@ public interface IHistorianDataSource : IDisposable
|
||||
/// Note on parameter types — <paramref name="maxEvents"/> is <see cref="int"/> (not
|
||||
/// <see cref="uint"/>) so callers can pass <c>0</c> or a negative value as a "use the
|
||||
/// backend's default cap" sentinel; see <c>WonderwareHistorianClient</c> /
|
||||
/// <c>HistorianDataSource</c> and Core.Abstractions-006 for the rationale. The sibling
|
||||
/// <c>HistorianDataSource</c> for the rationale. The sibling
|
||||
/// <see cref="ReadRawAsync"/> / <see cref="ReadProcessedAsync"/> use
|
||||
/// <c>uint maxValuesPerNode</c> because their OPC UA HistoryRead surface has no
|
||||
/// equivalent "use default" sentinel.
|
||||
@@ -85,8 +88,7 @@ public interface IHistorianDataSource : IDisposable
|
||||
/// This surface declares <see cref="ReadAtTimeAsync"/> and <see cref="ReadEventsAsync"/>
|
||||
/// as required members — a server-side historian owns the full read surface, unlike
|
||||
/// <see cref="IHistoryProvider"/> where the same two methods are optional default-impl
|
||||
/// methods so legacy drivers can stay raw-only. The asymmetry is intentional
|
||||
/// (Core.Abstractions-008).
|
||||
/// methods so legacy drivers can stay raw-only. The asymmetry is intentional.
|
||||
/// </remarks>
|
||||
/// <param name="sourceName">The source name to filter events, or null to return events from all sources.</param>
|
||||
/// <param name="startUtc">The start of the time range in UTC.</param>
|
||||
@@ -96,9 +98,10 @@ public interface IHistorianDataSource : IDisposable
|
||||
/// default cap. When the backend cap truncates the result, implementations MUST set
|
||||
/// <see cref="HistoricalEventsResult.ContinuationPoint"/> to a non-null token so
|
||||
/// callers can detect truncation and page — a null continuation point means all
|
||||
/// matching events were returned. (Core.Abstractions-009.)
|
||||
/// matching events were returned.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
|
||||
/// <returns>The matching historical event records, with a continuation point if the result was truncated.</returns>
|
||||
Task<HistoricalEventsResult> ReadEventsAsync(
|
||||
string? sourceName,
|
||||
DateTime startUtc,
|
||||
@@ -110,5 +113,6 @@ public interface IHistorianDataSource : IDisposable
|
||||
/// Point-in-time health snapshot for diagnostics and dashboards. Pure
|
||||
/// observation; never blocks on backend I/O.
|
||||
/// </summary>
|
||||
/// <returns>The current health snapshot for the historian backend.</returns>
|
||||
HistorianHealthSnapshot GetHealthSnapshot();
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ public interface IHistorizationOutbox : IDisposable
|
||||
/// <summary>Appends <paramref name="entry"/> to the tail of the durable buffer.</summary>
|
||||
/// <param name="entry">The value record to buffer.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
ValueTask AppendAsync(HistorizationOutboxEntry entry, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
@@ -24,6 +25,7 @@ public interface IHistorizationOutbox : IDisposable
|
||||
/// </summary>
|
||||
/// <param name="max">Maximum number of entries to return; must be positive.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Up to <paramref name="max"/> oldest un-acked entries in FIFO order.</returns>
|
||||
ValueTask<IReadOnlyList<HistorizationOutboxEntry>> PeekBatchAsync(int max, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
@@ -32,9 +34,11 @@ public interface IHistorizationOutbox : IDisposable
|
||||
/// </summary>
|
||||
/// <param name="id">The <see cref="HistorizationOutboxEntry.Id"/> to ack.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
ValueTask RemoveAsync(Guid id, CancellationToken ct);
|
||||
|
||||
/// <summary>Current number of un-acked entries held in the buffer.</summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The current number of un-acked entries in the buffer.</returns>
|
||||
ValueTask<int> CountAsync(CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// <para>
|
||||
/// A process-wide singleton via <see cref="Instance"/> (private ctor): it carries no state
|
||||
/// and is immutable, so one shared instance is safe to assign as the node-manager's
|
||||
/// <c>HistorianDataSource</c> default until the Host wires a real source post-start (Task 5).
|
||||
/// <c>HistorianDataSource</c> default until the Host wires a real source post-start.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class NullHistorianDataSource : IHistorianDataSource
|
||||
@@ -55,10 +55,7 @@ public sealed class NullHistorianDataSource : IHistorianDataSource
|
||||
int maxEvents,
|
||||
CancellationToken cancellationToken) => Task.FromResult(EmptyEvents);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a fully-disabled snapshot — no connections open, every counter zero, every nullable
|
||||
/// field null, no cluster nodes. Pure; never blocks.
|
||||
/// </summary>
|
||||
/// <inheritdoc />
|
||||
public HistorianHealthSnapshot GetHealthSnapshot() => new(
|
||||
TotalQueries: 0,
|
||||
TotalSuccesses: 0,
|
||||
|
||||
@@ -6,9 +6,9 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// as it discovers nodes — no buffering of the whole tree.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per <c>docs/v2/plan.md</c> decision #52 — drivers register nodes via this builder
|
||||
/// rather than returning a tree object. Supports incremental / large address spaces
|
||||
/// without forcing the driver to buffer the whole tree.
|
||||
/// Drivers register nodes via this builder rather than returning a tree object,
|
||||
/// supporting incremental / large address spaces without forcing the driver to
|
||||
/// buffer the whole tree.
|
||||
/// </remarks>
|
||||
public interface IAddressSpaceBuilder
|
||||
{
|
||||
@@ -18,6 +18,7 @@ public interface IAddressSpaceBuilder
|
||||
/// </summary>
|
||||
/// <param name="browseName">OPC UA browse name (the segment of the path under the parent).</param>
|
||||
/// <param name="displayName">Human-readable display name. May equal <paramref name="browseName"/>.</param>
|
||||
/// <returns>A child builder scoped to inside the new folder.</returns>
|
||||
IAddressSpaceBuilder Folder(string browseName, string displayName);
|
||||
|
||||
/// <summary>
|
||||
@@ -27,6 +28,7 @@ public interface IAddressSpaceBuilder
|
||||
/// <param name="browseName">OPC UA browse name (the segment of the path under the parent folder).</param>
|
||||
/// <param name="displayName">Human-readable display name. May equal <paramref name="browseName"/>.</param>
|
||||
/// <param name="attributeInfo">Driver-side metadata for the variable.</param>
|
||||
/// <returns>A handle for the registered variable, used by Core for subscription routing.</returns>
|
||||
IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo);
|
||||
|
||||
/// <summary>
|
||||
@@ -56,6 +58,7 @@ public interface IVariableHandle
|
||||
/// <c>Acknowledge</c>, <c>Deactivate</c>).
|
||||
/// </summary>
|
||||
/// <param name="info">The alarm condition information.</param>
|
||||
/// <returns>The sink that receives lifecycle transitions for this alarm condition.</returns>
|
||||
IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ public interface IAlarmSource
|
||||
/// </summary>
|
||||
/// <param name="sourceNodeIds">The driver node IDs to subscribe to.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A handle identifying the created subscription.</returns>
|
||||
Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
||||
IReadOnlyList<string> sourceNodeIds,
|
||||
CancellationToken cancellationToken);
|
||||
@@ -20,11 +21,13 @@ public interface IAlarmSource
|
||||
/// <summary>Cancel an alarm subscription returned by <see cref="SubscribeAlarmsAsync"/>.</summary>
|
||||
/// <param name="handle">The subscription handle returned from <see cref="SubscribeAlarmsAsync"/>.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Acknowledge one or more active alarms by source node ID + condition ID.</summary>
|
||||
/// <param name="acknowledgements">The batch of alarm acknowledgement requests.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
@@ -8,10 +8,6 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// <see cref="IHostConnectivityProbe"/>) are composable — a driver implements only what its
|
||||
/// backend actually supports.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per <c>docs/v2/plan.md</c> decisions #4 (composable capability interfaces) and #53
|
||||
/// (capability discovery via <c>is</c> checks — no redundant flag enum).
|
||||
/// </remarks>
|
||||
public interface IDriver
|
||||
{
|
||||
/// <summary>Stable logical ID of this driver instance, sourced from the central config DB.</summary>
|
||||
@@ -23,6 +19,7 @@ public interface IDriver
|
||||
/// <summary>Initialize the driver from its <c>DriverConfig</c> JSON; open connections; prepare for first use.</summary>
|
||||
/// <param name="driverConfigJson">The driver configuration as JSON.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
@@ -37,13 +34,16 @@ public interface IDriver
|
||||
/// </remarks>
|
||||
/// <param name="driverConfigJson">The driver configuration as JSON.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Stop the driver, close connections, release resources. Called on shutdown or driver removal.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task ShutdownAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Current health snapshot, polled by Core for the status dashboard and ServiceLevel.</summary>
|
||||
/// <returns>The driver's current health snapshot.</returns>
|
||||
DriverHealth GetHealth();
|
||||
|
||||
/// <summary>
|
||||
@@ -56,6 +56,7 @@ public interface IDriver
|
||||
/// allocation tracking". Tier C drivers (process-isolated) report through the same
|
||||
/// interface but the cache-flush is internal to their host.
|
||||
/// </remarks>
|
||||
/// <returns>The approximate memory footprint in bytes.</returns>
|
||||
long GetMemoryFootprint();
|
||||
|
||||
/// <summary>
|
||||
@@ -63,5 +64,6 @@ public interface IDriver
|
||||
/// Required-for-correctness state must NOT be flushed.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task FlushOptionalCachesAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// JSON editor with schema-driven validation against the registered JSON schema.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per <c>docs/v2/plan.md</c> decision #27 — driver-specific config editors are deferred
|
||||
/// Driver-specific config editors are deferred
|
||||
/// to each driver's implementation phase; v2.0 ships with the generic JSON editor as the
|
||||
/// default. This interface is the future plug-point so phase-specific editors can land
|
||||
/// incrementally.
|
||||
|
||||
@@ -34,12 +34,8 @@ public sealed class NullDriverFactory : IDriverFactory
|
||||
public static readonly NullDriverFactory Instance = new();
|
||||
private NullDriverFactory() { }
|
||||
|
||||
/// <summary>Creates a driver (always returns null in this null implementation).</summary>
|
||||
/// <param name="driverType">The driver type name.</param>
|
||||
/// <param name="driverInstanceId">The driver instance identifier.</param>
|
||||
/// <param name="driverConfigJson">The driver configuration as a JSON string.</param>
|
||||
/// <returns>Always returns null.</returns>
|
||||
/// <inheritdoc />
|
||||
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) => null;
|
||||
/// <summary>Gets the collection of supported driver types (empty in this null implementation).</summary>
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyCollection<string> SupportedTypes { get; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ public interface IDriverHealthPublisher
|
||||
/// Publishes a health snapshot for one driver instance. Implementations must be
|
||||
/// non-blocking and tolerant of being called from any thread.
|
||||
/// </summary>
|
||||
/// <param name="clusterId">The cluster the driver instance belongs to.</param>
|
||||
/// <param name="driverInstanceId">The driver instance the snapshot describes.</param>
|
||||
/// <param name="health">The current health snapshot.</param>
|
||||
/// <param name="errorCount5Min">The number of errors observed in the trailing 5-minute window.</param>
|
||||
void Publish(
|
||||
string clusterId,
|
||||
string driverInstanceId,
|
||||
|
||||
@@ -17,6 +17,10 @@ public interface IDriverProbe
|
||||
/// timeout cancellation. Never throw on connection failure; instead return a result
|
||||
/// with <c>Ok = false</c> + a message.
|
||||
/// </summary>
|
||||
/// <param name="configJson">The driver-specific configuration JSON to probe with.</param>
|
||||
/// <param name="timeout">The maximum time to allow the probe to run.</param>
|
||||
/// <param name="ct">Cancellation token for the operation.</param>
|
||||
/// <returns>The probe outcome, including success/failure and latency.</returns>
|
||||
Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,10 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// hard fault is detected (memory breach, wedge, scheduled recycle window).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per <c>docs/v2/plan.md</c> decisions #68, #73-74, and #145. Tier A/B drivers do NOT have
|
||||
/// a supervisor because they run in-process — recycling would kill every OPC UA session and
|
||||
/// every co-hosted driver. The Core.Stability layer only invokes this interface for Tier C
|
||||
/// instances after asserting the tier via <see cref="DriverTypeMetadata.Tier"/>.
|
||||
/// Tier A/B drivers do NOT have a supervisor because they run in-process — recycling would
|
||||
/// kill every OPC UA session and every co-hosted driver. The Core.Stability layer only invokes
|
||||
/// this interface for Tier C instances after asserting the tier via
|
||||
/// <see cref="DriverTypeMetadata.Tier"/>.
|
||||
/// </remarks>
|
||||
public interface IDriverSupervisor
|
||||
{
|
||||
@@ -22,5 +22,6 @@ public interface IDriverSupervisor
|
||||
/// </summary>
|
||||
/// <param name="reason">Human-readable reason — flows into the supervisor's logs.</param>
|
||||
/// <param name="cancellationToken">Cancels the recycle request; an in-flight restart is not interrupted.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task RecycleAsync(string reason, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// a raw-only driver compiles without forcing it to provide at-time / event surfaces it
|
||||
/// has no backend for. The sibling server-side surface, <see cref="IHistorianDataSource"/>,
|
||||
/// declares both methods as required because a registered historian owns the full read
|
||||
/// surface; the asymmetry is intentional (Core.Abstractions-008).
|
||||
/// surface; the asymmetry is intentional.
|
||||
/// </remarks>
|
||||
public interface IHistoryProvider
|
||||
{
|
||||
@@ -91,7 +91,7 @@ public interface IHistoryProvider
|
||||
/// reads use for <c>maxValuesPerNode</c>) because callers and downstream historian
|
||||
/// adapters historically treat <c>maxEvents <= 0</c> as a sentinel meaning
|
||||
/// "use the backend's default cap" (see <c>WonderwareHistorianClient</c> /
|
||||
/// <c>HistorianDataSource</c>). The asymmetry is intentional — Core.Abstractions-006.
|
||||
/// <c>HistorianDataSource</c>). The asymmetry is intentional.
|
||||
///
|
||||
/// <b>Continuation contract when using the sentinel:</b> When <c>maxEvents <= 0</c>
|
||||
/// the backend applies its own cap. If the backend's cap truncates the result
|
||||
@@ -99,9 +99,9 @@ public interface IHistoryProvider
|
||||
/// <see cref="HistoricalEventsResult.ContinuationPoint"/> to a non-null token so
|
||||
/// callers can detect truncation and page. A null <c>ContinuationPoint</c> means all
|
||||
/// matching events were returned — callers rely on this to decide whether to page.
|
||||
/// (Core.Abstractions-009.)
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">Request cancellation.</param>
|
||||
/// <returns>A task that returns the historical events result containing matching event records and optional continuation point.</returns>
|
||||
/// <remarks>
|
||||
/// Default implementation throws. Drivers whose backend can serve historical events
|
||||
/// override: Galaxy (Wonderware Alarm & Events log) and the OPC UA Client driver
|
||||
|
||||
@@ -16,6 +16,7 @@ public interface IHostConnectivityProbe
|
||||
/// Snapshot of host-level connectivity. The Core uses this to drive Bad-quality
|
||||
/// fan-out scoped to the affected host's subtree (not the whole driver namespace).
|
||||
/// </summary>
|
||||
/// <returns>The current connectivity status of each known host.</returns>
|
||||
IReadOnlyList<HostConnectivityStatus> GetHostStatuses();
|
||||
|
||||
/// <summary>Fired when a host transitions Running ↔ Stopped (or similar lifecycle change).</summary>
|
||||
|
||||
@@ -9,8 +9,8 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Multi-host drivers (Modbus with N PLCs, hypothetical AB CIP across a rack, etc.)
|
||||
/// implement this so the Phase 6.1 resilience pipeline can be keyed on
|
||||
/// <c>(DriverInstanceId, ResolvedHostName, DriverCapability)</c> per decision #144. One
|
||||
/// implement this so the resilience pipeline can be keyed on
|
||||
/// <c>(DriverInstanceId, ResolvedHostName, DriverCapability)</c>. One
|
||||
/// dead PLC behind a multi-device Modbus driver then trips only its own breaker; healthy
|
||||
/// siblings keep serving.</para>
|
||||
///
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Reads are idempotent — Polly retry pipelines can safely retry on transient failures
|
||||
/// (per <c>docs/v2/plan.md</c> decisions #34 and #44).
|
||||
/// (per <c>docs/v2/plan.md</c>).
|
||||
/// </remarks>
|
||||
public interface IReadable
|
||||
{
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// symbol-version-changed) implement this to tell Core when to re-run discovery.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per <c>docs/v2/plan.md</c> decision #54 — static drivers (Modbus, S7, etc. whose tags
|
||||
/// Per <c>docs/v2/plan.md</c> — static drivers (Modbus, S7, etc. whose tags
|
||||
/// only change via a published config generation) don't implement <c>IRediscoverable</c>.
|
||||
/// The Core just sees absence of the interface and skips change-detection wiring for that driver.
|
||||
/// </remarks>
|
||||
|
||||
@@ -15,7 +15,7 @@ public enum DiscoveryRediscoverPolicy
|
||||
/// <summary>
|
||||
/// Driver capability for discovering tags and hierarchy from the backend.
|
||||
/// Streams discovered nodes into <see cref="IAddressSpaceBuilder"/> rather than
|
||||
/// buffering the entire tree (decision #52 — supports incremental / large address spaces).
|
||||
/// buffering the entire tree (supports incremental / large address spaces).
|
||||
/// </summary>
|
||||
public interface ITagDiscovery
|
||||
{
|
||||
@@ -25,6 +25,7 @@ public interface ITagDiscovery
|
||||
/// </summary>
|
||||
/// <param name="builder">The address space builder to stream discovered nodes into.</param>
|
||||
/// <param name="cancellationToken">A cancellation token for the discovery operation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Post-connect re-discovery policy. Default preserves the original retry-until-stable behavior.</summary>
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// historian-only adapter, for example) can omit this.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per <c>docs/v2/plan.md</c> decisions #44 + #45 — <b>writes are NOT auto-retried by default</b>.
|
||||
/// Per <c>docs/v2/plan.md</c>, <b>writes are NOT auto-retried by default</b>.
|
||||
/// A timeout may fire after the device already accepted the command; replaying non-idempotent
|
||||
/// field actions (pulses, alarm acks, recipe steps, counter increments) can cause duplicate
|
||||
/// operations. Per-tag opt-in via <c>Tag.WriteIdempotent = true</c> in the central config DB
|
||||
@@ -19,6 +19,7 @@ public interface IWritable
|
||||
/// </summary>
|
||||
/// <param name="writes">Pairs of full reference + value to write.</param>
|
||||
/// <param name="cancellationToken">Cancellation token; the driver should abort the batch if cancelled.</param>
|
||||
/// <returns>One <see cref="WriteResult"/> per requested write, in the same order.</returns>
|
||||
Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
IReadOnlyList<WriteRequest> writes,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
@@ -2,8 +2,8 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Every OPC UA operation surface the Phase 6.2 authorization evaluator gates, per
|
||||
/// <c>docs/v2/implementation/phase-6-2-authorization-runtime.md</c> §Stream C and
|
||||
/// decision #143. The evaluator maps each operation onto the corresponding
|
||||
/// <c>docs/v2/implementation/phase-6-2-authorization-runtime.md</c> §Stream C.
|
||||
/// The evaluator maps each operation onto the corresponding
|
||||
/// <c>NodePermissions</c> bit(s) to decide whether the calling session is allowed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
|
||||
@@ -41,6 +41,7 @@ public sealed class PollGroupEngine : IAsyncDisposable
|
||||
/// <summary>Default floor for publishing intervals — matches the Modbus 100 ms cap.</summary>
|
||||
public static readonly TimeSpan DefaultMinInterval = TimeSpan.FromMilliseconds(100);
|
||||
|
||||
/// <summary>Constructs the engine with the driver-supplied reader, change callback, and optional interval floor / error sink.</summary>
|
||||
/// <param name="reader">Driver-supplied batch reader; snapshots MUST be returned in the same
|
||||
/// order as the input references.</param>
|
||||
/// <param name="onChange">Callback invoked per changed tag — the driver forwards to its own
|
||||
@@ -49,7 +50,7 @@ public sealed class PollGroupEngine : IAsyncDisposable
|
||||
/// per <see cref="DefaultMinInterval"/>.</param>
|
||||
/// <param name="onError">Optional error sink — invoked once per caught reader exception (or
|
||||
/// internal contract-violation throw) so the owning driver can route the failure to its
|
||||
/// health surface (Core.Abstractions-005). Defensive: an <c>onError</c> handler that
|
||||
/// health surface. Defensive: an <c>onError</c> handler that
|
||||
/// itself throws is silently absorbed so a buggy forwarder cannot crash the poll loop.</param>
|
||||
public PollGroupEngine(
|
||||
Func<IReadOnlyList<string>, CancellationToken, Task<IReadOnlyList<DataValueSnapshot>>> reader,
|
||||
@@ -68,6 +69,7 @@ public sealed class PollGroupEngine : IAsyncDisposable
|
||||
/// <summary>Register a new polled subscription and start its background loop.</summary>
|
||||
/// <param name="fullReferences">The list of tag references to poll.</param>
|
||||
/// <param name="publishingInterval">The desired polling interval; will be clamped to the configured minimum.</param>
|
||||
/// <returns>A handle identifying the new subscription, for later use with <see cref="Unsubscribe"/>.</returns>
|
||||
public ISubscriptionHandle Subscribe(IReadOnlyList<string> fullReferences, TimeSpan publishingInterval)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fullReferences);
|
||||
@@ -138,7 +140,7 @@ public sealed class PollGroupEngine : IAsyncDisposable
|
||||
catch (Exception ex) when (!IsFatal(ex))
|
||||
{
|
||||
// transient poll error — loop continues, driver health surface logs it
|
||||
// via the supplied onError callback (Core.Abstractions-005).
|
||||
// via the supplied onError callback.
|
||||
ReportError(ex);
|
||||
}
|
||||
}
|
||||
@@ -170,7 +172,7 @@ public sealed class PollGroupEngine : IAsyncDisposable
|
||||
{
|
||||
var snapshots = await _reader(state.TagReferences, ct).ConfigureAwait(false);
|
||||
|
||||
// Core.Abstractions-002: validate the reader contract before indexing. A reader that
|
||||
// Validate the reader contract before indexing. A reader that
|
||||
// returns fewer snapshots than references would silently stall the subscription; surface
|
||||
// the violation immediately with a descriptive exception instead.
|
||||
if (snapshots.Count != state.TagReferences.Count)
|
||||
@@ -207,6 +209,7 @@ public sealed class PollGroupEngine : IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <summary>Cancel every active subscription and await all loop tasks. Idempotent.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
// Cancel all loops first so they can all start winding down in parallel.
|
||||
@@ -253,7 +256,7 @@ public sealed class PollGroupEngine : IAsyncDisposable
|
||||
|
||||
private sealed record PollSubscriptionHandle(long Id) : ISubscriptionHandle
|
||||
{
|
||||
/// <summary>Gets a diagnostic identifier for this subscription.</summary>
|
||||
/// <inheritdoc />
|
||||
public string DiagnosticId => $"poll-sub-{Id}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// recipe-step advances can duplicate irreversible field actions.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per <c>docs/v2/plan.md</c> decisions #44, #45, and #143. Applied to tag-definition POCOs
|
||||
/// Applied to tag-definition POCOs
|
||||
/// (e.g. <c>ModbusTagDefinition</c>, <c>S7TagDefinition</c>, OPC UA client tag rows) at the
|
||||
/// property or record level. The <c>CapabilityInvoker</c> in <c>ZB.MOM.WW.OtOpcUa.Core.Resilience</c>
|
||||
/// reads this attribute via reflection once at driver-init time and caches the result; no
|
||||
|
||||
@@ -4,8 +4,8 @@ namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
|
||||
/// <summary>
|
||||
/// The event shape the historian sink consumes — source-agnostic across scripted
|
||||
/// alarms + Galaxy-native + AB CIP ALMD + any future IAlarmSource per Phase 7 plan
|
||||
/// decision #15 (sink scope = all alarm sources, not just scripted). A per-alarm
|
||||
/// alarms + Galaxy-native + AB CIP ALMD + any future IAlarmSource (sink scope = all
|
||||
/// alarm sources, not just scripted). A per-alarm
|
||||
/// <c>HistorizeToAveva</c> toggle on the producer side gates which events flow.
|
||||
/// </summary>
|
||||
/// <param name="AlarmId">Stable condition identity.</param>
|
||||
|
||||
@@ -11,8 +11,8 @@ namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
/// <see cref="EnqueueAsync"/> is fire-and-forget from the engine's perspective —
|
||||
/// the sink MUST NOT block the emitting thread. Production implementations
|
||||
/// (<see cref="SqliteStoreAndForwardSink"/>) persist to a local SQLite queue
|
||||
/// first, then drain asynchronously to the actual historian. Per Phase 7 plan
|
||||
/// decision #16, failed downstream writes replay with exponential backoff;
|
||||
/// first, then drain asynchronously to the actual historian. Per the Phase 7 plan,
|
||||
/// failed downstream writes replay with exponential backoff;
|
||||
/// operator actions are never blocked waiting on the historian.
|
||||
/// </para>
|
||||
/// <para>
|
||||
@@ -25,9 +25,11 @@ public interface IAlarmHistorianSink
|
||||
/// <summary>Durably enqueue the event. Returns as soon as the queue row is committed.</summary>
|
||||
/// <param name="evt">The alarm historian event to enqueue.</param>
|
||||
/// <param name="cancellationToken">A cancellation token for async operations.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task EnqueueAsync(AlarmHistorianEvent evt, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Snapshot of current queue depth + drain health.</summary>
|
||||
/// <returns>The current queue depth and drain health snapshot.</returns>
|
||||
HistorianSinkStatus GetStatus();
|
||||
}
|
||||
|
||||
@@ -96,6 +98,7 @@ public interface IAlarmHistorianWriter
|
||||
/// <summary>Push a batch of events to the historian. Returns one outcome per event, same order.</summary>
|
||||
/// <param name="batch">The batch of alarm historian events to write.</param>
|
||||
/// <param name="cancellationToken">A cancellation token for async operations.</param>
|
||||
/// <returns>One <see cref="HistorianWriteOutcome"/> per event, in the same order as <paramref name="batch"/>.</returns>
|
||||
Task<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(
|
||||
IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -5,11 +5,10 @@ using Serilog;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 7 plan decisions #16–#17 implementation: durable SQLite queue on the node
|
||||
/// absorbs every qualifying alarm event, a drain worker batches rows to the
|
||||
/// Wonderware historian sidecar via <see cref="IAlarmHistorianWriter"/> on an
|
||||
/// exponential-backoff cadence, and operator acks never block on the historian
|
||||
/// being reachable.
|
||||
/// Durable SQLite queue on the node absorbs every qualifying alarm event, a drain
|
||||
/// worker batches rows to the Wonderware historian sidecar via
|
||||
/// <see cref="IAlarmHistorianWriter"/> on an exponential-backoff cadence, and
|
||||
/// operator acks never block on the historian being reachable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -27,7 +26,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
/// );
|
||||
/// </code>
|
||||
/// Dead-lettered rows stay in place for the configured retention window (default
|
||||
/// 30 days per Phase 7 plan decision #21) so operators can inspect + manually
|
||||
/// 30 days) so operators can inspect + manually
|
||||
/// retry before the sweeper purges them. Regular queue capacity is bounded —
|
||||
/// overflow evicts the oldest non-dead-lettered rows with a WARN log. The
|
||||
/// durability guarantee is therefore bounded by <see cref="DefaultCapacity"/>:
|
||||
@@ -77,29 +76,16 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
|
||||
private TimeSpan _tickInterval;
|
||||
private volatile int _backoffIndex;
|
||||
// volatile: read by EnqueueAsync, DrainOnceAsync, RescheduleDrain, and StartDrainLoop
|
||||
// from different threads; Dispose() writes it from the owner's thread (Core.AlarmHistorian-013).
|
||||
// from different threads; Dispose() writes it from the owner's thread.
|
||||
private volatile bool _disposed;
|
||||
|
||||
// Core.AlarmHistorian-005: status fields written by the drain timer thread and
|
||||
// read concurrently by GetStatus() / health-check threads. Guard all reads and
|
||||
// writes with this lock so the Admin UI never observes a torn or stale value.
|
||||
private readonly object _statusLock = new();
|
||||
private DateTime? _lastDrainUtc;
|
||||
private DateTime? _lastSuccessUtc;
|
||||
private string? _lastError;
|
||||
private HistorianDrainState _drainState = HistorianDrainState.Idle;
|
||||
// Core.AlarmHistorian-009: lifetime counter of rows evicted due to capacity overflow.
|
||||
// Surfaces in HistorianSinkStatus so operators can see data-loss events without
|
||||
// having to scrape the WARN log.
|
||||
private long _evictedCount;
|
||||
|
||||
// Core.AlarmHistorian-008: keep an approximate in-memory count of non-dead-lettered
|
||||
// rows so EnqueueAsync does not need to run a SELECT COUNT(*) on every call. The
|
||||
// counter is seeded from storage at construction, kept current by every mutation
|
||||
// (Enqueue, Drain, RetryDeadLettered, PurgeAgedDeadLetters, EnforceCapacity), and
|
||||
// periodically re-synced from storage as a safety net against drift.
|
||||
// Mutations cross threads (EnqueueAsync is called from the emitting thread, drain
|
||||
// runs on the timer / drain thread) so it is updated via Interlocked.
|
||||
private long _queuedRowCount;
|
||||
// Probe counter — incremented every time we actually issue a real COUNT(*) for
|
||||
// capacity enforcement. Public for test instrumentation only.
|
||||
@@ -145,7 +131,7 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
|
||||
// DefaultTimeout gives ADO.NET command-level retry; the PRAGMA busy_timeout
|
||||
// applied in OpenConnection backs it with SQLite's own busy-handler so an
|
||||
// enqueue/drain collision waits out the file lock instead of throwing
|
||||
// SQLITE_BUSY immediately (Core.AlarmHistorian-004).
|
||||
// SQLITE_BUSY immediately.
|
||||
_connectionString = new SqliteConnectionStringBuilder
|
||||
{
|
||||
DataSource = databasePath,
|
||||
@@ -153,8 +139,6 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
|
||||
}.ToString();
|
||||
|
||||
InitializeSchema();
|
||||
// Core.AlarmHistorian-008: seed the in-memory counter from storage so the
|
||||
// perf-optimised EnqueueAsync path starts in sync with what's on disk.
|
||||
_queuedRowCount = ProbeQueuedRowCount();
|
||||
}
|
||||
|
||||
@@ -195,11 +179,11 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
|
||||
/// <remarks>
|
||||
/// The worker is a self-rescheduling one-shot <see cref="Timer"/>: after each
|
||||
/// drain it sets its next due-time to <c>max(tickInterval, CurrentBackoff)</c>
|
||||
/// so a historian outage actually slows the cadence down the backoff ladder
|
||||
/// (Core.AlarmHistorian-002). The callback body is fully guarded — a fault in
|
||||
/// so a historian outage actually slows the cadence down the backoff ladder.
|
||||
/// The callback body is fully guarded — a fault in
|
||||
/// <see cref="DrainOnceAsync"/> is logged and recorded into
|
||||
/// <see cref="GetStatus"/> rather than being lost as an unobserved task
|
||||
/// exception (Core.AlarmHistorian-006).
|
||||
/// exception.
|
||||
/// </remarks>
|
||||
/// <param name="tickInterval">The base interval between drain attempts.</param>
|
||||
public void StartDrainLoop(TimeSpan tickInterval)
|
||||
@@ -251,19 +235,7 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
|
||||
catch (ObjectDisposedException) { /* raced with Dispose — nothing to re-arm */ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enqueues an alarm historian event asynchronously for forwarding to the historian.
|
||||
/// Respects the queue capacity and enforces eviction of oldest rows when full.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Core.AlarmHistorian-003: use async SQLite APIs so the emitting thread is not
|
||||
/// blocked waiting for a file-lock or disk write; honor the cancellationToken
|
||||
/// throughout. Microsoft.Data.Sqlite's async surface (OpenAsync /
|
||||
/// ExecuteNonQueryAsync) is a thin wrapper over the synchronous path, so the
|
||||
/// blocking still happens — but on a thread-pool thread, not the caller's thread.
|
||||
/// </remarks>
|
||||
/// <param name="evt">The alarm historian event to enqueue.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <inheritdoc />
|
||||
public async Task EnqueueAsync(AlarmHistorianEvent evt, CancellationToken cancellationToken)
|
||||
{
|
||||
if (evt is null) throw new ArgumentNullException(nameof(evt));
|
||||
@@ -273,10 +245,6 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
|
||||
await conn.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
await ApplyPragmasAsync(conn, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Core.AlarmHistorian-008: use the in-memory counter to short-circuit the
|
||||
// capacity check on every enqueue. The bare hot path is now one INSERT — no
|
||||
// SELECT COUNT(*). We fall back to a real probe only when the cached counter
|
||||
// says we're at or above capacity, or periodically to defend against drift.
|
||||
await EnforceCapacityFastPathAsync(conn, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var cmd = conn.CreateCommand();
|
||||
@@ -347,13 +315,8 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
|
||||
/// on RetryPlease. Safe to call from multiple threads; the semaphore enforces
|
||||
/// serial execution.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Core.AlarmHistorian-008: every per-tick SQLite operation runs through a
|
||||
/// single shared connection (purge, read, corrupt-row dead-letter, and the
|
||||
/// outcome-applying transaction). Pre-fix the drain opened three independent
|
||||
/// connections per tick, each paying the open + PRAGMA cost.
|
||||
/// </remarks>
|
||||
/// <param name="ct">Cancellation token for the operation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task DrainOnceAsync(CancellationToken ct)
|
||||
{
|
||||
if (_disposed) return;
|
||||
@@ -391,8 +354,6 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
|
||||
foreach (var rowId in corruptRowIds)
|
||||
DeadLetterRow(conn, corruptTx, rowId, $"corrupt payload at {_clock():O}");
|
||||
corruptTx.Commit();
|
||||
// Each corrupt row leaves the non-dead-lettered queue — bookkeeping for
|
||||
// the in-memory counter (Core.AlarmHistorian-008).
|
||||
Interlocked.Add(ref _queuedRowCount, -corruptRowIds.Count);
|
||||
_logger.Warning(
|
||||
"Dead-lettered {Count} historian queue row(s) with un-deserializable payload",
|
||||
@@ -413,9 +374,6 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Core.AlarmHistorian-012: reset _drainState so the status surface does
|
||||
// not stay stuck at Draining after the cancellation unwinds the method.
|
||||
// The row stays queued; the next drain tick will retry it.
|
||||
lock (_statusLock) { _drainState = HistorianDrainState.BackingOff; }
|
||||
throw;
|
||||
}
|
||||
@@ -432,13 +390,6 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
// Core.AlarmHistorian-007: a cardinality mismatch is a writer contract
|
||||
// violation — potentially the events were already persisted. Rather than
|
||||
// throwing (which, pre -006 fix, was swallowed and left _drainState
|
||||
// stale), treat it as a transient batch failure so the rows stay queued
|
||||
// and the backoff surface becomes visible to the operator. A deterministic
|
||||
// mismatch will stall the row until an operator intervenes or the writer
|
||||
// is fixed — far safer than re-throwing into a fire-and-forget timer.
|
||||
if (outcomes.Count != events.Count)
|
||||
{
|
||||
var msg = $"Writer returned {outcomes.Count} outcomes for {events.Count} events — expected 1:1; treating as batch retry";
|
||||
@@ -491,7 +442,7 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
|
||||
}
|
||||
// Ack-deleted + PermanentFail-dead-lettered rows both leave the
|
||||
// non-dead-lettered queue, as do RetryPlease rows that hit the max-attempts
|
||||
// cap (finding 002) — keep the counter aligned (Core.AlarmHistorian-008).
|
||||
// cap (finding 002) — keep the counter aligned.
|
||||
if (rowsLeavingQueue > 0)
|
||||
Interlocked.Add(ref _queuedRowCount, -rowsLeavingQueue);
|
||||
|
||||
@@ -517,14 +468,10 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Gets the current status of the historian sink including queue depth and drain state.</summary>
|
||||
/// <inheritdoc />
|
||||
public HistorianSinkStatus GetStatus()
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(SqliteStoreAndForwardSink));
|
||||
// Core.AlarmHistorian-008: read the non-dead-lettered count from the in-memory
|
||||
// counter so a busy Admin UI / health probe does not hammer the DB. Dead-letter
|
||||
// depth is rare-path only (it lives in the queue until retention) so a real
|
||||
// COUNT(*) on a single combined connection is fine.
|
||||
var queued = Interlocked.Read(ref _queuedRowCount);
|
||||
if (queued < 0) queued = 0;
|
||||
|
||||
@@ -536,8 +483,6 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
|
||||
deadlettered = (long)(cmd.ExecuteScalar() ?? 0L);
|
||||
}
|
||||
|
||||
// Core.AlarmHistorian-005: snapshot status fields atomically under the lock
|
||||
// so the Admin UI never sees a torn DateTime? or stale DrainState.
|
||||
DateTime? lastDrain, lastSuccess;
|
||||
string? lastError;
|
||||
HistorianDrainState drainState;
|
||||
@@ -562,6 +507,7 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>Operator action from Admin UI — retry every dead-lettered row. Non-cascading: they rejoin the regular queue + get a fresh backoff.</summary>
|
||||
/// <returns>The number of rows revived from the dead-letter state.</returns>
|
||||
public int RetryDeadLettered()
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(SqliteStoreAndForwardSink));
|
||||
@@ -570,7 +516,7 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
|
||||
cmd.CommandText = "UPDATE Queue SET DeadLettered = 0, AttemptCount = 0, LastError = NULL WHERE DeadLettered = 1";
|
||||
var revived = cmd.ExecuteNonQuery();
|
||||
// Dead-lettered rows rejoin the non-dead-lettered queue — keep the in-memory
|
||||
// counter aligned (Core.AlarmHistorian-008).
|
||||
// counter aligned.
|
||||
if (revived > 0) Interlocked.Add(ref _queuedRowCount, revived);
|
||||
return revived;
|
||||
}
|
||||
@@ -652,10 +598,7 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// Async variant used by EnqueueAsync (Core.AlarmHistorian-003).
|
||||
// Core.AlarmHistorian-008: the precise path — runs COUNT(*) to compute the exact
|
||||
// number of rows to evict. Reached only from the fast-path fallback when the
|
||||
// in-memory counter says we are at or above capacity.
|
||||
// Async variant used by EnqueueAsync.
|
||||
private async Task EnforceCapacityAsync(SqliteConnection conn, CancellationToken ct)
|
||||
{
|
||||
Interlocked.Increment(ref _capacityProbeCount);
|
||||
|
||||
@@ -5,12 +5,12 @@ namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
/// <summary>
|
||||
/// Persistent per-alarm state tracked by the Part 9 state machine. Every field
|
||||
/// carried here either participates in the state machine or contributes to the
|
||||
/// audit trail required by Phase 7 plan decision #14 (GxP / 21 CFR Part 11).
|
||||
/// audit trail required for GxP / 21 CFR Part 11 compliance.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="Active"/> is re-derived from the predicate at startup per Phase 7
|
||||
/// decision #14 — the engine runs every alarm's predicate against current tag
|
||||
/// <see cref="Active"/> is re-derived from the predicate at startup — the engine
|
||||
/// runs every alarm's predicate against current tag
|
||||
/// values at <c>Load</c>, overriding whatever Active state is in the store.
|
||||
/// Every other state field persists verbatim across server restarts so
|
||||
/// operators don't re-ack active alarms after an outage + shelved alarms stay
|
||||
@@ -22,7 +22,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
/// entries. The runtime type is <see cref="ImmutableList{AlarmComment}"/> so
|
||||
/// each append is O(log n) rather than the O(n) copy a plain
|
||||
/// <c>IReadOnlyList<AlarmComment></c> would force on every audit-producing
|
||||
/// transition. (Core.ScriptedAlarms-008)
|
||||
/// transition.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record AlarmConditionState(
|
||||
|
||||
@@ -3,8 +3,8 @@ using System.Collections.Concurrent;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
|
||||
/// <summary>
|
||||
/// Persistence for <see cref="AlarmConditionState"/> across server restarts. Phase 7
|
||||
/// plan decision #14: operator-supplied state (EnabledState / AckedState /
|
||||
/// Persistence for <see cref="AlarmConditionState"/> across server restarts.
|
||||
/// Operator-supplied state (EnabledState / AckedState /
|
||||
/// ConfirmedState / ShelvingState + audit trail) persists; ActiveState is
|
||||
/// recomputed from the live predicate on startup so operators never re-ack.
|
||||
/// </summary>
|
||||
|
||||
@@ -4,8 +4,8 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
|
||||
/// <summary>
|
||||
/// Per Phase 7 plan decision #13, alarm messages are static-with-substitution
|
||||
/// templates. The engine resolves <c>{TagPath}</c> tokens at event emission time
|
||||
/// Alarm messages are static-with-substitution templates. The engine resolves
|
||||
/// <c>{TagPath}</c> tokens at event emission time
|
||||
/// against current tag values; unresolvable tokens become <c>{?}</c> so the event
|
||||
/// still fires but the operator sees where the reference broke.
|
||||
/// </summary>
|
||||
@@ -41,7 +41,6 @@ public static class MessageTemplate
|
||||
/// inspect, but the operator-facing message must make doubt explicit rather
|
||||
/// than substituting a value an operator might act on. See the
|
||||
/// "Input-quality policy" section in <c>docs/ScriptedAlarms.md</c>.
|
||||
/// (Core.ScriptedAlarms-010)
|
||||
/// </remarks>
|
||||
/// <param name="template">The template string with {path} tokens.</param>
|
||||
/// <param name="resolveTag">A function to resolve tag values by path.</param>
|
||||
|
||||
@@ -340,7 +340,6 @@ public static class Part9StateMachine
|
||||
/// no operator intent recorded — e.g. a predicate re-evaluation that
|
||||
/// confirms the existing active state) leave <see cref="NoOpReason"/>
|
||||
/// null because there is nothing to surface to an operator.
|
||||
/// (Core.ScriptedAlarms-011)
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record TransitionResult(AlarmConditionState State, EmissionKind Emission, string? NoOpReason = null)
|
||||
|
||||
@@ -17,15 +17,15 @@ namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
/// </param>
|
||||
/// <param name="AlarmName">Human-readable alarm name — used in the browse tree + Admin UI.</param>
|
||||
/// <param name="Kind">Concrete OPC UA Part 9 subtype the alarm materializes as.</param>
|
||||
/// <param name="Severity">Static severity per Phase 7 plan decision #13; not currently computed by the predicate.</param>
|
||||
/// <param name="Severity">Static severity; not currently computed by the predicate.</param>
|
||||
/// <param name="MessageTemplate">
|
||||
/// Message text with <c>{TagPath}</c> tokens resolved at event-emission time per
|
||||
/// Phase 7 plan decision #13. Unresolvable tokens emit <c>{?}</c> + a structured
|
||||
/// Message text with <c>{TagPath}</c> tokens resolved at event-emission time.
|
||||
/// Unresolvable tokens emit <c>{?}</c> + a structured
|
||||
/// error so operators can spot stale references.
|
||||
/// </param>
|
||||
/// <param name="PredicateScriptSource">
|
||||
/// Roslyn C# script returning <c>bool</c>. <c>true</c> = alarm condition currently holds (active);
|
||||
/// <c>false</c> = condition has cleared. Same sandbox rules as virtual tags per Phase 7 decision #6.
|
||||
/// <c>false</c> = condition has cleared. Same sandbox rules as virtual tags.
|
||||
/// </param>
|
||||
/// <param name="HistorizeToAveva">
|
||||
/// When true, every transition emission of this alarm flows to the Historian alarm
|
||||
|
||||
@@ -46,7 +46,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
// A plain Dictionary read concurrent with a writer's entry reassignment can
|
||||
// throw or return torn state; ConcurrentDictionary makes entry assignment and
|
||||
// snapshot enumeration safe. The only write shapes are indexer-set and Clear,
|
||||
// both of which ConcurrentDictionary supports atomically. (Core.ScriptedAlarms-001)
|
||||
// both of which ConcurrentDictionary supports atomically.
|
||||
private readonly ConcurrentDictionary<string, AlarmState> _alarms = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
@@ -58,7 +58,6 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// runs under <see cref="_evalGate"/>, which serialises every evaluation:
|
||||
/// two threads can never observe the same scratch in a half-refilled state.
|
||||
/// Cleared in <see cref="LoadAsync"/> alongside <see cref="_alarms"/>.
|
||||
/// (Core.ScriptedAlarms-009)
|
||||
/// </summary>
|
||||
private readonly ConcurrentDictionary<string, AlarmScratch> _scratchByAlarmId =
|
||||
new(StringComparer.Ordinal);
|
||||
@@ -67,11 +66,11 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// Compile cache for every alarm predicate. Routes <see cref="LoadAsync"/>'s
|
||||
/// <see cref="ScriptEvaluator{TContext, TResult}.Compile"/> calls through the
|
||||
/// cache so the collectible <see cref="System.Runtime.Loader.AssemblyLoadContext"/>
|
||||
/// each compile produces is actually disposed on the publish-replace path
|
||||
/// (Core.Scripting-016): the cache's <see cref="CompiledScriptCache{TContext, TResult}.Clear"/>
|
||||
/// each compile produces is actually disposed on the publish-replace path:
|
||||
/// the cache's <see cref="CompiledScriptCache{TContext, TResult}.Clear"/>
|
||||
/// disposes every materialised evaluator before dropping its dictionary entry,
|
||||
/// so a config-publish releases the prior generation's ALCs and the per-publish
|
||||
/// accretion the Core.Scripting-008 fix targeted is actually freed in production.
|
||||
/// accretion the prior fix targeted is actually freed in production.
|
||||
/// Pre-fix the engine called <c>ScriptEvaluator.Compile</c> directly, which left
|
||||
/// the ALCs rooted until the process exited — defeating -008 on the real path.
|
||||
/// </summary>
|
||||
@@ -79,9 +78,9 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
|
||||
/// <summary>
|
||||
/// Test-only diagnostic: returns the per-alarm scratch read-cache dictionary
|
||||
/// if one has been allocated, else null. Used by Core.ScriptedAlarms-009
|
||||
/// regression tests to assert the scratch is reused across evaluations
|
||||
/// (two reads return the same instance).
|
||||
/// if one has been allocated, else null. Used by regression tests to assert
|
||||
/// the scratch is reused across evaluations (two reads return the same
|
||||
/// instance).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Synchronization:</b> the returned <see cref="IReadOnlyDictionary{TKey, TValue}"/>
|
||||
@@ -94,9 +93,10 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// comparisons (e.g. asserting the same instance is reused across calls),
|
||||
/// and single-key reads against an engine that has quiesced after a
|
||||
/// deterministic upstream push. Anything more involved should snapshot a
|
||||
/// copy under the gate. (Core.ScriptedAlarms-013.)
|
||||
/// copy under the gate.
|
||||
/// </remarks>
|
||||
/// <param name="alarmId">The alarm identifier to look up.</param>
|
||||
/// <returns>The alarm's read-cache dictionary if one has been allocated; otherwise null.</returns>
|
||||
internal IReadOnlyDictionary<string, DataValueSnapshot>? TryGetScratchReadCacheForTest(string alarmId)
|
||||
=> _scratchByAlarmId.TryGetValue(alarmId, out var s) ? s.ReadCache : null;
|
||||
|
||||
@@ -110,9 +110,9 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// read-cache as <see cref="TryGetScratchReadCacheForTest"/> — the same
|
||||
/// "don't iterate during an in-flight evaluation" caveat applies. Safe
|
||||
/// for reference-identity assertions on a quiesced engine.
|
||||
/// (Core.ScriptedAlarms-013.)
|
||||
/// </remarks>
|
||||
/// <param name="alarmId">The alarm identifier to look up.</param>
|
||||
/// <returns>The alarm's predicate context if one has been allocated; otherwise null.</returns>
|
||||
internal AlarmPredicateContext? TryGetScratchContextForTest(string alarmId)
|
||||
=> _scratchByAlarmId.TryGetValue(alarmId, out var s) ? s.Context : null;
|
||||
private readonly ConcurrentDictionary<string, DataValueSnapshot> _valueCache
|
||||
@@ -131,7 +131,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
// these so a re-evaluation in flight when shutdown begins finishes its
|
||||
// SaveAsync before the engine returns control to the caller. The HashSet is
|
||||
// accessed under its own lock — never under _evalGate — so registration /
|
||||
// unregistration cannot deadlock against the gate. (Core.ScriptedAlarms-006)
|
||||
// unregistration cannot deadlock against the gate.
|
||||
private readonly HashSet<Task> _inFlight = [];
|
||||
private readonly object _inFlightLock = new();
|
||||
|
||||
@@ -171,10 +171,11 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// compile failures into one <see cref="InvalidOperationException"/>, subscribes
|
||||
/// to upstream input tags, seeds the value cache, loads persisted state from
|
||||
/// the store (falling back to Fresh for first-load alarms), and recomputes
|
||||
/// ActiveState per Phase 7 plan decision #14 (startup recovery).
|
||||
/// ActiveState from the current predicate (startup recovery).
|
||||
/// </summary>
|
||||
/// <param name="definitions">The alarm definitions to load.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A task that completes once every definition has been loaded and state restored.</returns>
|
||||
public async Task LoadAsync(IReadOnlyList<ScriptedAlarmDefinition> definitions, CancellationToken ct)
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(ScriptedAlarmEngine));
|
||||
@@ -191,7 +192,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
// assignment at the bottom of the try block. Without this, the old timer
|
||||
// keeps firing against the partially-cleared _alarms until Dispose() is
|
||||
// eventually called — not a permanent leak, but an unexpected side effect
|
||||
// during the window. (Core.ScriptedAlarms-015)
|
||||
// during the window.
|
||||
_shelvingTimer?.Dispose();
|
||||
_shelvingTimer = null;
|
||||
|
||||
@@ -200,11 +201,11 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
_alarmsReferencing.Clear();
|
||||
// Drop the prior generation's per-alarm scratch buffers — definitions may
|
||||
// have changed (different Inputs, different Logger), so any reuse would be
|
||||
// unsafe. (Core.ScriptedAlarms-009)
|
||||
// unsafe.
|
||||
_scratchByAlarmId.Clear();
|
||||
// Dispose every compiled-predicate ALC from the prior generation BEFORE we
|
||||
// recompile this one. Skipping this is what made Core.Scripting-008 a
|
||||
// no-op in production. (Core.Scripting-016)
|
||||
// recompile this one. Skipping this is what made the earlier fix a
|
||||
// no-op in production.
|
||||
_compileCache.Clear();
|
||||
|
||||
var compileFailures = new List<string>();
|
||||
@@ -222,7 +223,6 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
|
||||
// Route through CompiledScriptCache so the emitted assembly's
|
||||
// collectible ALC participates in publish-replace cleanup.
|
||||
// (Core.Scripting-016)
|
||||
var evaluator = _compileCache.GetOrCompile(def.PredicateScriptSource);
|
||||
var timed = new TimedScriptEvaluator<AlarmPredicateContext, bool>(evaluator, _scriptTimeout);
|
||||
var logger = _loggerFactory.Create(def.AlarmId);
|
||||
@@ -256,21 +256,20 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
|
||||
// Seed the value cache with current tag values before subscribing. The
|
||||
// ReadTag calls happen first so that the initial predicate evaluation below
|
||||
// (startup recovery, decision #14) uses a consistent snapshot.
|
||||
// (startup recovery) uses a consistent snapshot.
|
||||
// Subscriptions are established AFTER _loaded = true so that any synchronous
|
||||
// initial-push an ITagUpstreamSource delivers from inside SubscribeTag arrives
|
||||
// when _alarms is fully initialised. Before _loaded = true, a synchronous push
|
||||
// would race the in-progress state restore and could overwrite the carefully
|
||||
// seeded cache with a push that has no defined ordering relative to ReadTag.
|
||||
// (Core.ScriptedAlarms-004)
|
||||
foreach (var path in _alarmsReferencing.Keys)
|
||||
_valueCache[path] = _upstream.ReadTag(path);
|
||||
|
||||
// Restore persisted state, falling back to Fresh where nothing was saved,
|
||||
// then re-derive ActiveState from the current predicate per decision #14.
|
||||
// then re-derive ActiveState from the current predicate.
|
||||
// Any predicate emissions queue into `pending` and fire after the gate
|
||||
// is released — so a startup-recovery activation event can call back into
|
||||
// the engine without deadlocking. (Core.ScriptedAlarms-003)
|
||||
// the engine without deadlocking.
|
||||
foreach (var (alarmId, state) in _alarms)
|
||||
{
|
||||
var persisted = await _store.LoadAsync(alarmId, ct).ConfigureAwait(false);
|
||||
@@ -294,8 +293,8 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
|
||||
// Start the shelving-check timer — ticks every 5s, expires any timed shelves
|
||||
// that have passed their UnshelveAtUtc. The prior timer was already disposed
|
||||
// at the START of this try block (Core.ScriptedAlarms-015), so _shelvingTimer
|
||||
// is null here; no double-dispose risk. (Core.ScriptedAlarms-002, -015)
|
||||
// at the START of this try block, so _shelvingTimer
|
||||
// is null here; no double-dispose risk.
|
||||
_shelvingTimer = new Timer(_ => RunShelvingCheck(),
|
||||
null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5));
|
||||
}
|
||||
@@ -305,7 +304,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
}
|
||||
|
||||
// Fire any emissions collected during startup recovery OUTSIDE the gate so
|
||||
// subscribers can re-enter the engine safely. (Core.ScriptedAlarms-003)
|
||||
// subscribers can re-enter the engine safely.
|
||||
foreach (var evt in pending) FireEvent(evt);
|
||||
}
|
||||
|
||||
@@ -314,10 +313,12 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// unknown alarm. Mainly used for diagnostics + the Admin UI status page.
|
||||
/// </summary>
|
||||
/// <param name="alarmId">The alarm identifier.</param>
|
||||
/// <returns>The alarm's current condition state, or null if the alarm id is unknown.</returns>
|
||||
public AlarmConditionState? GetState(string alarmId)
|
||||
=> _alarms.TryGetValue(alarmId, out var s) ? s.Condition : null;
|
||||
|
||||
/// <summary>Gets the current persisted state for all loaded alarms.</summary>
|
||||
/// <returns>The current condition state of every loaded alarm.</returns>
|
||||
public IReadOnlyCollection<AlarmConditionState> GetAllStates()
|
||||
=> _alarms.Values.Select(a => a.Condition).ToArray();
|
||||
|
||||
@@ -326,6 +327,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// <param name="user">The user performing the acknowledgment.</param>
|
||||
/// <param name="comment">An optional comment to attach to the acknowledgment.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A task that completes once the acknowledgment has been applied and persisted.</returns>
|
||||
public Task AcknowledgeAsync(string alarmId, string user, string? comment, CancellationToken ct)
|
||||
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyAcknowledge(cur, user, comment, _clock()));
|
||||
|
||||
@@ -334,6 +336,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// <param name="user">The user performing the confirmation.</param>
|
||||
/// <param name="comment">An optional comment to attach to the confirmation.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A task that completes once the confirmation has been applied and persisted.</returns>
|
||||
public Task ConfirmAsync(string alarmId, string user, string? comment, CancellationToken ct)
|
||||
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyConfirm(cur, user, comment, _clock()));
|
||||
|
||||
@@ -341,6 +344,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// <param name="alarmId">The alarm identifier.</param>
|
||||
/// <param name="user">The user performing the shelve operation.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A task that completes once the shelve has been applied and persisted.</returns>
|
||||
public Task OneShotShelveAsync(string alarmId, string user, CancellationToken ct)
|
||||
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyOneShotShelve(cur, user, _clock()));
|
||||
|
||||
@@ -349,6 +353,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// <param name="user">The user performing the shelve operation.</param>
|
||||
/// <param name="unshelveAtUtc">The UTC time at which the shelve will automatically expire.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A task that completes once the timed shelve has been applied and persisted.</returns>
|
||||
public Task TimedShelveAsync(string alarmId, string user, DateTime unshelveAtUtc, CancellationToken ct)
|
||||
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyTimedShelve(cur, user, unshelveAtUtc, _clock()));
|
||||
|
||||
@@ -356,6 +361,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// <param name="alarmId">The alarm identifier.</param>
|
||||
/// <param name="user">The user performing the unshelve operation.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A task that completes once the unshelve has been applied and persisted.</returns>
|
||||
public Task UnshelveAsync(string alarmId, string user, CancellationToken ct)
|
||||
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyUnshelve(cur, user, _clock()));
|
||||
|
||||
@@ -363,6 +369,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// <param name="alarmId">The alarm identifier.</param>
|
||||
/// <param name="user">The user performing the enable operation.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A task that completes once the enable has been applied and persisted.</returns>
|
||||
public Task EnableAsync(string alarmId, string user, CancellationToken ct)
|
||||
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyEnable(cur, user, _clock()));
|
||||
|
||||
@@ -370,6 +377,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// <param name="alarmId">The alarm identifier.</param>
|
||||
/// <param name="user">The user performing the disable operation.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A task that completes once the disable has been applied and persisted.</returns>
|
||||
public Task DisableAsync(string alarmId, string user, CancellationToken ct)
|
||||
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyDisable(cur, user, _clock()));
|
||||
|
||||
@@ -378,6 +386,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// <param name="user">The user adding the comment.</param>
|
||||
/// <param name="text">The comment text.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A task that completes once the comment has been recorded and persisted.</returns>
|
||||
public Task AddCommentAsync(string alarmId, string user, string text, CancellationToken ct)
|
||||
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyAddComment(cur, user, text, _clock()));
|
||||
|
||||
@@ -395,14 +404,13 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
// Persist BEFORE updating in-memory so a store failure leaves both
|
||||
// in-memory and persisted at the prior state rather than diverging.
|
||||
// If SaveAsync throws the in-memory _alarms entry stays unchanged and
|
||||
// the exception propagates to the caller. (Core.ScriptedAlarms-007)
|
||||
// the exception propagates to the caller.
|
||||
await _store.SaveAsync(result.State, ct).ConfigureAwait(false);
|
||||
_alarms[alarmId] = state with { Condition = result.State };
|
||||
// Build the emission event under the gate (it captures a coherent
|
||||
// snapshot of state + message-template values) but defer the actual
|
||||
// OnEvent dispatch until after Release() so a slow subscriber or a
|
||||
// subscriber that re-enters the engine doesn't block / deadlock.
|
||||
// (Core.ScriptedAlarms-003)
|
||||
if (result.Emission != EmissionKind.None)
|
||||
pending = BuildEmission(state, result.State, result.Emission);
|
||||
else if (result.NoOpReason is { } reason)
|
||||
@@ -411,7 +419,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
// disabled-alarm no-ops + idempotent ack/confirm/shelve/unshelve
|
||||
// calls. We surface them at debug so they're available when
|
||||
// investigating "why didn't my ack take effect?" without spamming
|
||||
// the main info log. (Core.ScriptedAlarms-011)
|
||||
// the main info log.
|
||||
state.Logger.Debug("Alarm {AlarmId} no-op transition: {Reason}", alarmId, reason);
|
||||
}
|
||||
}
|
||||
@@ -427,7 +435,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// Upstream-change callback. Updates the value cache + enqueues predicate
|
||||
/// re-evaluation for every alarm referencing the changed path. Fire-and-forget
|
||||
/// so driver-side dispatch isn't blocked; the background task is tracked so
|
||||
/// <see cref="Dispose"/> can drain it. (Core.ScriptedAlarms-006)
|
||||
/// <see cref="Dispose"/> can drain it.
|
||||
/// </summary>
|
||||
/// <param name="path">The upstream tag path that changed.</param>
|
||||
/// <param name="value">The new data value snapshot.</param>
|
||||
@@ -452,7 +460,6 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
// Re-check after acquiring the gate: a Dispose() call may have
|
||||
// completed between our _evalGate.WaitAsync and here. Writing to a
|
||||
// disposing store or mutating _alarms after clear is unsafe.
|
||||
// (Core.ScriptedAlarms-005)
|
||||
if (_disposed) return;
|
||||
|
||||
foreach (var id in alarmIds)
|
||||
@@ -463,7 +470,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
if (!ReferenceEquals(newState, state.Condition))
|
||||
{
|
||||
// Persist before updating in-memory so a store failure leaves
|
||||
// both sides at the prior state. (Core.ScriptedAlarms-007)
|
||||
// both sides at the prior state.
|
||||
await _store.SaveAsync(newState, ct).ConfigureAwait(false);
|
||||
_alarms[id] = state with { Condition = newState };
|
||||
}
|
||||
@@ -477,7 +484,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
return;
|
||||
}
|
||||
// Fire emissions OUTSIDE _evalGate so subscriber callbacks can re-enter
|
||||
// the engine without deadlocking. (Core.ScriptedAlarms-003)
|
||||
// the engine without deadlocking.
|
||||
foreach (var evt in pending) FireEvent(evt);
|
||||
}
|
||||
|
||||
@@ -486,14 +493,13 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// Returns the new condition state. If the transition produces an emission,
|
||||
/// appends it to <paramref name="pendingEmissions"/> so the caller can fire
|
||||
/// them after releasing <c>_evalGate</c> — keeping subscriber callbacks
|
||||
/// outside the gate. (Core.ScriptedAlarms-003)
|
||||
/// outside the gate.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every caller (LoadAsync and ReevaluateAsync) owns a <c>pending</c> list and
|
||||
/// passes it here; emissions are always deferred to after the gate is released.
|
||||
/// The parameter is required (non-nullable) to make this contract explicit and
|
||||
/// prevent a future caller from accidentally firing events under the gate.
|
||||
/// (Core.ScriptedAlarms-014)
|
||||
/// </remarks>
|
||||
private async Task<AlarmConditionState> EvaluatePredicateToStateAsync(
|
||||
AlarmState state, AlarmConditionState seed, DateTime nowUtc, CancellationToken ct,
|
||||
@@ -501,7 +507,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
{
|
||||
// Look up (or lazily allocate) the per-alarm scratch and refill its read cache
|
||||
// in place. The dictionary + context survive across evaluations so the hot path
|
||||
// no longer allocates per upstream tag change. (Core.ScriptedAlarms-009)
|
||||
// no longer allocates per upstream tag change.
|
||||
var scratch = _scratchByAlarmId.GetOrAdd(
|
||||
state.Definition.AlarmId,
|
||||
_ => new AlarmScratch(state.Inputs, state.Logger, _clock));
|
||||
@@ -552,7 +558,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// first upstream push hasn't arrived yet. The dictionary is cleared and
|
||||
/// repopulated under <c>_evalGate</c> so no concurrent reader can observe
|
||||
/// a partial state. Replaces the old <c>BuildReadCache</c> which allocated a
|
||||
/// fresh dictionary every call (Core.ScriptedAlarms-009).
|
||||
/// fresh dictionary every call.
|
||||
/// </summary>
|
||||
private void RefillReadCache(
|
||||
Dictionary<string, DataValueSnapshot> cache, IReadOnlySet<string> inputs)
|
||||
@@ -591,7 +597,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// <c>_evalGate</c> so the message-template resolution uses a coherent
|
||||
/// value-cache snapshot. The actual <see cref="OnEvent"/> dispatch is
|
||||
/// done by <see cref="FireEvent(ScriptedAlarmEvent)"/> AFTER the gate is
|
||||
/// released. (Core.ScriptedAlarms-003)
|
||||
/// released.
|
||||
/// </summary>
|
||||
private ScriptedAlarmEvent? BuildEmission(AlarmState state, AlarmConditionState condition, EmissionKind kind)
|
||||
{
|
||||
@@ -632,7 +638,6 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// block the gate for every other engine operation, and a subscriber
|
||||
/// that re-enters the engine (e.g. calls AcknowledgeAsync) would
|
||||
/// deadlock against the non-reentrant SemaphoreSlim.
|
||||
/// (Core.ScriptedAlarms-003)
|
||||
/// </summary>
|
||||
private void FireEvent(ScriptedAlarmEvent evt)
|
||||
{
|
||||
@@ -656,7 +661,6 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// <summary>
|
||||
/// Register a fire-and-forget task so <see cref="Dispose"/> can await it.
|
||||
/// The task removes itself from the set on completion via a continuation.
|
||||
/// (Core.ScriptedAlarms-006)
|
||||
/// </summary>
|
||||
private void TrackBackgroundTask(Task task)
|
||||
{
|
||||
@@ -673,7 +677,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// <summary>
|
||||
/// Test hook — triggers a shelving check synchronously without waiting for
|
||||
/// the 5-second timer. Allows tests that inject a controllable clock to advance
|
||||
/// time and immediately drive timed-shelve expiry. (Core.ScriptedAlarms-012)
|
||||
/// time and immediately drive timed-shelve expiry.
|
||||
/// </summary>
|
||||
internal void RunShelvingCheckForTest() => RunShelvingCheck();
|
||||
|
||||
@@ -689,7 +693,6 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
// running callbacks, so a shelving-check callback that passed the _disposed
|
||||
// check in RunShelvingCheck can arrive here after Dispose() has returned.
|
||||
// Mutating _alarms or saving to a disposed store here is unsafe.
|
||||
// (Core.ScriptedAlarms-005)
|
||||
if (_disposed) return;
|
||||
|
||||
var now = _clock();
|
||||
@@ -700,7 +703,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
if (!ReferenceEquals(result.State, state.Condition))
|
||||
{
|
||||
// Persist before updating in-memory so a store failure leaves
|
||||
// both sides at the prior state. (Core.ScriptedAlarms-007)
|
||||
// both sides at the prior state.
|
||||
await _store.SaveAsync(result.State, ct).ConfigureAwait(false);
|
||||
_alarms[id] = state with { Condition = result.State };
|
||||
if (result.Emission != EmissionKind.None)
|
||||
@@ -718,7 +721,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
_engineLogger.Warning(ex, "ScriptedAlarmEngine shelving-check failed");
|
||||
return;
|
||||
}
|
||||
// Fire emissions OUTSIDE _evalGate. (Core.ScriptedAlarms-003)
|
||||
// Fire emissions OUTSIDE _evalGate.
|
||||
foreach (var evt in pending) FireEvent(evt);
|
||||
}
|
||||
|
||||
@@ -751,7 +754,6 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
// flight can outlive the engine and write to a (possibly disposed) store
|
||||
// after Dispose() has returned. The tasks re-check _disposed after
|
||||
// acquiring the gate and bail out, but the await still has to complete.
|
||||
// (Core.ScriptedAlarms-006)
|
||||
Task[] toAwait;
|
||||
lock (_inFlightLock) { toAwait = [.. _inFlight]; }
|
||||
if (toAwait.Length > 0)
|
||||
@@ -769,20 +771,19 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
// Safe to clear here: the Task.WhenAll drain above guaranteed no
|
||||
// ReevaluateAsync / ShelvingCheckAsync is mid-flight, and _disposed=true
|
||||
// prevents new background work from being queued (OnUpstreamChange bails on
|
||||
// line 334). Pre-Core.Scripting-016 the comment said "Do NOT clear _alarms",
|
||||
// line 334). Previously the comment said "Do NOT clear _alarms",
|
||||
// but that was when the engine called ScriptEvaluator.Compile directly and
|
||||
// held the script ALCs through _alarms→AlarmState→TimedScriptEvaluator
|
||||
// forever — leaving them rooted defeated the -008 collectible-ALC unload.
|
||||
// forever — leaving them rooted defeated the collectible-ALC unload.
|
||||
// Clearing now drops the delegate references so the cache's Dispose call
|
||||
// below can actually unload the emitted assemblies. (Core.ScriptedAlarms-005
|
||||
// re-evaluated under -016.)
|
||||
// below can actually unload the emitted assemblies.
|
||||
_alarms.Clear();
|
||||
_alarmsReferencing.Clear();
|
||||
_scratchByAlarmId.Clear();
|
||||
// Dispose every compiled-predicate ALC so the engine's shutdown actually
|
||||
// releases the emitted assemblies. The drain above ensures no evaluator is
|
||||
// mid-call; CompiledScriptCache.Dispose internally guards against use-after-
|
||||
// dispose. (Core.Scripting-016)
|
||||
// dispose.
|
||||
_compileCache.Dispose();
|
||||
}
|
||||
|
||||
@@ -800,7 +801,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// cleared and refilled in <see cref="ScriptedAlarmEngine.RefillReadCache"/> on
|
||||
/// each call. <see cref="Context"/> wraps that dictionary by reference, so a
|
||||
/// refilled <see cref="ReadCache"/> is what the predicate's
|
||||
/// <c>ctx.GetTag(path)</c> calls observe. (Core.ScriptedAlarms-009)
|
||||
/// <c>ctx.GetTag(path)</c> calls observe.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Reuse is safe because <see cref="ScriptedAlarmEngine"/> serialises every
|
||||
|
||||
@@ -124,7 +124,7 @@ public sealed class ScriptedAlarmSource : IAlarmSource, IDisposable
|
||||
/// <summary>Initializes a new instance of the <see cref="SubscriptionHandle"/> class.</summary>
|
||||
/// <param name="id">The diagnostic ID for this subscription handle.</param>
|
||||
public SubscriptionHandle(string id) { DiagnosticId = id; }
|
||||
/// <summary>Gets the diagnostic ID that uniquely identifies this subscription.</summary>
|
||||
/// <inheritdoc />
|
||||
public string DiagnosticId { get; }
|
||||
}
|
||||
|
||||
|
||||
@@ -36,8 +36,6 @@ public sealed class AlarmPredicateContext : ScriptContext
|
||||
_clock = clock ?? (() => DateTime.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>Gets a tag value from the read cache.</summary>
|
||||
/// <param name="path">The tag path to retrieve.</param>
|
||||
/// <inheritdoc />
|
||||
public override DataValueSnapshot GetTag(string path)
|
||||
{
|
||||
@@ -48,9 +46,6 @@ public sealed class AlarmPredicateContext : ScriptContext
|
||||
: new DataValueSnapshot(null, 0x80340000u /* BadNodeIdUnknown */, null, _clock());
|
||||
}
|
||||
|
||||
/// <summary>Rejects virtual tag writes for pure predicate semantics.</summary>
|
||||
/// <param name="path">The virtual tag path.</param>
|
||||
/// <param name="value">The value to write.</param>
|
||||
/// <inheritdoc />
|
||||
public override void SetVirtualTag(string path, object? value)
|
||||
{
|
||||
|
||||
@@ -6,8 +6,8 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
/// <summary>
|
||||
/// The API user scripts see as the global <c>ctx</c>. Abstract — concrete subclasses
|
||||
/// (e.g. <c>VirtualTagScriptContext</c>, <c>AlarmScriptContext</c>) plug in the
|
||||
/// actual tag-backend + logger + virtual-tag writer for each evaluation. Plan decision
|
||||
/// #6 (see <c>docs/v2/plan.md</c>): scripts can read any tag, write only to virtual
|
||||
/// actual tag-backend + logger + virtual-tag writer for each evaluation. By design
|
||||
/// (see <c>docs/v2/plan.md</c>), scripts can read any tag, write only to virtual
|
||||
/// tags, and have no other .NET reach — no HttpClient, no File, no Process, no
|
||||
/// reflection.
|
||||
/// </summary>
|
||||
@@ -47,12 +47,13 @@ public abstract class ScriptContext
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="path">The literal tag path to read.</param>
|
||||
/// <returns>A snapshot of the tag's current value, quality, and source timestamp.</returns>
|
||||
public abstract DataValueSnapshot GetTag(string path);
|
||||
|
||||
/// <summary>
|
||||
/// Write a value to a virtual tag. Operator scripts cannot write to driver-sourced
|
||||
/// tags — the OPC UA dispatch in <c>DriverNodeManager</c> rejects that separately
|
||||
/// per ADR-002 with <c>BadUserAccessDenied</c>. This method is the only write path
|
||||
/// with <c>BadUserAccessDenied</c>. This method is the only write path
|
||||
/// virtual tags have.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
@@ -89,6 +90,8 @@ public abstract class ScriptContext
|
||||
/// <param name="previous">The previous value to compare against.</param>
|
||||
/// <param name="tolerance">The minimum change magnitude to detect (must be >= 0).
|
||||
/// Negative values cause the function to always return true; NaN always returns false.</param>
|
||||
/// <returns><c>true</c> when <paramref name="current"/> differs from <paramref name="previous"/>
|
||||
/// by more than <paramref name="tolerance"/>; otherwise <c>false</c>.</returns>
|
||||
public static bool Deadband(double current, double previous, double tolerance)
|
||||
=> Math.Abs(current - previous) > tolerance;
|
||||
}
|
||||
|
||||
@@ -32,8 +32,8 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Lifecycle:</b> compiled scripts hold a collectible
|
||||
/// <see cref="System.Runtime.Loader.AssemblyLoadContext"/> per evaluator
|
||||
/// (Core.Scripting-008 fix). <see cref="Clear"/> disposes every materialised
|
||||
/// <see cref="System.Runtime.Loader.AssemblyLoadContext"/> per evaluator.
|
||||
/// <see cref="Clear"/> disposes every materialised
|
||||
/// evaluator before dropping its dictionary entry so the emitted assemblies are
|
||||
/// eligible for GC immediately after a publish. <see cref="Dispose"/> drops the
|
||||
/// cache itself for graceful server shutdown.
|
||||
@@ -75,7 +75,7 @@ public sealed class CompiledScriptCache<TContext, TResult> : IDisposable
|
||||
// value reference, so if two threads race the same bad source both observe
|
||||
// the same faulted Lazy and both reach this catch, and a concurrent retry
|
||||
// re-added a fresh Lazy under the same key between the two removals, the
|
||||
// second removal does NOT evict the in-flight retry. (Core.Scripting-006.)
|
||||
// second removal does NOT evict the in-flight retry.
|
||||
_cache.TryRemove(new KeyValuePair<string, Lazy<ScriptEvaluator<TContext, TResult>>>(key, lazy));
|
||||
throw;
|
||||
}
|
||||
@@ -88,28 +88,28 @@ public sealed class CompiledScriptCache<TContext, TResult> : IDisposable
|
||||
/// Drop every cached compile. Used on config generation publish + tests.
|
||||
/// Disposes each materialised evaluator before removing it so its collectible
|
||||
/// <see cref="System.Runtime.Loader.AssemblyLoadContext"/> unloads and the
|
||||
/// emitted script assembly becomes eligible for GC (Core.Scripting-008).
|
||||
/// emitted script assembly becomes eligible for GC.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Safe to call after <see cref="Dispose"/> — the operation is idempotent.
|
||||
/// <see cref="Dispose"/> sets <c>_disposed = true</c> before invoking this
|
||||
/// method (so callers see the post-Dispose guard on <see cref="GetOrCompile"/>),
|
||||
/// but this method itself MUST run to completion so the Dispose-triggered
|
||||
/// drain actually unloads every materialised evaluator's ALC. (Core.Scripting-016
|
||||
/// uncovered this — a previous Clear-aborts-when-disposed guard silently
|
||||
/// skipped the entire drain on Dispose, leaving emitted assemblies rooted.)
|
||||
/// drain actually unloads every materialised evaluator's ALC (a previous
|
||||
/// Clear-aborts-when-disposed guard silently skipped the entire drain on
|
||||
/// Dispose, leaving emitted assemblies rooted).
|
||||
/// </remarks>
|
||||
public void Clear()
|
||||
{
|
||||
// Snapshot (key, value) pairs and remove with the value-scoped
|
||||
// TryRemove(KeyValuePair<,>) overload — same shape as the
|
||||
// Core.Scripting-006 fix in GetOrCompile's catch block. A concurrent
|
||||
// fix in GetOrCompile's catch block. A concurrent
|
||||
// GetOrCompile re-add that hashes to the same key between our snapshot
|
||||
// and the TryRemove inserts a *different* Lazy reference; the value-
|
||||
// scoped removal sees the mismatch and leaves the fresh entry intact
|
||||
// (instead of evicting + disposing it while the concurrent caller
|
||||
// still holds it). The fresh evaluator and its ALC stay live for the
|
||||
// concurrent caller. (Core.Scripting-014.)
|
||||
// concurrent caller.
|
||||
foreach (var entry in _cache.ToArray())
|
||||
{
|
||||
if (_cache.TryRemove(entry))
|
||||
|
||||
@@ -8,8 +8,8 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
/// <summary>
|
||||
/// Parses a script's source text + extracts every <c>ctx.GetTag("literal")</c> and
|
||||
/// <c>ctx.SetVirtualTag("literal", ...)</c> call. Outputs the static dependency set
|
||||
/// the virtual-tag engine uses to build its change-trigger subscription graph (Phase
|
||||
/// 7 plan decision #7 — AST inference, operator doesn't maintain a separate list).
|
||||
/// the virtual-tag engine uses to build its change-trigger subscription graph (AST
|
||||
/// inference, operator doesn't maintain a separate list).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -29,7 +29,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
/// extractor while still having a working script. Calls with the same method name on
|
||||
/// a different receiver (<c>other.GetTag("X")</c>) are explicitly ignored so that
|
||||
/// scripts defining local helper types with matching names do not produce spurious
|
||||
/// dependencies. (Core.Scripting-004.)
|
||||
/// dependencies.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class DependencyExtractor
|
||||
@@ -39,6 +39,7 @@ public static class DependencyExtractor
|
||||
/// paths, or a list of rejection messages if non-literal paths were used.
|
||||
/// </summary>
|
||||
/// <param name="scriptSource">The script source code to analyze.</param>
|
||||
/// <returns>The inferred read/write tag dependencies, plus any non-literal-path rejections.</returns>
|
||||
public static DependencyExtractionResult Extract(string scriptSource)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scriptSource))
|
||||
@@ -81,7 +82,6 @@ public static class DependencyExtractor
|
||||
// field). Calls with the same method name on a different receiver (e.g.
|
||||
// someHelper.GetTag("X")) are ignored — not picking them up avoids spurious
|
||||
// dependencies when scripts define local types with matching method names.
|
||||
// (Core.Scripting-004.)
|
||||
if (node.Expression is MemberAccessExpressionSyntax member
|
||||
&& member.Expression is IdentifierNameSyntax receiver
|
||||
&& receiver.Identifier.ValueText == "ctx")
|
||||
@@ -113,7 +113,7 @@ public static class DependencyExtractor
|
||||
// rather than StringLiteralToken. Checking the expression kind
|
||||
// (StringLiteralExpression) covers all token kinds Roslyn assigns to literal
|
||||
// strings, so a """raw""" path is harvested rather than mis-rejected as a
|
||||
// dynamic path. (Core.Scripting-005.)
|
||||
// dynamic path.
|
||||
if (pathArg is not LiteralExpressionSyntax literal
|
||||
|| !literal.IsKind(SyntaxKind.StringLiteralExpression))
|
||||
{
|
||||
|
||||
@@ -17,13 +17,13 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Deny-list is the authoritative Phase 7 plan decision #6 set:
|
||||
/// Deny-list is the authoritative Phase 7 plan set:
|
||||
/// <c>System.IO</c>, <c>System.Net</c>, <c>System.Diagnostics</c>,
|
||||
/// <c>System.Reflection</c>, <c>System.Threading.Thread</c>,
|
||||
/// <c>System.Threading.Tasks</c> (scripts are synchronous predicates — no
|
||||
/// legitimate need to start background tasks; a <c>Task.Run</c> fan-out outlives
|
||||
/// the evaluation timeout entirely), <c>System.Runtime.InteropServices</c>,
|
||||
/// <c>Microsoft.Win32</c>. (Core.Scripting-003.)
|
||||
/// <c>Microsoft.Win32</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Deny-list prefix match. <c>System.Net</c> catches <c>System.Net.Http</c>,
|
||||
@@ -45,7 +45,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
/// <c>Activator.CreateInstance</c> is a reflection-equivalent escape; <c>GC</c>
|
||||
/// and <c>AppDomain</c> expose process-wide control. Legitimate <c>System</c>
|
||||
/// types (<c>Math</c>, <c>String</c>, <c>Convert</c>, <c>DateTime</c>, …) are not
|
||||
/// on the list and stay usable. (Core.Scripting-001.)
|
||||
/// on the list and stay usable.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class ForbiddenTypeAnalyzer
|
||||
@@ -69,14 +69,13 @@ public static class ForbiddenTypeAnalyzer
|
||||
// ForbiddenFullTypeNames instead so the check actually fires.
|
||||
"System.Threading.Tasks", // Task.Run / Parallel — scripts are synchronous predicates
|
||||
// and have no legitimate need to start background work;
|
||||
// a Task fan-out outlives the evaluation timeout entirely
|
||||
// (Core.Scripting-003).
|
||||
// a Task fan-out outlives the evaluation timeout entirely.
|
||||
"System.Runtime.InteropServices",
|
||||
"System.Runtime.Loader", // AssemblyLoadContext + AssemblyDependencyResolver —
|
||||
// arbitrary DLL load into the host process
|
||||
// (Core.Scripting-012). Namespace-prefix rather than
|
||||
// type-granular so future BCL additions to this
|
||||
// namespace are denied by default.
|
||||
// arbitrary DLL load into the host process.
|
||||
// Namespace-prefix rather than type-granular so
|
||||
// future BCL additions to this namespace are
|
||||
// denied by default.
|
||||
"Microsoft.Win32", // registry
|
||||
];
|
||||
|
||||
@@ -104,15 +103,14 @@ public static class ForbiddenTypeAnalyzer
|
||||
/// per-evaluation timeout; denied type-granularly because its containing
|
||||
/// namespace is <c>System.Threading</c> (shared with allowed types like
|
||||
/// <c>CancellationToken</c>), so a namespace-prefix rule cannot reach it
|
||||
/// without blocking unrelated types. (Core.Scripting-010.)</item>
|
||||
/// without blocking unrelated types.</item>
|
||||
/// <item><c>System.Threading.ThreadPool</c> / <c>System.Threading.Timer</c> —
|
||||
/// background-work vectors that outlive the per-evaluation timeout; same
|
||||
/// threat as <c>Task.Run</c> that Core.Scripting-003 closed. (Core.Scripting-012.)</item>
|
||||
/// threat as <c>Task.Run</c>.</item>
|
||||
/// <item><c>System.Runtime.CompilerServices.Unsafe</c> — exposes raw type-
|
||||
/// reinterpretation (<c>As<TFrom,TTo></c>, <c>As<T>(object)</c>)
|
||||
/// that bypasses the CLR type system without requiring an <c>unsafe</c> context at
|
||||
/// the call site; enables type-confusion and managed heap corruption.
|
||||
/// (Core.Scripting-017.)</item>
|
||||
/// the call site; enables type-confusion and managed heap corruption.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public static readonly IReadOnlyList<string> ForbiddenFullTypeNames =
|
||||
@@ -124,18 +122,15 @@ public static class ForbiddenTypeAnalyzer
|
||||
// System.Threading.Thread lives in the System.Threading namespace (shared with
|
||||
// CancellationToken, SemaphoreSlim, etc.), so a namespace-prefix deny-list cannot
|
||||
// target it without blocking those legitimate types. Denied type-granularly here.
|
||||
// (Core.Scripting-010.)
|
||||
"System.Threading.Thread",
|
||||
// Core.Scripting-012 — broadening the references list to the BCL trusted-platform-
|
||||
// assemblies set (Core.Scripting-008 follow-up) re-exposed two background-work
|
||||
// vectors the original deny-list missed. Both live in System.Threading (shared
|
||||
// with allowed sync primitives like CancellationToken / SemaphoreSlim), so they
|
||||
// must be denied type-granularly:
|
||||
// Broadening the references list to the BCL trusted-platform-assemblies set
|
||||
// re-exposed two background-work vectors the original deny-list missed. Both live
|
||||
// in System.Threading (shared with allowed sync primitives like CancellationToken /
|
||||
// SemaphoreSlim), so they must be denied type-granularly:
|
||||
//
|
||||
// System.Threading.ThreadPool — QueueUserWorkItem / UnsafeQueueUserWorkItem
|
||||
// re-introduce the background-fanout threat
|
||||
// Core.Scripting-003 closed against
|
||||
// System.Threading.Tasks.
|
||||
// closed against System.Threading.Tasks.
|
||||
// System.Threading.Timer — Timer(callback, …) schedules unbounded work
|
||||
// that outlives the per-evaluation timeout.
|
||||
//
|
||||
@@ -144,14 +139,14 @@ public static class ForbiddenTypeAnalyzer
|
||||
// namespace are denied by default.
|
||||
"System.Threading.ThreadPool",
|
||||
"System.Threading.Timer",
|
||||
// Core.Scripting-017 — System.Runtime.CompilerServices.Unsafe exposes raw
|
||||
// type-reinterpretation (Unsafe.As<TFrom, TTo>, Unsafe.As<T>(object)) that
|
||||
// bypasses the CLR type system without requiring an 'unsafe' compilation context
|
||||
// at the call site. In a script, calling Unsafe.As<string>(someObject) can corrupt
|
||||
// managed heap references and cause type-confusion in downstream virtual-tag
|
||||
// consumers. Type-granular (not namespace-prefix) because System.Runtime.CompilerServices
|
||||
// also contains harmless compile-time-only types (CallerMemberNameAttribute,
|
||||
// MethodImplAttribute, MethodImplOptions) that scripts may legitimately reference.
|
||||
// System.Runtime.CompilerServices.Unsafe exposes raw type-reinterpretation
|
||||
// (Unsafe.As<TFrom, TTo>, Unsafe.As<T>(object)) that bypasses the CLR type system
|
||||
// without requiring an 'unsafe' compilation context at the call site. In a script,
|
||||
// calling Unsafe.As<string>(someObject) can corrupt managed heap references and
|
||||
// cause type-confusion in downstream virtual-tag consumers. Type-granular (not
|
||||
// namespace-prefix) because System.Runtime.CompilerServices also contains harmless
|
||||
// compile-time-only types (CallerMemberNameAttribute, MethodImplAttribute,
|
||||
// MethodImplOptions) that scripts may legitimately reference.
|
||||
"System.Runtime.CompilerServices.Unsafe",
|
||||
];
|
||||
|
||||
@@ -176,7 +171,7 @@ public static class ForbiddenTypeAnalyzer
|
||||
/// declares it, even though the receiver type <c>System.Type</c> is allowed).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Pass (2) — the Core.Scripting-002 fix — resolves the <em>type</em> of every
|
||||
/// Pass (2) resolves the <em>type</em> of every
|
||||
/// <c>TypeSyntax</c> node via <c>GetTypeInfo</c>. The old walker only inspected
|
||||
/// the four node kinds above, so a forbidden type named through
|
||||
/// <c>typeof(System.IO.File)</c>, a generic argument
|
||||
@@ -223,7 +218,7 @@ public static class ForbiddenTypeAnalyzer
|
||||
break;
|
||||
}
|
||||
|
||||
// Pass (2) — type-reference surface (Core.Scripting-002). Every TypeSyntax
|
||||
// Pass (2) — type-reference surface. Every TypeSyntax
|
||||
// resolves to the type it names, regardless of the syntactic form that
|
||||
// introduced it (typeof operand, cast type, generic argument, default(T)
|
||||
// operand, array element type, is/as pattern type, declared local type).
|
||||
@@ -275,10 +270,10 @@ public static class ForbiddenTypeAnalyzer
|
||||
|
||||
var typeName = typeSymbol.ToDisplayString();
|
||||
|
||||
// The broadened walk (Core.Scripting-002) resolves both GetSymbolInfo and
|
||||
// GetTypeInfo on every node, so the same forbidden reference can be hit several
|
||||
// times. Dedupe on span + type so the operator sees one rejection per offending
|
||||
// reference, not a noisy pile of identical messages.
|
||||
// The broadened walk resolves both GetSymbolInfo and GetTypeInfo on every node,
|
||||
// so the same forbidden reference can be hit several times. Dedupe on span + type
|
||||
// so the operator sees one rejection per offending reference, not a noisy pile of
|
||||
// identical messages.
|
||||
if (rejections.Any(r => r.Span == span && r.TypeName == typeName))
|
||||
return;
|
||||
|
||||
@@ -298,9 +293,9 @@ public static class ForbiddenTypeAnalyzer
|
||||
}
|
||||
|
||||
// Type-granular deny-list — dangerous types that live in the allow-listed
|
||||
// System namespace and so cannot be caught by ForbiddenNamespacePrefixes
|
||||
// (Core.Scripting-001). Matched on the full type name; OriginalDefinition
|
||||
// unwraps any generic construction before naming.
|
||||
// System namespace and so cannot be caught by ForbiddenNamespacePrefixes.
|
||||
// Matched on the full type name; OriginalDefinition unwraps any generic
|
||||
// construction before naming.
|
||||
var fullTypeName = typeSymbol.OriginalDefinition.ToDisplayString(
|
||||
SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle(
|
||||
SymbolDisplayGlobalNamespaceStyle.Omitted));
|
||||
|
||||
@@ -18,9 +18,9 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
/// Scripts are wrapped in a synthesized <c>CompiledScript.Run(globals)</c> method
|
||||
/// and compiled via <see cref="CSharpCompilation"/> into a regular .NET assembly
|
||||
/// that is loaded into a <b>collectible</b>
|
||||
/// <see cref="AssemblyLoadContext"/>. The collectible ALC is the fix for
|
||||
/// Core.Scripting-008: per-publish recompile accretion was previously unbounded
|
||||
/// because Roslyn's <c>CSharpScript.CreateDelegate</c> emits into the default ALC
|
||||
/// <see cref="AssemblyLoadContext"/>. The collectible ALC fixes unbounded
|
||||
/// per-publish recompile accretion: previously
|
||||
/// Roslyn's <c>CSharpScript.CreateDelegate</c> emitted into the default ALC
|
||||
/// (non-collectible); now <see cref="Dispose"/> unloads the entire ALC and the
|
||||
/// emitted assembly becomes eligible for GC.
|
||||
/// </para>
|
||||
@@ -38,8 +38,8 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Runtime exceptions thrown from user code propagate unwrapped. The virtual-tag
|
||||
/// engine catches them per-tag and maps to <c>BadInternalError</c> quality
|
||||
/// per Phase 7 decision #11; this layer doesn't swallow anything so tests can
|
||||
/// engine catches them per-tag and maps to <c>BadInternalError</c> quality;
|
||||
/// this layer doesn't swallow anything so tests can
|
||||
/// assert on the original exception type.
|
||||
/// </para>
|
||||
/// <para>
|
||||
@@ -66,6 +66,7 @@ public sealed class ScriptEvaluator<TContext, TResult> : IDisposable
|
||||
|
||||
/// <summary>Compiles user script source into an evaluator.</summary>
|
||||
/// <param name="scriptSource">The user script source code to compile.</param>
|
||||
/// <returns>A ready-to-run <see cref="ScriptEvaluator{TContext, TResult}"/> wrapping the compiled script.</returns>
|
||||
public static ScriptEvaluator<TContext, TResult> Compile(string scriptSource)
|
||||
{
|
||||
if (scriptSource is null) throw new ArgumentNullException(nameof(scriptSource));
|
||||
@@ -78,7 +79,7 @@ public sealed class ScriptEvaluator<TContext, TResult> : IDisposable
|
||||
var wrapperSource = BuildWrapperSource(scriptSource, sandbox.Imports);
|
||||
var syntaxTree = CSharpSyntaxTree.ParseText(wrapperSource);
|
||||
|
||||
// Step 1a — defend against wrapper-source injection (Core.Scripting-013).
|
||||
// Step 1a — defend against wrapper-source injection.
|
||||
// A script body of `return 0; } public static int Evil() { return 0;` would
|
||||
// close the synthesized `Run` method early, declare a sibling `Evil` method
|
||||
// inside the synthesized `CompiledScript` class, and leave the wrapper's
|
||||
@@ -173,6 +174,7 @@ public sealed class ScriptEvaluator<TContext, TResult> : IDisposable
|
||||
/// <summary>Runs the script against an already-constructed context.</summary>
|
||||
/// <param name="context">The script context.</param>
|
||||
/// <param name="ct">Cancellation token for the operation.</param>
|
||||
/// <returns>A task carrying the script's result.</returns>
|
||||
public Task<TResult> RunAsync(TContext context, CancellationToken ct = default)
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(ScriptEvaluator<TContext, TResult>));
|
||||
@@ -215,7 +217,7 @@ public sealed class ScriptEvaluator<TContext, TResult> : IDisposable
|
||||
/// exactly one member — the <c>Run</c> method. Anything else (a sibling
|
||||
/// method, nested class, additional class in the namespace, free-floating
|
||||
/// top-level statement) means the user source closed the synthesized braces
|
||||
/// early and injected its own declarations. (Core.Scripting-013.)
|
||||
/// early and injected its own declarations.
|
||||
/// </summary>
|
||||
private static void EnforceSingleRunMember(SyntaxTree syntaxTree)
|
||||
{
|
||||
@@ -344,7 +346,7 @@ public sealed class ScriptEvaluator<TContext, TResult> : IDisposable
|
||||
// emits as `global::Ns.Outer<T>.Inner<U>` — valid C#. The pre-fix code
|
||||
// used `IndexOf('`')` to find the FIRST backtick and truncated the
|
||||
// entire name there, silently dropping the rest of the nested-generic
|
||||
// closed args. (Core.Scripting-015.)
|
||||
// closed args.
|
||||
var rawName = t.GetGenericTypeDefinition().FullName!.Replace('+', '.');
|
||||
var allArgs = t.GetGenericArguments();
|
||||
var segments = rawName.Split('.');
|
||||
@@ -403,7 +405,7 @@ internal sealed class ScriptAssemblyLoadContext : AssemblyLoadContext
|
||||
/// reports compile-time errors against the wrapper source. Mirrors the
|
||||
/// <c>Microsoft.CodeAnalysis.Scripting.CompilationErrorException</c> from the legacy
|
||||
/// <c>CSharpScript</c> path so callers (engines + the Admin test-harness) keep the
|
||||
/// same catch site after the Core.Scripting-008 rewrite.
|
||||
/// same catch site after the rewrite.
|
||||
/// </summary>
|
||||
public sealed class CompilationErrorException : Exception
|
||||
{
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for the compile-time sandbox every user script is built against.
|
||||
/// Implements Phase 7 plan decision #6 (read-only sandbox) by whitelisting only the
|
||||
/// Enforces a read-only sandbox by whitelisting only the
|
||||
/// assemblies + namespaces the script API needs; no <c>System.IO</c>, no
|
||||
/// <c>System.Net</c>, no <c>System.Diagnostics.Process</c>, no
|
||||
/// <c>System.Reflection</c>. Attempts to reference those types in a script fail at
|
||||
@@ -18,8 +18,8 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
/// <c>mscorlib</c> / <c>System.Runtime</c> — this class overrides that with an
|
||||
/// explicit minimal allow-list. The list is the same regardless of whether
|
||||
/// <see cref="ScriptEvaluator{TContext, TResult}"/> uses the legacy
|
||||
/// <c>CSharpScript</c> path or the collectible-<c>AssemblyLoadContext</c> path
|
||||
/// (Core.Scripting-008): both go through <see cref="Build"/>.
|
||||
/// <c>CSharpScript</c> path or the collectible-<c>AssemblyLoadContext</c> path:
|
||||
/// both go through <see cref="Build"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Namespaces pre-imported so scripts don't have to write <c>using</c> clauses:
|
||||
@@ -43,6 +43,7 @@ public static class ScriptSandbox
|
||||
/// to resolve <c>ctx.GetTag(...)</c> calls.
|
||||
/// </summary>
|
||||
/// <param name="contextType">The concrete script context type to use for compilation.</param>
|
||||
/// <returns>The sandbox configuration (assembly references + default imports) to compile the script against.</returns>
|
||||
public static SandboxConfig Build(Type contextType)
|
||||
{
|
||||
if (contextType is null) throw new ArgumentNullException(nameof(contextType));
|
||||
@@ -74,8 +75,8 @@ public static class ScriptSandbox
|
||||
// type by FQN — including the ones we forbid (HttpClient, File, Process,
|
||||
// Registry, etc.). Letting those types resolve at compile is intentional: the
|
||||
// hard security gate is ForbiddenTypeAnalyzer in step 3 of the compile pipeline
|
||||
// (Core.Scripting-001 / -002 established the analyzer must be the sole gate
|
||||
// because type forwarding makes any references-list-only restriction porous).
|
||||
// (the analyzer must be the sole gate because type forwarding makes any
|
||||
// references-list-only restriction porous).
|
||||
// The references list now serves only as scoping hygiene — out-of-band BCL
|
||||
// surface (operator-authored hosting helpers, third-party packages, app code)
|
||||
// is not on the list and stays unreachable.
|
||||
@@ -118,7 +119,7 @@ public static class ScriptSandbox
|
||||
string.Equals(name, "mscorlib.dll", StringComparison.Ordinal) ||
|
||||
// Microsoft.Win32.Registry isn't a System.* DLL but the analyzer's
|
||||
// Microsoft.Win32 deny-list relies on the type being resolvable so it
|
||||
// can identify + reject it (Core.Scripting-001 / -002). Add the one
|
||||
// can identify + reject it. Add the one
|
||||
// DLL we need rather than broadening to Microsoft.* (which would also
|
||||
// pull in compilers, build tooling, etc.).
|
||||
string.Equals(name, "Microsoft.Win32.Registry.dll", StringComparison.Ordinal))
|
||||
|
||||
@@ -90,7 +90,7 @@ public sealed class TimedScriptEvaluator<TContext, TResult>
|
||||
// When both fire at nearly the same time, WaitAsync observes them in
|
||||
// non-deterministic order, so a cancel that arrives a few µs after the
|
||||
// timeout still reaches here as TimeoutException. Re-check the token so
|
||||
// the guarantee holds regardless of race ordering. (Core.Scripting-007.)
|
||||
// the guarantee holds regardless of race ordering.
|
||||
if (ct.IsCancellationRequested)
|
||||
throw new OperationCanceledException(ct);
|
||||
throw new ScriptTimeoutException(Timeout);
|
||||
@@ -101,7 +101,7 @@ public sealed class TimedScriptEvaluator<TContext, TResult>
|
||||
/// <summary>
|
||||
/// Thrown when a script evaluation exceeds its configured timeout. The virtual-tag
|
||||
/// engine (Stream B) catches this + maps the owning tag's quality to
|
||||
/// <c>BadInternalError</c> per Phase 7 plan decision #11, logging a structured
|
||||
/// <c>BadInternalError</c> per the Phase 7 plan, logging a structured
|
||||
/// warning with the offending script name so operators can locate + fix it.
|
||||
/// </summary>
|
||||
public sealed class ScriptTimeoutException : Exception
|
||||
|
||||
@@ -156,6 +156,7 @@ public sealed class DependencyGraph
|
||||
/// dependencies. Throws <see cref="DependencyCycleException"/> if any cycle
|
||||
/// exists. Implemented via Kahn's algorithm.
|
||||
/// </summary>
|
||||
/// <returns>The nodes in dependency-respecting evaluation order.</returns>
|
||||
public IReadOnlyList<string> TopologicalSort()
|
||||
{
|
||||
// Kahn's framing: edge u -> v means "u must come before v". For dependencies,
|
||||
@@ -205,6 +206,7 @@ public sealed class DependencyGraph
|
||||
/// Empty list means the graph is a DAG. Useful for surfacing every cycle in one
|
||||
/// rejection pass so operators see all of them, not just one at a time.
|
||||
/// </summary>
|
||||
/// <returns>Every strongly-connected component of size greater than 1, plus every self-loop; empty when the graph is a DAG.</returns>
|
||||
public IReadOnlyList<IReadOnlyList<string>> DetectCycles()
|
||||
{
|
||||
// Iterative Tarjan's SCC. Avoids recursion so deep graphs don't StackOverflow.
|
||||
|
||||
@@ -25,8 +25,6 @@ public sealed class NullHistoryWriter : IHistoryWriter
|
||||
{
|
||||
public static readonly NullHistoryWriter Instance = new();
|
||||
|
||||
/// <summary>Records a data value snapshot (no-op implementation).</summary>
|
||||
/// <param name="path">The virtual tag path (unused).</param>
|
||||
/// <param name="value">The data value snapshot (unused).</param>
|
||||
/// <inheritdoc />
|
||||
public void Record(string path, DataValueSnapshot value) { }
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ public interface ITagUpstreamSource
|
||||
/// when the path isn't configured.
|
||||
/// </summary>
|
||||
/// <param name="path">The tag path to read.</param>
|
||||
/// <returns>The last-known value + quality snapshot for <paramref name="path"/>.</returns>
|
||||
DataValueSnapshot ReadTag(string path);
|
||||
|
||||
/// <summary>
|
||||
@@ -40,5 +41,6 @@ public interface ITagUpstreamSource
|
||||
/// </summary>
|
||||
/// <param name="path">The tag path to subscribe to.</param>
|
||||
/// <param name="observer">The callback to invoke when the value changes.</param>
|
||||
/// <returns>An <see cref="IDisposable"/> that cancels the subscription when disposed.</returns>
|
||||
IDisposable SubscribeTag(string path, Action<string, DataValueSnapshot> observer);
|
||||
}
|
||||
|
||||
@@ -11,21 +11,6 @@ namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||
/// <see cref="ScriptSandbox"/>, builds the dependency graph, subscribes to every
|
||||
/// referenced upstream tag, and schedules re-evaluations on change + on timer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Evaluation order is topological per ADR-001 / Phase 7 plan decision #19 —
|
||||
/// serial for the v1 rollout, parallel promoted to a follow-up. When upstream
|
||||
/// tag X changes, the engine computes the transitive dependent closure of X in
|
||||
/// topological rank and evaluates each in turn, so a cascade through multiple
|
||||
/// levels of virtual tags settles within one change-trigger pass.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Per-tag error isolation per Phase 7 plan decision #11 — a script exception
|
||||
/// (or timeout) fails that tag's latest value with <c>BadInternalError</c> or
|
||||
/// <c>BadTypeMismatch</c> quality and logs a structured error; every other tag
|
||||
/// keeps evaluating. The engine itself never faults from a user script.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class VirtualTagEngine : IDisposable
|
||||
{
|
||||
private readonly ITagUpstreamSource _upstream;
|
||||
@@ -42,13 +27,13 @@ public sealed class VirtualTagEngine : IDisposable
|
||||
/// Compile cache for every virtual-tag script. Routes <see cref="Load"/>'s
|
||||
/// <see cref="ScriptEvaluator{TContext, TResult}.Compile"/> calls through the
|
||||
/// cache so the collectible <see cref="System.Runtime.Loader.AssemblyLoadContext"/>
|
||||
/// each compile produces is actually disposed on the publish-replace path
|
||||
/// (Core.Scripting-016): the cache's <see cref="CompiledScriptCache{TContext, TResult}.Clear"/>
|
||||
/// disposes every materialised evaluator before dropping its dictionary entry,
|
||||
/// so a config-publish releases the prior generation's ALCs and the per-publish
|
||||
/// accretion the Core.Scripting-008 fix targeted is actually freed in production.
|
||||
/// Pre-fix the engine called <c>ScriptEvaluator.Compile</c> directly, which left
|
||||
/// the ALCs rooted until the process exited — defeating -008 on the real path.
|
||||
/// each compile produces is actually disposed on the publish-replace path: the
|
||||
/// cache's <see cref="CompiledScriptCache{TContext, TResult}.Clear"/> disposes
|
||||
/// every materialised evaluator before dropping its dictionary entry, so a
|
||||
/// config-publish releases the prior generation's ALCs and the per-publish
|
||||
/// accretion is actually freed in production. Pre-fix, the engine called
|
||||
/// <c>ScriptEvaluator.Compile</c> directly, which left the ALCs rooted until
|
||||
/// the process exited.
|
||||
/// </summary>
|
||||
private readonly CompiledScriptCache<VirtualTagContext, object?> _compileCache = new();
|
||||
|
||||
@@ -99,9 +84,6 @@ public sealed class VirtualTagEngine : IDisposable
|
||||
UnsubscribeFromUpstream();
|
||||
_tags.Clear();
|
||||
_graph.Clear();
|
||||
// Dispose every compiled-script ALC from the prior generation BEFORE we
|
||||
// recompile this one. Skipping this is what made Core.Scripting-008 a
|
||||
// no-op in production (Core.Scripting-016).
|
||||
_compileCache.Clear();
|
||||
|
||||
var compileFailures = new List<string>();
|
||||
@@ -131,8 +113,6 @@ public sealed class VirtualTagEngine : IDisposable
|
||||
continue;
|
||||
}
|
||||
|
||||
// Route through CompiledScriptCache so the emitted assembly's collectible
|
||||
// ALC participates in publish-replace cleanup. (Core.Scripting-016)
|
||||
var evaluator = _compileCache.GetOrCompile(def.ScriptSource);
|
||||
var timed = new TimedScriptEvaluator<VirtualTagContext, object?>(evaluator, _scriptTimeout);
|
||||
var scriptLogger = _loggerFactory.Create(def.Path);
|
||||
@@ -198,6 +178,7 @@ public sealed class VirtualTagEngine : IDisposable
|
||||
/// default. Also called after a config reload.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token to stop evaluation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task EvaluateAllAsync(CancellationToken ct = default)
|
||||
{
|
||||
EnsureLoaded();
|
||||
@@ -212,6 +193,7 @@ public sealed class VirtualTagEngine : IDisposable
|
||||
/// <summary>Evaluate a single tag — used by the timer trigger + test hooks.</summary>
|
||||
/// <param name="path">Path of the virtual tag to evaluate.</param>
|
||||
/// <param name="ct">Cancellation token to stop evaluation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task EvaluateOneAsync(string path, CancellationToken ct = default)
|
||||
{
|
||||
EnsureLoaded();
|
||||
@@ -226,6 +208,7 @@ public sealed class VirtualTagEngine : IDisposable
|
||||
/// evaluation result.
|
||||
/// </summary>
|
||||
/// <param name="path">Path of the tag to read.</param>
|
||||
/// <returns>The tag's most recently evaluated value, or a Bad-quality snapshot if the path is unrecognized.</returns>
|
||||
public DataValueSnapshot Read(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
@@ -242,6 +225,7 @@ public sealed class VirtualTagEngine : IDisposable
|
||||
/// </summary>
|
||||
/// <param name="path">Path of the tag to subscribe to.</param>
|
||||
/// <param name="observer">Callback invoked with the tag path and new value on each evaluation.</param>
|
||||
/// <returns>An <see cref="IDisposable"/> that removes the observer when disposed.</returns>
|
||||
public IDisposable Subscribe(string path, Action<string, DataValueSnapshot> observer)
|
||||
{
|
||||
// Race-safe pattern paired with Unsub.Dispose: if Unsub.Dispose removed the
|
||||
@@ -308,11 +292,6 @@ public sealed class VirtualTagEngine : IDisposable
|
||||
{
|
||||
if (!_tags.TryGetValue(path, out var state)) return;
|
||||
|
||||
// Serial evaluation across all tags. Phase 7 plan decision #19 — parallel is a
|
||||
// follow-up. The semaphore bounds the evaluation graph so two cascades don't
|
||||
// interleave, which would break the "earlier nodes computed first" invariant.
|
||||
// SemaphoreSlim.WaitAsync is async-safe where Monitor.Enter is not (Monitor
|
||||
// ownership is thread-local and lost across await).
|
||||
await _evalGate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
@@ -397,7 +376,7 @@ public sealed class VirtualTagEngine : IDisposable
|
||||
/// <c>BadInternalError</c> — the correct sentinel for "script ran but faulted"
|
||||
/// rather than "inputs not yet available". Previously the null-value check
|
||||
/// caused a Good-quality null upstream to permanently block all its dependents at
|
||||
/// <c>BadWaitingForInitialData</c> (Core.VirtualTags-014).
|
||||
/// <c>BadWaitingForInitialData</c>.
|
||||
/// </summary>
|
||||
private static bool AreInputsReady(IReadOnlyDictionary<string, DataValueSnapshot> cache)
|
||||
{
|
||||
@@ -529,8 +508,6 @@ public sealed class VirtualTagEngine : IDisposable
|
||||
UnsubscribeFromUpstream();
|
||||
_tags.Clear();
|
||||
_graph.Clear();
|
||||
// Dispose every compiled-script ALC so the engine's shutdown actually
|
||||
// releases the emitted assemblies. (Core.Scripting-016)
|
||||
_compileCache.Dispose();
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||
/// <summary>
|
||||
/// Implements the driver-agnostic capability surface the
|
||||
/// <c>DriverNodeManager</c> dispatches to when a node resolves to
|
||||
/// <c>NodeSource.Virtual</c> per ADR-002. Reads return the engine's last-known
|
||||
/// <c>NodeSource.Virtual</c>. Reads return the engine's last-known
|
||||
/// evaluation result; subscriptions forward engine-emitted change events as
|
||||
/// <see cref="ISubscribable.OnDataChange"/> events.
|
||||
/// </summary>
|
||||
@@ -14,7 +14,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||
/// <para>
|
||||
/// <see cref="IWritable"/> is deliberately not implemented — OPC UA client
|
||||
/// writes to virtual tags are rejected in <c>DriverNodeManager</c> before they
|
||||
/// reach here per Phase 7 decision #6. Scripts are the only write path, routed
|
||||
/// reach here. Scripts are the only write path, routed
|
||||
/// through <c>ctx.SetVirtualTag</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
@@ -37,12 +37,7 @@ public sealed class VirtualTagSource : IReadable, ISubscribable
|
||||
/// </summary>
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
|
||||
/// <summary>
|
||||
/// Reads data values asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="fullReferences">The full references to read.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A list of data value snapshots.</returns>
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -53,13 +48,7 @@ public sealed class VirtualTagSource : IReadable, ISubscribable
|
||||
return Task.FromResult<IReadOnlyList<DataValueSnapshot>>(results);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to data changes asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="fullReferences">The full references to subscribe to.</param>
|
||||
/// <param name="publishingInterval">The publishing interval.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A subscription handle.</returns>
|
||||
/// <inheritdoc />
|
||||
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> fullReferences,
|
||||
TimeSpan publishingInterval,
|
||||
@@ -87,12 +76,7 @@ public sealed class VirtualTagSource : IReadable, ISubscribable
|
||||
return Task.FromResult<ISubscriptionHandle>(handle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unsubscribes from data changes asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="handle">The subscription handle.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
/// <inheritdoc />
|
||||
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
if (handle is null) throw new ArgumentNullException(nameof(handle));
|
||||
@@ -114,9 +98,7 @@ public sealed class VirtualTagSource : IReadable, ISubscribable
|
||||
/// <param name="id">The subscription identifier.</param>
|
||||
public SubscriptionHandle(string id) { DiagnosticId = id; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the diagnostic identifier.
|
||||
/// </summary>
|
||||
/// <inheritdoc />
|
||||
public string DiagnosticId { get; }
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Tri-state result of an <see cref="IPermissionEvaluator.Authorize"/> call, per decision
|
||||
/// #149. Phase 6.2 only produces <see cref="AuthorizationVerdict.Allow"/> and
|
||||
/// Tri-state result of an <see cref="IPermissionEvaluator.Authorize"/> call.
|
||||
/// Phase 6.2 only produces <see cref="AuthorizationVerdict.Allow"/> and
|
||||
/// <see cref="AuthorizationVerdict.NotGranted"/>; the <see cref="AuthorizationVerdict.Denied"/>
|
||||
/// variant exists in the model so v2.1 Explicit Deny lands without an API break. Provenance
|
||||
/// carries the matched grants (or empty when not granted) for audit + the Admin UI "Probe
|
||||
@@ -19,6 +19,7 @@ public sealed record AuthorizationDecision(
|
||||
public bool IsAllowed => Verdict == AuthorizationVerdict.Allow;
|
||||
|
||||
/// <summary>Convenience constructor for the common "no grants matched" outcome.</summary>
|
||||
/// <returns>An authorization decision with the <see cref="AuthorizationVerdict.NotGranted"/> verdict.</returns>
|
||||
public static AuthorizationDecision NotGranted() => new(AuthorizationVerdict.NotGranted, []);
|
||||
|
||||
/// <summary>Allow with the list of grants that matched.</summary>
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
/// <remarks>
|
||||
/// Data-plane only. Reads <c>NodeAcl</c> rows joined against the session's resolved LDAP
|
||||
/// groups (via <see cref="UserAuthorizationState"/>). Must not depend on the control-plane
|
||||
/// admin-role mapping table per decision #150 — the two concerns share zero runtime code.
|
||||
/// admin-role mapping table — the two concerns share zero runtime code.
|
||||
/// </remarks>
|
||||
public interface IPermissionEvaluator
|
||||
{
|
||||
@@ -22,5 +22,6 @@ public interface IPermissionEvaluator
|
||||
/// <param name="session">The user session containing resolved LDAP groups and roles.</param>
|
||||
/// <param name="operation">The OPC UA operation being requested.</param>
|
||||
/// <param name="scope">The node address scope being accessed.</param>
|
||||
/// <returns>An authorization decision indicating whether the operation is allowed.</returns>
|
||||
AuthorizationDecision Authorize(UserAuthorizationState session, OpcUaOperation operation, NodeScope scope);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
/// to <see cref="IPermissionEvaluator"/> which walks the matching trie path.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Per decision #129 and the Phase 6.2 Stream B plan the hierarchy is
|
||||
/// <para>Per the Phase 6.2 Stream B plan the hierarchy is
|
||||
/// <c>Cluster → Namespace → UnsArea → UnsLine → Equipment → Tag</c> for all
|
||||
/// (Equipment-kind) namespaces. Galaxy is a standard Equipment-kind driver, so Galaxy
|
||||
/// points are ordinary equipment tags that resolve through this same walk.</para>
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
/// requested <see cref="Abstractions.OpcUaOperation"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per decision #129 (additive grants, no explicit Deny in v2.0) the walk is pure union:
|
||||
/// The walk is pure union (additive grants; no explicit Deny in v2.0):
|
||||
/// encountering a grant at any level contributes its flags, never revokes them. A grant at
|
||||
/// the Cluster root therefore cascades to every tag below it; a grant at a deep equipment
|
||||
/// leaf is visible only on that equipment subtree.
|
||||
@@ -33,6 +33,7 @@ public sealed class PermissionTrie
|
||||
/// </summary>
|
||||
/// <param name="scope">The node scope to match permissions for.</param>
|
||||
/// <param name="ldapGroups">The user's LDAP group memberships.</param>
|
||||
/// <returns>The matched grants accumulated across every trie level visited for the scope.</returns>
|
||||
public IReadOnlyList<MatchedGrant> CollectMatches(NodeScope scope, IEnumerable<string> ldapGroups)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scope);
|
||||
|
||||
@@ -37,10 +37,11 @@ public static class PermissionTrieBuilder
|
||||
/// <param name="diagnostic">
|
||||
/// Optional callback invoked when a sub-cluster row's <c>ScopeId</c> cannot be located
|
||||
/// in <paramref name="scopePaths"/>. Production callers should wire a logger here so
|
||||
/// orphaned grants surface — silently dropping them under the wrong trie level was the
|
||||
/// Core-011 production hazard. The callback fires only when <paramref name="scopePaths"/>
|
||||
/// orphaned grants surface — silently dropping them under the wrong trie level was a
|
||||
/// production hazard. The callback fires only when <paramref name="scopePaths"/>
|
||||
/// is non-null (a null lookup is the explicit deterministic-test fallback mode).
|
||||
/// </param>
|
||||
/// <returns>The built <see cref="PermissionTrie"/> for the cluster + generation.</returns>
|
||||
public static PermissionTrie Build(
|
||||
string clusterId,
|
||||
long generationId,
|
||||
@@ -128,8 +129,7 @@ public sealed record NodeAclPath(IReadOnlyList<string> Segments);
|
||||
/// <summary>
|
||||
/// Diagnostic emitted by <see cref="PermissionTrieBuilder.Build"/> when a row could not be
|
||||
/// placed at its structurally-correct trie node. Production callers should log these so
|
||||
/// orphaned grants surface instead of being silently dropped under an unreachable node
|
||||
/// (Core-011).
|
||||
/// orphaned grants surface instead of being silently dropped under an unreachable node.
|
||||
/// </summary>
|
||||
/// <param name="NodeAclId">The offending row's logical id.</param>
|
||||
/// <param name="ScopeKind">The row's <see cref="NodeAclScopeKind"/>.</param>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user