docs: fill missing XML docs and strip tracking IDs from src comments

Clears CommentChecker findings in src/ (3,280 -> 209, the remainder being false
positives like PLC-5/IEEE-754/M0.0) by adding missing summaries/params/returns,
normalising inheritdoc usage, and removing Gitea/review tracking IDs while keeping
the rationale they annotated. Comment-only: comment-stripped sources are identical
to the parent commit.
This commit is contained in:
Joseph Doherty
2026-10-15 06:14:58 -04:00
parent ebcbd81fca
commit 826312befe
430 changed files with 4434 additions and 3923 deletions
@@ -58,7 +58,7 @@ public sealed class AkkaClusterOptions
/// <see cref="RoleParser.ResolveEffectiveRoles(IReadOnlyList{string}?)"/>. That fallback was
/// described here — and relied upon in the field — for a year before it was implemented; a
/// node configured only through the env var joined the cluster carrying no roles at all.
/// See Gitea #532 and <c>docs/deployments/wonder-app-vd03-makino-z-34184.md</c>.
/// See <c>docs/deployments/wonder-app-vd03-makino-z-34184.md</c>.
/// </para>
/// <para>
/// The two sources normalize differently: values bound from <c>Cluster:Roles</c> are taken
@@ -44,7 +44,7 @@ public sealed class AkkaClusterOptionsValidator : OptionsValidatorBase<AkkaClust
/// DI-constructed by <c>AddValidatedOptions</c> (a plain <c>AddSingleton</c>), so constructor
/// dependencies are safe here. Both <paramref name="effectiveRoles"/> and
/// <paramref name="configuration"/> are optional (default <see langword="null"/>) so a caller
/// with neither to hand — including every test in this file that pre-dates R3-04 — keeps
/// with neither to hand — including every test in this file that pre-dates the post-overlay guard — keeps
/// constructing this validator parameterlessly; the post-overlay guard below (choice 4) is
/// simply skipped when either is null.
/// </summary>
@@ -116,7 +116,7 @@ public sealed class AkkaClusterOptionsValidator : OptionsValidatorBase<AkkaClust
}
/// <summary>
/// R3-04 (choice 4, the residual divergence surface): <c>Program.cs</c> resolves
/// Post-overlay guard (choice 4, the residual divergence surface): <c>Program.cs</c> resolves
/// <see cref="EffectiveRoles"/> BEFORE the per-role appsettings overlay loads (the overlay's
/// filename depends on the answer), so if a role overlay — or a <c>${secret:}</c> expansion —
/// ever set <c>Cluster:Roles</c> itself, that value would silently disagree with the
@@ -46,7 +46,9 @@ public sealed class ClusterBootstrapCoordinator : IHostedService
_log = log ?? throw new ArgumentNullException(nameof(log));
}
/// <inheritdoc/>
/// <summary>Starts the bootstrap join in the background when the bootstrap guard is enabled.</summary>
/// <param name="cancellationToken">Not used; the join runs under the coordinator's own cancellation source.</param>
/// <returns>A completed task; the join itself continues asynchronously.</returns>
public Task StartAsync(CancellationToken cancellationToken)
{
if (!_options.BootstrapGuard.Enabled)
@@ -59,7 +61,9 @@ public sealed class ClusterBootstrapCoordinator : IHostedService
return Task.CompletedTask;
}
/// <inheritdoc/>
/// <summary>Cancels the background join and waits for it to finish.</summary>
/// <param name="cancellationToken">Not used; shutdown is driven by the coordinator's own cancellation source.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task StopAsync(CancellationToken cancellationToken)
{
await _cts.CancelAsync().ConfigureAwait(false);
@@ -63,7 +63,7 @@ public static class ClusterBootstrapGuard
/// <summary>
/// Analyzes the configured seed list to decide this node's bootstrap role. The guard engages only
/// for the Phase-6 pair shape (exactly two seeds, one of them this node); any other shape
/// for the pair shape (exactly two seeds, one of them this node); any other shape
/// (single-seed legacy site node, three+ seeds, self absent) returns <see cref="BootstrapRole.Applies"/>
/// = false and the caller joins the configured seeds unchanged.
/// </summary>
@@ -23,7 +23,7 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
private readonly string? _clusterRole;
private readonly string? _clusterId;
private readonly object _lock = new();
// R3-04 (01/C-9, census row 17) — the two dictionaries below have DIFFERENT correct comparers,
// The two dictionaries below have DIFFERENT correct comparers,
// and that split is deliberate, not an oversight (review finding, 2026-08-20).
//
// _membersByRole answers "every member carrying (a spelling of) this role" — OrdinalIgnoreCase,
@@ -53,7 +53,7 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
// sharing a bind IP) stay distinct. Production hosts have unique DNS names so the port
// suffix is harmless redundancy.
_localNode = CommonsNodeId.Parse($"{options.Value.PublicHostname}:{options.Value.Port}");
// OrdinalIgnoreCase (Gitea #532): Cluster:Roles binds verbatim, so a node authored with 'Driver'
// OrdinalIgnoreCase: Cluster:Roles binds verbatim, so a node authored with 'Driver'
// used to answer HasRole("driver") false while gossiping a role Akka would still match against
// its own ordinal comparison — the node was a driver to the cluster and not to itself.
_localRoles = new HashSet<string>(options.Value.Roles, StringComparer.OrdinalIgnoreCase);
@@ -128,7 +128,7 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
{
lock (_lock)
{
// Cluster-002 (review R1 m6): seed only USABLE members. A member already Leaving / Exiting / Down
// Seed only USABLE members. A member already Leaving / Exiting / Down
// (or still Joining) at seed time is not one, and a departing member never fires a later
// MemberLeft / MemberExited / MemberDowned that HandleMemberEvent would drop it on — it would
// linger until MemberRemoved, the very delay the member-event handling exists to avoid. A
@@ -165,7 +165,7 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
set.Add(up.Member);
}
break;
// Cluster-002: a leaving / exiting / downed member is no longer a usable member, so
// A leaving / exiting / downed member is no longer a usable member, so
// drop it as soon as that transition lands rather than waiting for MemberRemoved
// (several seconds later). MembersWithRole now has a production caller (the
// historization scope-peer check), which must not defer to a departing partner.
@@ -210,8 +210,8 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
// Record the event's own address. The leader used to be resolved by looking the address up
// in Cluster.State.Members, but that snapshot is published asynchronously and can lag the
// event: a miss stored null and nothing re-ran, so RoleLeader(role) stayed null for good
// (Gitea #590). Only the address is ever read, so no Member lookup is needed.
// event: a miss stored null and nothing re-ran, so RoleLeader(role) stayed null for good.
// Only the address is ever read, so no Member lookup is needed.
_roleLeaders[evt.Role] = evt.Leader;
if (evt.Leader is not null)
next = ToNodeId(evt.Leader);
@@ -21,14 +21,18 @@ public sealed class ConfigSourceOptionsValidator : IValidateOptions<ConfigSource
/// DI-constructed by <c>AddValidatedOptions</c> (a plain <c>AddSingleton</c>), so a
/// constructor dependency is safe here. This validator's only use of configuration is this
/// node's roles, so it takes <see cref="EffectiveRoles"/> — the single role authority
/// (archreview R3-04) — directly rather than <see cref="IConfiguration"/>.
/// — directly rather than <see cref="IConfiguration"/>.
/// </summary>
/// <param name="effectiveRoles">The node's effective roles, used to decide which fetch settings are required.</param>
public ConfigSourceOptionsValidator(EffectiveRoles effectiveRoles)
{
_effectiveRoles = effectiveRoles;
}
/// <inheritdoc />
/// <summary>Validates the <see cref="ConfigSourceOptions"/> for this node's roles.</summary>
/// <param name="name">The options instance name (unused).</param>
/// <param name="options">The options to validate.</param>
/// <returns>A success result, or a failure listing every violated rule.</returns>
public ValidateOptionsResult Validate(string? name, ConfigSourceOptions options)
{
ArgumentNullException.ThrowIfNull(options);
@@ -92,8 +96,8 @@ public sealed class ConfigSourceOptionsValidator : IValidateOptions<ConfigSource
// Per-cluster mesh Phase 4: a driver-only node has no central ConfigDb connection to fall back
// on, so Direct is only ever valid on a fused admin+driver node (admin holds the SQL
// connection). EffectiveRoles is the single role authority (archreview R3-04): its precedence
// already applies the OTOPCUA_ROLES fallback (Gitea #532) and its normalization already folds
// connection). EffectiveRoles is the single role authority: its precedence
// already applies the OTOPCUA_ROLES fallback and its normalization already folds
// case, so neither an env-only driver-only node nor one spelled 'Driver' can sail through here
// on Direct to read its configuration from nothing.
var isDriver = _effectiveRoles.HasDriver;
@@ -69,6 +69,7 @@ public sealed class EffectiveRoles
/// <summary>True when <paramref name="role"/> is among <see cref="Gossiped"/>, compared case-insensitively.</summary>
/// <param name="role">The role to test for.</param>
/// <returns><see langword="true"/> when the role is present; otherwise <see langword="false"/>.</returns>
public bool Has(string role) =>
Gossiped.Any(r => string.Equals(r, role, StringComparison.OrdinalIgnoreCase));
@@ -23,7 +23,7 @@ public sealed class MeshTransportOptionsValidator : IValidateOptions<MeshTranspo
/// DI-constructed by <c>AddValidatedOptions</c> (a plain <c>AddSingleton</c>), so constructor
/// dependencies are safe here. <see cref="IConfiguration"/> is still needed for the
/// <c>ConfigServe:GrpcListenPort</c> cross-read (the admin-side obligation below) —
/// <see cref="EffectiveRoles"/> is the single role authority (archreview R3-04) this rule reads
/// <see cref="EffectiveRoles"/> is the single role authority this rule reads
/// roles through instead of re-deriving them.
/// </summary>
/// <param name="configuration">The application configuration, read for <c>ConfigServe:GrpcListenPort</c>.</param>
@@ -34,7 +34,10 @@ public sealed class MeshTransportOptionsValidator : IValidateOptions<MeshTranspo
_effectiveRoles = effectiveRoles;
}
/// <inheritdoc />
/// <summary>Validates the <see cref="MeshTransportOptions"/> for this node's roles.</summary>
/// <param name="name">The options instance name (unused).</param>
/// <param name="options">The options to validate.</param>
/// <returns>A success result, or a failure listing every violated rule.</returns>
public ValidateOptionsResult Validate(string? name, MeshTransportOptions options)
{
ArgumentNullException.ThrowIfNull(options);
@@ -71,7 +74,7 @@ public sealed class MeshTransportOptionsValidator : IValidateOptions<MeshTranspo
// dials nodes rather than being dialled, and resolves per-cluster keys at runtime through
// MeshTransportOptions.ClusterPsks / the Secrets store, so requiring a single GrpcPsk and an
// endpoint list of it would demand configuration it never reads. EffectiveRoles is the single
// role authority (archreview R3-04) — its precedence already applies the OTOPCUA_ROLES
// role authority — its precedence already applies the OTOPCUA_ROLES
// fallback and its normalization already folds case, so a node configured only through the env
// var, or spelled 'Driver', is still the driver that has to dial central.
var isDriver = _effectiveRoles.HasDriver;
@@ -24,7 +24,7 @@ public static class RoleParser
/// <summary>Returns true when <paramref name="role"/> is a cluster-scoped role — starts with
/// <see cref="ClusterRolePrefix"/> and carries a non-empty ClusterId suffix.</summary>
/// <remarks>
/// The prefix test is <b>case-insensitive</b> (Gitea #532). It was ordinal, which meant a
/// The prefix test is <b>case-insensitive</b>. It was ordinal, which meant a
/// <c>Cluster:Roles</c> entry spelled <c>Cluster-SITE-A</c> — that path binds verbatim, unlike
/// the <see cref="Parse"/> path, which lower-cases — classified as an ordinary role. The node
/// then resolved a <see langword="null"/> ClusterId while still gossiping a cluster-scoped role,
@@ -33,6 +33,7 @@ public static class RoleParser
/// <c>ClusterRoleInfo</c> did not, and got the null.
/// </remarks>
/// <param name="role">The role name to test.</param>
/// <returns><see langword="true"/> if the role is a cluster-scoped role.</returns>
public static bool IsClusterRole(string role) =>
role is not null
&& role.StartsWith(ClusterRolePrefix, StringComparison.OrdinalIgnoreCase)
@@ -47,9 +48,10 @@ public static class RoleParser
/// <c>OTOPCUA_ROLES</c> path (⇒ <c>site-a</c>) — and normalizing one side would silently rename
/// the Akka role of a running deployment (docker-dev's rig configures <c>cluster-MAIN</c> /
/// <c>cluster-SITE-A</c> verbatim today). Every consumer of a ClusterId therefore compares it
/// case-insensitively; see the identity rule on <c>AclScopeRef</c> (Gitea #533).
/// case-insensitively; see the identity rule on <c>AclScopeRef</c>.
/// </remarks>
/// <param name="role">The cluster-scoped role name.</param>
/// <returns>The ClusterId portion of the role, with the role prefix removed.</returns>
public static string ClusterIdFromRole(string role) => role[ClusterRolePrefix.Length..];
/// <summary>
@@ -72,7 +74,7 @@ public static class RoleParser
/// cluster-scoped role (a legacy / single-mesh node).
/// </summary>
/// <remarks>
/// The single home for a derivation that was open-coded at four call sites before Gitea #532
/// The single home for a derivation that was open-coded at four call sites before
/// (<c>ClusterRoleInfo</c>, the ControlPlane singleton scope, <c>SplitTopologyTransportValidator</c>
/// and <c>ConfigDbApiKeyStore</c>) — each with its own view of whether the classification was
/// case-sensitive, which is how the mixed-case gap above survived in three of the four.
@@ -91,7 +93,7 @@ public static class RoleParser
/// </summary>
/// <remarks>
/// <para>
/// ⚠️ <b>This fallback was documented for a year before it existed</b> (Gitea #532).
/// ⚠️ <b>This fallback was documented for a year before it existed</b>.
/// <c>AkkaClusterOptions.Roles</c>' own XML doc and <see cref="SplitTopologyTransportValidator"/>
/// both described it; only the validator implemented it, privately. A node configured solely
/// through <c>OTOPCUA_ROLES</c> — which drives every bit of <c>Program.cs</c>'s conditional
@@ -114,7 +116,7 @@ public static class RoleParser
/// <exception cref="ArgumentException">Either source names an unknown role.</exception>
/// <remarks>
/// Thin delegation to <see cref="EffectiveRoles.Resolve(IReadOnlyList{string}?, string?)"/> —
/// the single role authority (archreview R3-04). <see cref="EffectiveRoles"/> is the type to
/// the single role authority. <see cref="EffectiveRoles"/> is the type to
/// reach for directly; this overload is retained so existing callers of the array-returning
/// shape keep compiling.
/// </remarks>
@@ -35,7 +35,7 @@ public static class ServiceCollectionExtensions
services.AddValidatedOptions<AkkaClusterOptions, AkkaClusterOptionsValidator>(
configuration, AkkaClusterOptions.SectionName);
// R3-04 (structural identity, choice 3): EffectiveRoles is resolved ONCE and registered as a
// Structural identity: EffectiveRoles is resolved ONCE and registered as a
// singleton, off the SAME IConfiguration this call received — not off a DI-resolved
// IConfiguration, which a plain ServiceCollection (as opposed to a WebApplicationBuilder) never
// registers. Every consumer (Program.cs's hasAdmin/hasDriver, the four role-scoped validators,
@@ -54,7 +54,7 @@ public static class ServiceCollectionExtensions
// otherwise; TryAddSingleton is a no-op on a real host, where it is already registered.
services.TryAddSingleton(configuration);
// Gitea #532 (superseded by R3-04): apply the OTOPCUA_ROLES fallback the Roles property has
// Apply the OTOPCUA_ROLES fallback the Roles property has
// always DOCUMENTED, in the one place every consumer reads through. Before this, a node
// configured only through the env var — the knob that drives all of Program.cs's role wiring —
// bound an EMPTY Roles array and so joined the cluster role-less: invisible to
@@ -14,7 +14,7 @@ namespace ZB.MOM.WW.OtOpcUa.Cluster;
/// Phase 2/3/5 dark switches keep their DPS branches alive, but DPS is mesh-wide — it delivers
/// nothing between a central node and a site node that no longer share a gossip ring. The
/// marker of the split topology is a <c>cluster-{ClusterId}</c> role
/// (<see cref="RoleParser.IsClusterRole"/>); those roles exist only post-Phase-6. A node that
/// (<see cref="RoleParser.IsClusterRole"/>); those roles exist only in the per-cluster mesh topology. A node that
/// carries one <b>must</b> use the mesh-crossing transports, and this validator refuses to
/// boot it otherwise — the alternative is a node that runs, listens, and silently delivers no
/// commands or telemetry, which has no stack trace and no failing node to point at.
@@ -44,16 +44,21 @@ public sealed class SplitTopologyTransportValidator : IValidateOptions<MeshTrans
/// transport-mode cross-reads (<c>MeshTransport:Mode</c>, <c>Telemetry:Mode</c>,
/// <c>TelemetryDial:Mode</c>) — not <c>IClusterRoleInfo</c>, whose implementation needs the
/// <c>ActorSystem</c>, which does not exist yet at <c>ValidateOnStart</c> time.
/// <see cref="EffectiveRoles"/> is the single role authority (archreview R3-04) — this rule no
/// <see cref="EffectiveRoles"/> is the single role authority — this rule no
/// longer re-derives the effective, normalized role list itself.
/// </summary>
/// <param name="configuration">The host configuration used to cross-read the transport-mode settings.</param>
/// <param name="effectiveRoles">The node's effective, normalized role list.</param>
public SplitTopologyTransportValidator(IConfiguration configuration, EffectiveRoles effectiveRoles)
{
_configuration = configuration;
_effectiveRoles = effectiveRoles;
}
/// <inheritdoc />
/// <summary>Fails startup when a split-topology node is left on a transport mode that cannot work there.</summary>
/// <param name="name">The options instance name; unused.</param>
/// <param name="options">The mesh transport options; unused because every input is cross-read from configuration.</param>
/// <returns>A success result, or a failure naming the offending transport setting.</returns>
public ValidateOptionsResult Validate(string? name, MeshTransportOptions options)
{
// The options instance is intentionally unused: every value this rule depends on is cross-read
@@ -93,14 +93,18 @@ public sealed class TelemetryOptionsValidator : IValidateOptions<TelemetryOption
/// DI-constructed by <c>AddValidatedOptions</c> (a plain <c>AddSingleton</c>), so a
/// constructor dependency is safe here. This validator's only use of configuration is this
/// node's roles, so it takes <see cref="EffectiveRoles"/> — the single role authority
/// (archreview R3-04) — directly rather than <see cref="IConfiguration"/>.
/// — directly rather than <see cref="IConfiguration"/>.
/// </summary>
/// <param name="effectiveRoles">The node's resolved roles, the single role authority.</param>
public TelemetryOptionsValidator(EffectiveRoles effectiveRoles)
{
_effectiveRoles = effectiveRoles;
}
/// <inheritdoc />
/// <summary>Validates the telemetry options against this node's roles.</summary>
/// <param name="name">The named options instance, or <see langword="null"/> for the default.</param>
/// <param name="options">The options to validate.</param>
/// <returns>Success, or a failure listing every invalid setting.</returns>
public ValidateOptionsResult Validate(string? name, TelemetryOptions options)
{
ArgumentNullException.ThrowIfNull(options);
@@ -117,8 +121,8 @@ public sealed class TelemetryOptionsValidator : IValidateOptions<TelemetryOption
+ $"Expected '{TelemetryOptions.ModeDps}' or '{TelemetryOptions.ModeGrpc}'.");
}
// EffectiveRoles is the single role authority (archreview R3-04) — its precedence already
// applies the OTOPCUA_ROLES fallback (Gitea #532) and its normalization already folds case, so
// EffectiveRoles is the single role authority — its precedence already
// applies the OTOPCUA_ROLES fallback and its normalization already folds case, so
// a node configured only through the env var, or spelled 'Driver', still satisfies the
// Grpc-mode requirements below.
var isDriver = _effectiveRoles.HasDriver;
@@ -157,7 +161,10 @@ public sealed class TelemetryOptionsValidator : IValidateOptions<TelemetryOption
/// </summary>
public sealed class TelemetryDialOptionsValidator : IValidateOptions<TelemetryDialOptions>
{
/// <inheritdoc />
/// <summary>Validates the telemetry dial options.</summary>
/// <param name="name">The named options instance, or <see langword="null"/> for the default.</param>
/// <param name="options">The options to validate.</param>
/// <returns>Success, or a failure listing every invalid setting.</returns>
public ValidateOptionsResult Validate(string? name, TelemetryDialOptions options)
{
ArgumentNullException.ThrowIfNull(options);
@@ -56,7 +56,8 @@ public sealed class CapturedTreeBrowseSession(CapturedTree tree) : IBrowseSessio
return Task.FromResult<IReadOnlyList<AttributeInfo>>(attrs);
}
/// <inheritdoc />
/// <summary>Drops this session's reference to the captured tree; the tree itself may be shared.</summary>
/// <returns>A completed <see cref="ValueTask"/>.</returns>
public ValueTask DisposeAsync()
{
_tree = null; // drop this session's reference only — tree may be shared (coalescing)
@@ -68,7 +69,7 @@ public sealed class CapturedTreeBrowseSession(CapturedTree tree) : IBrowseSessio
/// <summary>
/// Surfaces a leaf's driver-native type name as the <c>dataType</c> address field, so a
/// browse-commit can stamp the driver-typed <c>TagConfig</c> with the type the controller
/// actually reported (Gitea #536).
/// actually reported.
/// </summary>
/// <param name="a">The captured attribute.</param>
/// <returns>The address fields, or null when the driver reported no native type.</returns>
@@ -80,7 +81,7 @@ public sealed class CapturedTreeBrowseSession(CapturedTree tree) : IBrowseSessio
/// reverse map would bind a 16-bit INT as a 32-bit DINT and read four bytes for two.
/// <para>
/// Null when the driver stamped nothing — the mapper then leaves the model's own default in
/// place, which is exactly the pre-#536 behaviour rather than a guess.
/// place, which is exactly the previous behaviour rather than a guess.
/// </para>
/// </remarks>
private static IReadOnlyDictionary<string, string>? NativeTypeFields(DriverAttributeInfo a)
@@ -17,6 +17,8 @@ public sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder
public bool Truncated;
public readonly int NodeCap = nodeCap;
public readonly Dictionary<string, CapturedNode> ById = new(StringComparer.Ordinal);
/// <summary>Reserves a slot for one more node, marking the capture truncated when the cap is reached.</summary>
/// <returns><see langword="true"/> when the node may be recorded; <see langword="false"/> when the cap is reached.</returns>
public bool TryReserve()
{
if (Count >= NodeCap) { Truncated = true; return false; }
@@ -96,7 +98,10 @@ public sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder
private sealed class CapturedVariableHandle(CapturedNode node) : IVariableHandle
{
/// <inheritdoc />
public string FullReference => node.Id;
/// <inheritdoc />
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
{
node.IsAlarmMarked = true;
@@ -107,6 +112,7 @@ public sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder
private sealed class NoopAlarmSink : IAlarmConditionSink
{
public static readonly NoopAlarmSink Instance = new();
/// <inheritdoc />
public void OnTransition(AlarmEventArgs args) { } // browse never delivers live transitions
}
}
@@ -12,7 +12,7 @@ public sealed class DiscoveryDriverBrowser : IUniversalDriverBrowser
/// 20 s per-call browse timeout, which only covers serving from memory.</summary>
public static readonly TimeSpan OpenTimeout = TimeSpan.FromSeconds(60);
/// <summary>Bound on the cleanup ShutdownAsync (R2-01: no unbounded waits — a driver wedged
/// <summary>Bound on the cleanup ShutdownAsync (no unbounded waits — a driver wedged
/// in discovery may be equally wedged in shutdown).</summary>
public static readonly TimeSpan ShutdownTimeout = TimeSpan.FromSeconds(10);
@@ -46,7 +46,11 @@ public sealed class DiscoveryDriverBrowser : IUniversalDriverBrowser
public DiscoveryDriverBrowser(IDriverFactory driverFactory, ILogger<DiscoveryDriverBrowser> logger)
: this(driverFactory, logger, OpenTimeout, ShutdownTimeout) { }
// Test seam: short timeouts.
/// <summary>Test seam: constructor with explicit open and shutdown bounds.</summary>
/// <param name="driverFactory">Factory used to construct throwaway/capture driver instances.</param>
/// <param name="logger">Logger for shutdown-timeout warnings.</param>
/// <param name="openTimeout">Upper bound on opening a capture driver and running discovery.</param>
/// <param name="shutdownTimeout">Upper bound on the cleanup shutdown of a capture driver.</param>
internal DiscoveryDriverBrowser(IDriverFactory driverFactory, ILogger<DiscoveryDriverBrowser> logger,
TimeSpan openTimeout, TimeSpan shutdownTimeout)
{
@@ -111,6 +115,10 @@ public sealed class DiscoveryDriverBrowser : IUniversalDriverBrowser
}
}
/// <summary>Builds the in-flight de-duplication key for a capture request.</summary>
/// <param name="driverType">The driver type name.</param>
/// <param name="configJson">The driver configuration JSON.</param>
/// <returns>A key combining the lower-cased driver type and a hash of the configuration.</returns>
internal static string CaptureKey(string driverType, string configJson) =>
driverType.ToLowerInvariant() + ":" +
Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(
@@ -195,6 +203,10 @@ public sealed class DiscoveryDriverBrowser : IUniversalDriverBrowser
}
}
/// <summary>Applies the driver-specific browse-time patch (if any) to the configuration JSON.</summary>
/// <param name="driverType">The driver type name.</param>
/// <param name="configJson">The driver configuration JSON.</param>
/// <returns>The patched configuration JSON, or the input unchanged when the driver type has no patch.</returns>
internal static string PatchForBrowse(string driverType, string configJson)
{
if (!BrowsePatches.TryGetValue(driverType, out var patch)) return configJson;
@@ -13,7 +13,7 @@ public enum ApplyAckOutcome { Applied, Failed }
/// <param name="FailureReason">Why the apply failed; null on <see cref="ApplyAckOutcome.Applied"/>.</param>
/// <param name="CorrelationId">The dispatch's correlation id.</param>
/// <param name="Degraded">
/// Gitea #601: <see langword="true"/> when the node applied the deployment (the revision advanced) but
/// <see langword="true"/> when the node applied the deployment (the revision advanced) but
/// its OPC UA address-space apply was DEGRADED — swallowed per-node sink faults, a failed rebuild, or no
/// outcome reported in time — so the served address space may be partial. Only meaningful with
/// <see cref="ApplyAckOutcome.Applied"/>. Trailing and defaulted so an ack from a node built before
@@ -30,7 +30,7 @@ public sealed record ApplyAck(
string? DegradedReason = null)
{
/// <summary>
/// Gitea #601 — prefix of <c>NodeDeploymentState.FailureReason</c> on an <b>Applied</b> row whose
/// The prefix of <c>NodeDeploymentState.FailureReason</c> on an <b>Applied</b> row whose
/// address-space apply was degraded. Shared by central (which writes it from the ack) and a node with
/// a ConfigDb (which writes its own row), so both spell the row identically.
/// </summary>
@@ -11,11 +11,11 @@ public static class DeployTimings
/// (<c>ConfigPublishCoordinator.DefaultApplyDeadline</c>).</summary>
public static readonly TimeSpan ApplyDeadline = TimeSpan.FromMinutes(2);
/// <summary>Gitea #601 — how far inside <see cref="ApplyDeadline"/> a node's held Applied ack is released
/// <summary>How far inside <see cref="ApplyDeadline"/> a node's held Applied ack is released
/// when the address-space outcome has not arrived, leaving room for transport latency.</summary>
public static readonly TimeSpan ApplyOutcomeSafetyMargin = TimeSpan.FromSeconds(15);
/// <summary>Gitea #601 — the node's default apply-outcome timeout: <see cref="ApplyDeadline"/> −
/// <summary>The node's default apply-outcome timeout: <see cref="ApplyDeadline"/> −
/// <see cref="ApplyOutcomeSafetyMargin"/> (105 s). Long enough that a slow-but-clean rebuild of a large
/// address space is not labelled degraded, short enough to ack before central gives up.</summary>
/// <remarks>The timer is armed after the artifact read and driver reconcile, so if those alone took
@@ -38,7 +38,7 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
/// so a driver-only node has no ConfigDb connection to write rows to. This channel already reaches
/// <c>/hosts</c>, already survives the mesh split via the Phase 5 gRPC telemetry stream, and already
/// replays a last-value snapshot on every (re)subscribe — so per-host state re-primes after a
/// reconnect without a durable store. The table was dropped; see Gitea #521.</para>
/// reconnect without a durable store. The table was dropped.</para>
/// <para><b>Value-equality caveat.</b> A record's generated <c>Equals</c> compares this list by
/// REFERENCE, so two <c>DriverHealthChanged</c> carrying equal-but-distinct lists are not equal.
/// Nothing dedups on record equality — <c>DriverInstanceActor</c> keeps its own flattened fingerprint
@@ -48,13 +48,13 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
/// What this driver skipped when it last built its tag table, from
/// <c>ISkippedTagReporter.GetSkippedTags()</c>; null when the driver does not implement that
/// capability. All twelve production drivers do.
/// <para><b>Why this is on the health snapshot (Gitea #541).</b> A skip happens at init, is not an
/// <para><b>Why this is on the health snapshot.</b> A skip happens at init, is not an
/// error, and does not stop the driver's OTHER tags from reading — so <c>State</c> stays Healthy,
/// <c>ErrorCount5Min</c> stays 0, <c>LastError</c> stays null and <c>LastSuccessfulReadUtc</c> keeps
/// advancing while the driver ignores part or all of its configuration. On the Makino in Gitea #540
/// advancing while the driver ignores part or all of its configuration. On the Makino
/// that combination held while 20 of 22 tags were ignored. This field is what contradicts it.</para>
/// <para><b>Additive, never a state change.</b> See <c>ISkippedTagReporter</c> for why a skipped tag
/// must not latch the driver non-Healthy — it would satisfy both halves of the #527 comms-loss
/// must not latch the driver non-Healthy — it would satisfy both halves of the comms-loss
/// conjunction and reconnect-loop a healthy device.</para>
/// <para><b>Value-equality caveat</b> — the same one <paramref name="HostStatuses"/> carries: the
/// record's generated <c>Equals</c> compares the sample list by REFERENCE.</para>
@@ -62,7 +62,7 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
/// <param name="Diagnostics">
/// Operator-facing driver diagnostics from <c>IDriverDiagnostics.GetDiagnostics()</c> (e.g. S7's
/// negotiated PDU size), bounded and in ordinal key order; null when the driver does not implement the
/// capability (Gitea #640). Additive, never a state change. Same by-reference value-equality caveat as
/// capability. Additive, never a state change. Same by-reference value-equality caveat as
/// <paramref name="HostStatuses"/>.
/// </param>
public sealed record DriverHealthChanged(
@@ -216,7 +216,7 @@ public static class MeshTransportDtoMapper
if (message.FailureReason is not null)
proto.FailureReason = message.FailureReason;
// #601 — additive fields; a clean ack leaves both unset, byte-identical to a pre-#601 encode.
// Additive fields; a clean ack leaves both unset, byte-identical to an encode that predates these fields.
proto.Degraded = message.Degraded;
if (message.DegradedReason is not null)
proto.DegradedReason = message.DegradedReason;
@@ -255,7 +255,7 @@ public static class MeshTransportDtoMapper
outcome,
proto.HasFailureReason ? proto.FailureReason : null,
CorrelationId.Parse(proto.CorrelationId),
// #601 — absent on an old node's ack, which decodes as false/null: today's clean ack.
// Absent on an old node's ack, which decodes as false/null: today's clean ack.
Degraded: proto.Degraded,
DegradedReason: proto.HasDegradedReason ? proto.DegradedReason : null);
}
@@ -77,7 +77,7 @@ public static class OtOpcUaTelemetry
public const string PollOverrunDriverInstanceTag = "driver_instance";
/// <summary>
/// Session/publish-level events of an OPC UA Client driver instance (#276): the operator's view of a
/// Session/publish-level events of an OPC UA Client driver instance: the operator's view of a
/// flapping upstream. Tagged <see cref="PollOverrunDriverInstanceTag"/> (<c>driver_instance</c>) +
/// <c>event</c> = <c>publish_response|notification|data_change|bad_status_value|publish_error|
/// keepalive_miss|keepalive_recovered|stale_notification_dropped</c>.
@@ -19,15 +19,15 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
/// <param name="StatusCode">
/// Status code of the condition's source data (Part 9 <c>ConditionType.Quality</c>). Carried so a
/// comms-lost native source can report a non-<c>Good</c> condition instead of the accidentally-Good
/// default (issue #477). It is a pure annotation — it never alters Active/Acked/Retain. Defaults to
/// default. It is a pure annotation — it never alters Active/Acked/Retain. Defaults to
/// <see cref="OpcUaStatus.Good"/> so scripted-alarm callers (which stay Good in v1) need not supply it.
/// Carries the full sub-code since #519, so a comms-lost condition reports
/// Carries the full sub-code, so a comms-lost condition reports
/// <see cref="OpcUaStatus.BadNoCommunication"/> rather than a bare <see cref="OpcUaStatus.Bad"/>.
/// </param>
/// <param name="UnshelveAtUtc">
/// For <see cref="AlarmShelvingKind.Timed"/>, the UTC instant the engine expires the shelve; otherwise
/// <c>null</c>. Carried so the node's Part 9 <c>ShelvingState.UnshelveTime</c> reports the real remaining
/// time instead of the SDK's ~24.8-day ceiling (Gitea #597). A timed snapshot without it (a caller that
/// time instead of the SDK's ~24.8-day ceiling. A timed snapshot without it (a caller that
/// predates the field) falls back to that ceiling — the engine still owns the expiry either way.
/// </param>
public sealed record AlarmConditionSnapshot(
@@ -44,6 +44,7 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
=> _inner.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm);
/// <inheritdoc />
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
=> _inner.WriteAlarmQuality(alarmNodeId, statusCode, sourceTimestampUtc, realm);
@@ -52,7 +53,7 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
=> _inner.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, realm, isNative);
/// <inheritdoc />
// Forward the WP4 multi-notifier wiring to the inner sink. Without this the native-alarm fan-out to the
// Forward the multi-notifier wiring to the inner sink. Without this the native-alarm fan-out to the
// referencing equipment folders ships INERT on every driver-role host (actors inject THIS wrapper, not the
// inner SdkAddressSpaceSink) — the F10b / PR#423 forwarding trap the reflection guard exists to catch.
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
@@ -100,7 +101,7 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
=> _inner is ISurgicalAddressSpaceSink surgical
&& surgical.UpdateFolderDisplayName(folderNodeId, displayName, realm);
// R2-07 T7 — surgical remove forwards. Same capability-sniffing pattern as UpdateTagAttributes /
// Surgical remove forwards. Same capability-sniffing pattern as UpdateTagAttributes /
// UpdateFolderDisplayName: forward when the inner sink supports the surgical capability; return false
// otherwise (before the real SdkAddressSpaceSink is swapped in, or any non-surgical inner) so the caller
// (AddressSpaceApplier) falls back to a full rebuild. Without these forwards the surgical remove path is
@@ -11,14 +11,14 @@ public interface IOpcUaAddressSpaceSink
/// <summary>Write a Variable node's current value + status code + source timestamp.</summary>
/// <param name="nodeId">The OPC UA node ID of the variable.</param>
/// <param name="value">The value to write.</param>
/// <param name="statusCode">The full OPC UA status code, sub-code included. Passed to the SDK verbatim
/// (#519): a driver's <see cref="OpcUaStatus.BadNoCommunication"/> reaches the client as
/// <param name="statusCode">The full OPC UA status code, sub-code included. Passed to the SDK verbatim, so
/// a driver's <see cref="OpcUaStatus.BadNoCommunication"/> reaches the client as
/// <c>BadNoCommunication</c>, not as a generic <see cref="OpcUaStatus.Bad"/>. Use
/// <see cref="OpcUaStatus"/>' named constants rather than a literal.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
/// <param name="realm">Which of the two v3 namespaces (<see cref="AddressSpaceRealm.Raw"/> device tree or
/// <see cref="AddressSpaceRealm.Uns"/> equipment tree) the <paramref name="nodeId"/> lives in — the sink
/// resolves the namespace index from the realm rather than parsing it out of the id string. WP3's fan-out
/// resolves the namespace index from the realm rather than parsing it out of the id string. The fan-out
/// calls this once per NodeId (raw + every referencing UNS node) with the matching realm.</param>
void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm);
@@ -81,11 +81,11 @@ public interface IOpcUaAddressSpaceSink
/// the condition was materialised under).</param>
void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm);
/// <summary>#477 — annotate a materialised condition's source-data quality OUT OF BAND from any alarm
/// <summary>Annotate a materialised condition's source-data quality OUT OF BAND from any alarm
/// transition (used by the driver-connectivity path: comms lost → <see cref="OpcUaStatus.BadNoCommunication"/>,
/// restored → <see cref="OpcUaStatus.Good"/>). Sets ONLY the condition's Quality — never
/// Active/Acked/Severity/Retain (a comms-lost active alarm stays active) — and fires one Part 9 event
/// only on a change to the status code itself — since #519 that includes a move between two Bad
/// only on a change to the status code itself — that now includes a move between two Bad
/// sub-codes, which genuinely changes what a subscriber reads. A no-op for an unmaterialised /
/// non-condition node.</summary>
/// <param name="alarmNodeId">The condition node id (RawPath for a native alarm).</param>
@@ -172,9 +172,9 @@ public interface IOpcUaAddressSpaceSink
/// <param name="realm">The namespace realm the variable (and its <paramref name="parentFolderNodeId"/>) live
/// in. A historized UNS reference node registers the SAME <paramref name="historianTagname"/> as its backing
/// raw node — both NodeIds map to one tagname — so this call carries the shared tagname per realm.</param>
/// <param name="refuseArrayOnScalar">Gitea #610 review (R6): when true and the node is SCALAR, a client write
/// <param name="refuseArrayOnScalar">When true and the node is SCALAR, a client write
/// of an array value is refused inline (<c>BadTypeMismatch</c>, nothing dispatched) because the owning
/// driver's scalar encoder cannot take one (Modbus / S7). False (default) keeps the pre-#610 behaviour — the
/// driver's scalar encoder cannot take one (Modbus / S7). False (default) keeps the earlier behaviour — the
/// write is dispatched and the driver decides (OpcUaClient forwards it to the upstream server, Galaxy encodes
/// it). Fixed at creation; an existing node keeps the value it was created with.</param>
void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false);
@@ -186,7 +186,7 @@ public interface IOpcUaAddressSpaceSink
void RebuildAddressSpace();
/// <summary>
/// Gitea #641 — mark the END of one address-space apply pass (after <see cref="RebuildAddressSpace"/>, if
/// Mark the END of one address-space apply pass (after <see cref="RebuildAddressSpace"/>, if
/// one ran, and every materialise / notifier-wiring call that followed it). An SDK-backed sink carries each
/// native condition's live Part 9 state across a rebuild and restores it when the same condition is
/// re-materialised; this is where it reports the ONE condition event per restored condition (now that
@@ -229,7 +229,7 @@ public interface IOpcUaAddressSpaceSink
}
/// <summary>One value write within a <see cref="IOpcUaAddressSpaceSink.WriteValues"/> batch.
/// Same semantics per field as <see cref="IOpcUaAddressSpaceSink.WriteValue"/> (#519: status verbatim).</summary>
/// Same semantics per field as <see cref="IOpcUaAddressSpaceSink.WriteValue"/> (status verbatim).</summary>
public readonly record struct ValueWrite(
string NodeId, object? Value, uint StatusCode, DateTime SourceTimestampUtc, AddressSpaceRealm Realm);
@@ -237,7 +237,7 @@ public readonly record struct ValueWrite(
/// node's current VALUE is retained and only <paramref name="StatusCode"/> +
/// <paramref name="SourceTimestampUtc"/> change.</summary>
/// <param name="NodeId">The OPC UA node id of the variable, within <paramref name="Realm"/>.</param>
/// <param name="StatusCode">The full status code, sub-code included, passed to the SDK verbatim (#519).</param>
/// <param name="StatusCode">The full status code, sub-code included, passed to the SDK verbatim.</param>
/// <param name="SourceTimestampUtc">The source timestamp in UTC.</param>
/// <param name="Realm">Which of the two v3 namespaces <paramref name="NodeId"/> lives in.</param>
public readonly record struct StatusWrite(
@@ -279,6 +279,7 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
/// <inheritdoc />
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
/// <inheritdoc />
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
/// <inheritdoc />
@@ -2,7 +2,7 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
/// <summary>
/// The handful of OPC UA status codes the engine layer needs to spell, plus the Part 4 severity
/// classification helpers that replaced the retired <c>OpcUaQuality</c> 3-state enum (Gitea #519).
/// classification helpers that replaced the retired <c>OpcUaQuality</c> 3-state enum.
///
/// <para><b>Why these are bare <c>uint</c> literals.</b> Commons and Runtime deliberately do not
/// reference the OPC UA SDK — <see cref="IOpcUaAddressSpaceSink"/> exists precisely so the Runtime
@@ -10,11 +10,11 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
/// decision the driver assemblies make, and it carries the same single failure mode: nothing checks
/// the literal against the name. <c>StatusCodeParityTests</c> closes it by reflection, and it can
/// only see a <b>named</b> constant — so add codes here rather than writing a literal at a call
/// site, exactly as Gitea #497 concluded for the drivers.</para>
/// site, exactly as for the drivers.</para>
///
/// <para><b>Severity, not equality, is what callers should branch on.</b> A status code's top two
/// bits carry its Part 4 severity (00 = Good, 01 = Uncertain, 10/11 = Bad); everything below is the
/// sub-code. Before #519 the engine collapsed every value onto a 3-state enum here and the node
/// sub-code. Previously the engine collapsed every value onto a 3-state enum here and the node
/// manager re-expanded <c>Bad</c> to a bare <c>0x80000000</c>, so no driver's sub-code ever reached
/// a client. The sub-code now rides through to the SDK verbatim, and any code that only needs the
/// bucket uses <see cref="IsGood"/> / <see cref="IsUncertain"/> / <see cref="IsBad"/> rather than
@@ -1,7 +1,7 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// One OPC UA data-plane API key. The OPC UA data plane is machine-to-machine (#520), so a
/// One OPC UA data-plane API key. The OPC UA data plane is machine-to-machine, so a
/// consuming system authenticates with a key rather than an LDAP user, and the key resolves to a
/// <see cref="Principal"/> that <see cref="NodeAcl"/> rows grant permissions to.
/// </summary>
@@ -22,7 +22,7 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// ⚠️ <b>Revocation requires a deploy.</b> Phase 4 leaves driver-only nodes with no ConfigDb, so
/// keys reach them inside the deployment artifact exactly as <see cref="NodeAcl"/> rows do.
/// Setting <see cref="Enabled"/> to false takes effect on a node only once a new artifact reaches
/// it. This is an operational sharp edge, not an oversight — see the #520 design doc.
/// it. This is an operational sharp edge, not an oversight.
/// </para>
/// </remarks>
public sealed class ApiKey
@@ -41,7 +41,7 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
/// <summary>Gets the DbSet of node ACLs.</summary>
public DbSet<NodeAcl> NodeAcls => Set<NodeAcl>();
/// <summary>OPC UA data-plane API keys (#520). One principal per key; secret stored as a peppered hash.</summary>
/// <summary>OPC UA data-plane API keys. One principal per key; secret stored as a peppered hash.</summary>
public DbSet<ApiKey> ApiKeys => Set<ApiKey>();
/// <summary>Gets the DbSet of configuration audit logs.</summary>
public DbSet<ConfigAuditLog> ConfigAuditLogs => Set<ConfigAuditLog>();
@@ -563,14 +563,14 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
});
}
// ConfigureDriverHostStatus is GONE (Gitea #521). The DriverHostStatus table held per-host
// ConfigureDriverHostStatus is GONE. The DriverHostStatus table held per-host
// connectivity that a publisher hosted service was supposed to upsert from each driver node. That
// publisher was never written, and per-cluster mesh Phase 4 made it unwritable as designed: ConfigDb
// is registered only on the admin role, so a driver-only node has no connection to write rows with.
// Per-host connectivity now rides DriverHealthChanged.HostStatuses to /hosts over the Phase 5
// telemetry stream, which needs no DB and survives the mesh split.
//
// ConfigureDriverInstanceResilienceStatus is GONE for the same reason (Gitea #524). That table held
// ConfigureDriverInstanceResilienceStatus is GONE for the same reason. That table held
// per-(instance, host) breaker/in-flight counters and was equally never written: its doc described a
// HostedService sampler and an AdminUI left-join on DriverHostStatus, neither of which was built. The
// live data reaches the AdminUI's DriverStatusPanel as DriverResilienceStatusChanged over the
@@ -57,7 +57,7 @@ public sealed class DraftSnapshot
public IReadOnlyList<ScriptedAlarm> ScriptedAlarms { get; init; } = [];
/// <summary>User-authored scripts (shared by VirtualTags, ScriptedAlarms, and Calculation tags). Drives
/// the WP7 Calculation gates: <c>scriptId</c> existence + the calc→calc dependency-cycle check.</summary>
/// the Calculation gates: <c>scriptId</c> existence + the calc→calc dependency-cycle check.</summary>
public IReadOnlyList<Script> Scripts { get; init; } = [];
/// <summary>Gets the list of poll groups.</summary>
public IReadOnlyList<PollGroup> PollGroups { get; init; } = [];
@@ -33,7 +33,7 @@ public static class DraftValidator
ValidateReservationPreflight(draft, errors);
ValidateEquipmentIdDerivation(draft, errors);
// v3 rules (WP4). The retired rules ValidateSameClusterNamespaceBinding (Namespace entity gone)
// v3 rules. The retired rules ValidateSameClusterNamespaceBinding (Namespace entity gone)
// and ValidateGalaxyTagFullName (blob-as-identity gone) are replaced by:
ValidateRawNameCharset(draft, errors);
ValidateHistorizedTagnameLength(draft, errors);
@@ -47,7 +47,7 @@ public static class DraftValidator
}
/// <summary>
/// #520 stage 3: every <see cref="NodeAcl"/> row's scope must name something the deployed address
/// every <see cref="NodeAcl"/> row's scope must name something the deployed address
/// space actually contains.
/// </summary>
/// <remarks>
@@ -74,7 +74,7 @@ public static class DraftValidator
/// <c>AclScopePathResolver</c>'s dictionary lookups use, so a row this rule passes is a row that
/// resolver resolves. The realm check mirrors <c>NodeAclRealms.TryCanonicalize</c> instead
/// (trimmed, case-insensitive) for the same reason. Both this rule and the resolver were
/// ordinal until Gitea #533; the cross-cluster comparison below was ordinal too, which was the
/// ordinal until that was fixed; the cross-cluster comparison below was ordinal too, which was the
/// worse half — it could reject a deploy as cross-cluster over a <c>ClusterId</c> the runtime
/// would have matched, and the two spellings genuinely occur (see
/// <c>RoleParser.ClusterIdFromRole</c>). The rule now is: identifiers compare
@@ -279,7 +279,7 @@ public static class DraftValidator
/// <summary>
/// The <c>Sql</c> driver's "a pasted literal connection string is never persisted" guarantee, enforced
/// at the deploy gate (Gitea #498).
/// at the deploy gate.
/// </summary>
/// <remarks>
/// <para>A Sql driver names its credentials indirectly — <c>connectionStringRef</c> resolves from the
@@ -294,7 +294,7 @@ public static class DraftValidator
/// Discarding a secret on read is not the same as refusing to store it.</para>
/// <para><b>Two of the three surfaces are checked here.</b> The third — a node's
/// <see cref="ClusterNode.DriverConfigOverridesJson"/> — is not reachable from
/// <see cref="DraftSnapshot"/> and is gated at its save instead (#499); see
/// <see cref="DraftSnapshot"/> and is gated at its save instead; see
/// <see cref="SqlCredentialGuard"/> for why a deploy gate is the wrong instrument for a value that
/// never enters the artifact.</para>
/// <para>Checked on <b>both</b> config surfaces a Sql driver reads: the instance's
@@ -344,7 +344,7 @@ public static class DraftValidator
}
}
/// <summary>Error code for an MTConnect driver instance that has more than one <c>/raw</c> Device (#628).</summary>
/// <summary>Error code for an MTConnect driver instance that has more than one <c>/raw</c> Device.</summary>
public const string MTConnectMultipleDevicesCode = "MTConnectMultipleDevices";
/// <summary>
@@ -352,6 +352,7 @@ public static class DraftValidator
/// use, so the two can never describe different rules.
/// </summary>
/// <param name="driverLabel">The driver instance id or name to cite.</param>
/// <returns>The validation message text.</returns>
public static string MTConnectMultipleDevicesMessage(string driverLabel) =>
$"MTConnect driver '{driverLabel}' has more than one Device. MTConnect binds all tags through one agent " +
"client per driver instance and does not route by /raw Device, and with several Devices the Device's " +
@@ -359,7 +360,7 @@ public static class DraftValidator
"supply the endpoint is ambiguous. Author one MTConnect driver per agent device, each with a single Device.";
/// <summary>
/// #628 (decision (b)): an MTConnect driver instance owns exactly one agent client, so a second <c>/raw</c>
/// An MTConnect driver instance owns exactly one agent client, so a second <c>/raw</c>
/// Device under it is a genuine ambiguity — the driver ignores <c>RawTagEntry.DeviceName</c>, and
/// <c>DriverDeviceConfigMerger</c> only merges a Device's <c>DeviceConfig</c> up to the driver config when
/// there is exactly ONE Device. The normal single-Device layout (every driver gets a default
@@ -380,7 +381,7 @@ public static class DraftValidator
}
}
/// <summary>WP7 Calculation-driver deploy gates. For every tag bound to a <c>Calculation</c> driver:
/// <summary>Calculation-driver deploy gates. For every tag bound to a <c>Calculation</c> driver:
/// <list type="number">
/// <item><b>scriptId existence</b> — the tag's <c>TagConfig.scriptId</c> must be present and resolve to
/// a <see cref="Script"/> row in the draft.</item>
@@ -634,7 +635,7 @@ public static class DraftValidator
}
// The rule itself lives on UnsSegment, which the AdminUI's authoring guards also call — a second
// copy here is exactly how the two drifted apart (Gitea #535). Only the per-level wording is local.
// copy here is exactly how the two drifted apart. Only the per-level wording is local.
private static void ValidateUnsSegments(DraftSnapshot draft, List<ValidationError> errors)
{
foreach (var a in draft.UnsAreas)
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
/// <summary>
/// The one place that decides whether a persisted config blob carries a literal Sql
/// <c>connectionString</c> (Gitea #498 / #499).
/// <c>connectionString</c>.
/// </summary>
/// <remarks>
/// <para>
@@ -12,7 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
/// rule is not cosmetic: it is what keeps a NodeId parseable and stable.
/// </para>
/// <para>
/// ⚠️ <b>This exists because the rule was duplicated and drifted (Gitea #535).</b> The deploy gate
/// ⚠️ <b>This exists because the rule was duplicated and drifted.</b> The deploy gate
/// held it privately in <c>DraftValidator</c> and the AdminUI restated it on the equipment form
/// alone, so an area or line could be named <c>Packaging</c> or <c>Line 1</c>, saved, and then
/// fail the deploy with <c>UnsSegmentInvalid</c>. Because a deployment snapshots the whole
@@ -2,7 +2,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Shared capped-exponential backoff for poll-based / lazy-reconnect drivers, extracted from
/// the S7 driver's bespoke poll fork (05/STAB-8, CONV-1). Provides two surfaces:
/// the S7 driver's bespoke poll fork. Provides two surfaces:
/// <list type="bullet">
/// <item><see cref="ComputeDelay"/> — the pure schedule (base, 2×, 4×, … saturating at a
/// cap) used by <c>PollGroupEngine</c> to stretch its retry cadence under sustained
@@ -3,10 +3,10 @@ using System.Text.Json;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Gitea #610 — the single authority on whether a driver can WRITE a 1-D array tag. The address-space
/// the single authority on whether a driver can WRITE a 1-D array tag. The address-space
/// composer consults it at compose time (both compose seams run the same composer, so the live-edit and
/// artifact-decode sides cannot disagree) to decide whether an authored-<c>ReadWrite</c> array tag
/// materialises as a writable OPC UA node. Before #610 every array node was forced read-only regardless of
/// materialises as a writable OPC UA node. Previously every array node was forced read-only regardless of
/// driver; that remains the answer for every (driver, element type) pair NOT listed here.
/// <para>
/// <b>The rule is "never advertise a write the driver will refuse".</b> A pair is listed only when the
@@ -125,16 +125,16 @@ public static class DriverArrayWriteSupport
}
/// <summary>
/// Gitea #610 review (R6 MINOR-1) — whether a driver of <paramref name="driverType"/> is known to REFUSE an
/// Whether a driver of <paramref name="driverType"/> is known to REFUSE an
/// array value written to one of its SCALAR tags, so the node manager may refuse such a write inline
/// (<c>BadTypeMismatch</c>, nothing dispatched) instead of storing it optimistically, dispatching it, and
/// reverting it when the scalar encoder throws.
/// <para>
/// Only Modbus and S7 qualify: their scalar encoders <c>Convert.ToXxx</c> the value, which throws on an
/// array. Every other driver keeps the pre-#610 behaviour — the write is dispatched and the driver
/// array. Every other driver keeps the previous behaviour — the write is dispatched and the driver
/// decides. That matters for the pass-through drivers: a browse-committed array attribute is committed
/// as a SCALAR tag (the <c>/raw</c> commit never sets <c>isArray</c>), the driver publishes an array into
/// it, and writing that array back worked before #610 — <c>OpcUaClient</c> forwards the
/// it, and writing that array back worked previously — <c>OpcUaClient</c> forwards the
/// <c>Variant</c> unchanged (the upstream server arbitrates shape) and Galaxy's <c>MxValueEncoder</c>
/// encodes the array. An unknown / null driver type is not listed ⇒ <see langword="false"/>.
/// </para>
@@ -53,7 +53,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// Sparkplug births, MTConnect probe inference).
/// <para>
/// <b>It exists because <paramref name="DriverDataType"/> is lossy in exactly the direction a
/// browse-commit needs</b> (Gitea #536): <c>AbCipDataType.SInt</c>, <c>.Int</c> and <c>.DInt</c>
/// browse-commit needs</b>: <c>AbCipDataType.SInt</c>, <c>.Int</c> and <c>.DInt</c>
/// all map to <see cref="DriverDataType.Int32"/>, so a commit that reverse-mapped the
/// driver-agnostic type would bind a 16-bit INT as a 32-bit DINT. Nothing downstream of the
/// address-space build reads this — it is authoring metadata carried alongside the type the
@@ -38,7 +38,7 @@ public enum DriverCapability
AlarmAcknowledge,
/// <summary>
/// <c>IDriver.InitializeAsync</c> — connection establishment (R2-10 T10 closure: without this,
/// <c>IDriver.InitializeAsync</c> — connection establishment (without this,
/// a driver that cannot connect contributes zero breaker input and the resilience panel stays
/// quiet for the failure operators most care about). Does NOT retry — the
/// <c>DriverInstanceActor</c> reconnect timer is the retry loop, and pipeline-level retries
@@ -1,6 +1,6 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
// OPC UA Part 11 modified-value history (Gitea #668): every HistoryUpdate a backend applies leaves an audit row
// OPC UA Part 11 modified-value history: every HistoryUpdate a backend applies leaves an audit row
// carrying the PRIOR value, who made the change and when; HistoryRead with IsReadModified=true serves them.
/// <summary>Who is performing a HistoryUpdate. Threaded into every <see cref="IHistorianDataUpdater"/> call.</summary>
@@ -1,7 +1,7 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
// Shared history result contracts used by IHistorianDataSource and the historian gateway.
// Drivers carry no history capability (Gitea #655): HistoryRead is served server-side.
// Drivers carry no history capability: HistoryRead is served server-side.
/// <summary>Result of a HistoryRead call.</summary>
@@ -13,7 +13,7 @@ public sealed record HistoryReadResult(
/// <summary>Aggregate function for processed history reads. Mirrors OPC UA Part 13 standard aggregates.</summary>
/// <remarks>
/// The first five members are the original set every historian backend serves. The remainder (#285) are
/// The first five members are the original set every historian backend serves. The remainder are
/// the further standard Part 13 aggregates a server-side HistoryRead can forward to a backend that
/// supports them (a gateway backend maps them to its own aggregate modes). A backend that cannot serve a
/// member must throw <see cref="HistoryAggregateNotSupportedException"/> — the server maps that to
@@ -48,14 +48,14 @@ public enum HistoryAggregateType
StandardDeviationPopulation,
VariancePopulation,
/// <summary>Part 13 TimeAverage2: time-weighted average over the interval's non-Bad data only (#671).</summary>
/// <summary>Part 13 TimeAverage2: time-weighted average over the interval's non-Bad data only.</summary>
TimeAverage2,
/// <summary>Part 13 Total2: time-weighted integral (value·seconds) over the interval's non-Bad data only (#671).</summary>
/// <summary>Part 13 Total2: time-weighted integral (value·seconds) over the interval's non-Bad data only.</summary>
Total2,
}
/// <summary>Shared <see cref="HistoryAggregateType"/> sets for <see cref="IHistorianDataSource.SupportedAggregates"/> (#682).</summary>
/// <summary>Shared <see cref="HistoryAggregateType"/> sets for <see cref="IHistorianDataSource.SupportedAggregates"/>.</summary>
public static class HistoryAggregateTypes
{
/// <summary>Every defined <see cref="HistoryAggregateType"/> — the interface default.</summary>
@@ -85,7 +85,7 @@ public sealed class HistoryAggregateNotSupportedException : NotSupportedExceptio
/// <summary>
/// Thrown by a history backend when a read would exceed a configured size limit (e.g. the raw-row cap of the SQL
/// backends' time-weighted aggregates, #671). The Server HistoryRead path translates it to
/// backends' time-weighted aggregates). The Server HistoryRead path translates it to
/// <c>BadTooManyOperations</c> for that node only; it is a client-request limit, not a backend fault.
/// </summary>
public sealed class HistoryReadLimitExceededException : InvalidOperationException
@@ -94,7 +94,7 @@ public sealed class HistoryReadLimitExceededException : InvalidOperationExceptio
public long Limit { get; }
/// <summary>
/// Gitea #694: a raw read refused because rows BEFORE its window (inside the backend's look-back margin — a tie
/// a raw read refused because rows BEFORE its window (inside the backend's look-back margin — a tie
/// cluster just before a page start, the HistorianGateway's boundary row) crowded a ceiling-bounded row budget: how
/// many such rows the read saw (a lower bound when the read was itself truncated). <c>0</c> = not a crowding refusal.
/// The server re-reads such a page once, smaller by this many values (<c>RawPageLimit</c>).
@@ -127,7 +127,7 @@ public sealed record HistoricalEvent(
ushort Severity);
/// <summary>Result of a <see cref="IHistorianDataSource.ReadEventsAsync(string?, DateTime, DateTime, int, CancellationToken)"/> call.</summary>
/// <param name="Events">Events ordered by <c>EventTimeUtc</c> in the read's direction — ascending, or descending for a reverse read (#675).</param>
/// <param name="Events">Events ordered by <c>EventTimeUtc</c> in the read's direction — ascending, or descending for a reverse read.</param>
/// <param name="ContinuationPoint">Opaque token for the next call when more events are available; null when complete.</param>
public sealed record HistoricalEventsResult(
IReadOnlyList<HistoricalEvent> Events,
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// </summary>
/// <remarks>
/// This is a *server registration*, not a driver capability: drivers carry no history
/// surface (Gitea #655). The server calls it directly, so a single historian can serve
/// surface. The server calls it directly, so a single historian can serve
/// many drivers' nodes, and drivers can restart without dropping history availability.
/// All values returned use the shared <see cref="DataValueSnapshot"/> /
/// <see cref="HistoricalEvent"/> shapes; backend-specific quality / type encodings
@@ -20,7 +20,7 @@ public interface IHistorianDataSource : IDisposable
/// <remarks>
/// Forward (<c>startUtc &lt; endUtc</c>) is <c>[start, end)</c> ascending; <c>startUtc == endUtc</c> is a point read.
/// <c>startUtc &gt; endUtc</c> is the Part 11 reverse read <c>(end, start]</c> newest-first — but only a backend that
/// declares <see cref="SupportsReverseRaw"/> is ever sent one (Gitea #686); the server serves a reverse read on any
/// declares <see cref="SupportsReverseRaw"/> is ever sent one; the server serves a reverse read on any
/// other backend from bounded forward reads.
/// </remarks>
/// <param name="fullReference">The full reference of the tag to read.</param>
@@ -38,7 +38,7 @@ public interface IHistorianDataSource : IDisposable
/// <summary>
/// Whether this backend can serve a reverse (newest-first) raw read itself — <see cref="ReadRawAsync"/> with
/// <c>startUtc &gt; endUtc</c> (Gitea #686). The server checks it before every raw read. When <c>false</c> it never
/// <c>startUtc &gt; endUtc</c>. The server checks it before every raw read. When <c>false</c> it never
/// sends <c>start &gt; end</c>: it serves the reverse read — an explicit one, a resumed reverse page, and the
/// end-only capped read Part 11 reads backward from EndTime — from a series of bounded FORWARD reads walking back
/// from the later bound (<c>ReverseRawReader</c> in the OPC UA server). Default <c>false</c>, the safe direction: a
@@ -48,11 +48,11 @@ public interface IHistorianDataSource : IDisposable
/// <summary>
/// The most values one capped <see cref="ReadRawAsync"/> can currently return without failing
/// <see cref="HistoryReadLimitExceededException"/> on a BACKEND row ceiling (Gitea #694); <c>0</c> = no known limit.
/// <see cref="HistoryReadLimitExceededException"/> on a BACKEND row ceiling; <c>0</c> = no known limit.
/// The server sizes its raw pages to it — a page is at most this many values and carries a continuation point when
/// full (Part 11 allows fewer than <c>NumValuesPerNode</c> with a continuation point), instead of failing a page size
/// the backend cannot read in one go. It may change at runtime (a strict HistorianGateway's ceiling is learned from
/// its first refusal, #691.3), so it is read per read. A backend whose reads are never ceiling-bound returns <c>0</c>.
/// its first refusal), so it is read per read. A backend whose reads are never ceiling-bound returns <c>0</c>.
/// The source still never truncates: a read asking for more than this is answered in full or refused. Default
/// <c>0</c>; a backend declares it itself (a guard test pins that).
/// </summary>
@@ -94,7 +94,7 @@ public interface IHistorianDataSource : IDisposable
CancellationToken cancellationToken);
/// <summary>
/// Gitea #693: <see cref="ReadAtTimeAsync(string, IReadOnlyList{DateTime}, CancellationToken)"/> with the value type of
/// <see cref="ReadAtTimeAsync(string, IReadOnlyList{DateTime}, CancellationToken)"/> with the value type of
/// the node being read, for a backend whose stored values do not carry their type (the Gateway: AVEVA stores every
/// historized type as a numeric analog, so a Boolean comes back as a 0/1 Double). Such a backend uses the hint to apply
/// the AtTime rules exactly as the typed backends do — a Boolean (or String / DateTime) node is stepped, an integer node
@@ -156,17 +156,17 @@ public interface IHistorianDataSource : IDisposable
/// <summary>
/// Whether this backend can serve a reverse (newest-first) events read — the reverse overload of
/// <see cref="ReadEventsAsync(string?, DateTime, DateTime, int, bool, CancellationToken)"/> (Gitea #675). The server
/// <see cref="ReadEventsAsync(string?, DateTime, DateTime, int, bool, CancellationToken)"/>. The server
/// checks it BEFORE calling the source: a reverse read (explicit <c>StartTime &gt; EndTime</c>, or an end-only capped
/// read, which Part 11 reads backward) on a backend that cannot is never sent the reverse overload — since Gitea
/// #691.4 the server walks it back from bounded FORWARD reads instead (#675 refused it). Default <c>false</c>; an
/// the server walks it back from bounded FORWARD reads instead (it was once refused). Default <c>false</c>; an
/// implementation (or a wrapper) declares it itself — a guard test pins that.
/// </summary>
bool SupportsReverseEvents => false;
/// <summary>
/// Read historical alarm / event records in a chosen direction — the overload the server's HistoryReadEvents
/// paging calls (Gitea #675).
/// paging calls.
/// </summary>
/// <remarks>
/// <para>
@@ -182,7 +182,7 @@ public interface IHistorianDataSource : IDisposable
/// The default forwards a forward read to the five-argument overload and throws
/// <see cref="NotSupportedException"/> for a reverse one — a backend that cannot read newest-first (the
/// Gateway). The server never relies on that throw: it consults <see cref="SupportsReverseEvents"/> first and walks
/// such a read back from forward reads (#691.4), never returning the earliest events in ascending order as if
/// such a read back from forward reads, never returning the earliest events in ascending order as if
/// they were the answer. A backend that serves reverse natively must override BOTH members.
/// </para>
/// </remarks>
@@ -228,7 +228,7 @@ public interface IHistorianDataSource : IDisposable
long TimestampResolutionTicks => 1;
/// <summary>
/// The Part 13 aggregates this backend serves for a processed HistoryRead (Gitea #682) — the ONE authority for
/// The Part 13 aggregates this backend serves for a processed HistoryRead — the ONE authority for
/// both what the server advertises under <c>HistoryServerCapabilities/AggregateFunctions</c> (i=11201) and what
/// its HistoryRead-Processed answers <c>BadAggregateNotSupported</c> up front without calling the backend.
/// (<c>ServerCapabilities/AggregateFunctions</c>, i=2997, is not affected: it lists every aggregate the server
@@ -236,7 +236,7 @@ public interface IHistorianDataSource : IDisposable
/// </summary>
/// <remarks>
/// The default is <see cref="HistoryAggregateTypes.All"/> — "every aggregate the server can name; a member I cannot
/// serve throws <see cref="HistoryAggregateNotSupportedException"/> at read time", which is exactly the pre-#682
/// serve throws <see cref="HistoryAggregateNotSupportedException"/> at read time", which is exactly the earlier
/// behaviour, so a test fake or an out-of-tree source keeps working unchanged. It is a concrete set rather than a
/// <c>null</c> "all" sentinel so every consumer handles one shape. Every PRODUCTION backend declares its set itself
/// (a reflection guard pins that): a wrapper that forgot to forward this would otherwise advertise every aggregate.
@@ -247,7 +247,7 @@ public interface IHistorianDataSource : IDisposable
IReadOnlySet<HistoryAggregateType> SupportedAggregates => HistoryAggregateTypes.All;
/// <summary>
/// Read modified-value history — OPC UA HistoryReadRaw with <c>IsReadModified = true</c> (Part 11, Gitea #668):
/// Read modified-value history — OPC UA HistoryReadRaw with <c>IsReadModified = true</c> (Part 11):
/// every modification a HistoryUpdate applied to the tag inside the window, each carrying the PRIOR value.
/// </summary>
/// <remarks>
@@ -18,13 +18,13 @@ public enum HistoryUpdateMode
/// supplies no updater (the server then reports HistoryUpdate as unsupported).
/// </summary>
/// <remarks>
/// <b>Modified-value history (Part 11, Gitea #668).</b> Every sample an implementation inserts, replaces or deletes
/// <b>Modified-value history (Part 11).</b> Every sample an implementation inserts, replaces or deletes
/// MUST leave one modification record carrying the prior value (none for an insert), the
/// <see cref="HistoryUpdateContext.Principal"/>, the modification time and the <see cref="HistoryModificationType"/>,
/// readable through <see cref="IHistorianDataSource.ReadModifiedAsync"/>. A refused item (any non-Good code) leaves
/// no record. Live recorder writes and retention purges never leave one.
/// <para>
/// <b>Cannot update at all</b> (Gitea #674): an implementation that is wired but cannot perform any update right
/// <b>Cannot update at all</b>: an implementation that is wired but cannot perform any update right
/// now — the SqlServer backend on a schema without the modification trail — throws
/// <see cref="NotSupportedException"/> from every method without touching storage. The server reports it as a
/// node-level <c>BadHistoryOperationUnsupported</c>, the same as <see cref="IHistorianDataSource.ReadModifiedAsync"/>.
@@ -56,21 +56,21 @@ public sealed class NullHistorianDataSource : IHistorianDataSource
CancellationToken cancellationToken) => Task.FromResult(EmptyEvents);
/// <inheritdoc />
/// <remarks><c>true</c> (Gitea #686): there is no history, so the empty result is the correct answer in either
/// direction — no need for the server to walk back through forward reads to find nothing.</remarks>
// true: there is no history, so the empty result is the correct answer in either
// direction — no need for the server to walk back through forward reads to find nothing.
public bool SupportsReverseRaw => true;
/// <inheritdoc />
/// <remarks><c>0</c>: no history, no ceiling (Gitea #694).</remarks>
// 0: no history, no ceiling.
public int MaxRawValuesPerRead => 0;
/// <inheritdoc />
/// <remarks><c>true</c>, like <see cref="SupportsReverseRaw"/> (Gitea #691.4 review; <c>false</c> under #675): there is no
/// history to read in either direction — no need for the server to walk back through empty forward reads.</remarks>
// true, like SupportsReverseRaw: there is no history to read in either direction —
// no need for the server to walk back through empty forward reads.
public bool SupportsReverseEvents => true;
/// <inheritdoc />
/// <remarks>The empty result in either direction.</remarks>
// The empty result in either direction.
public Task<HistoricalEventsResult> ReadEventsAsync(
string? sourceName,
DateTime startUtc,
@@ -81,12 +81,10 @@ public sealed class NullHistorianDataSource : IHistorianDataSource
Task.FromResult(EmptyEvents);
/// <inheritdoc />
/// <remarks>
/// EMPTY (Gitea #682): with no historian configured the server advertises no aggregate functions, and a processed
/// HistoryRead answers <c>BadAggregateNotSupported</c> per node rather than <c>GoodNoData</c> — an aggregate is a
/// statistic the server cannot compute without a history, and advertising the SDK's full list on a server with no
/// historian misled capability discovery. Raw / AtTime / Events reads still return the empty <c>GoodNoData</c> result.
/// </remarks>
// EMPTY: with no historian configured the server advertises no aggregate functions, and a processed
// HistoryRead answers BadAggregateNotSupported per node rather than GoodNoData — an aggregate is a
// statistic the server cannot compute without a history, and advertising the SDK's full list on a server with no
// historian misled capability discovery. Raw / AtTime / Events reads still return the empty GoodNoData result.
public IReadOnlySet<HistoryAggregateType> SupportedAggregates => HistoryAggregateTypes.None;
/// <inheritdoc />
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// Driver capability for alarm events. Optional — only drivers whose backends expose
/// alarm conditions implement this. Currently: Galaxy (MxAccess alarms), FOCAS
/// (CNC alarms), OPC UA Client (A&amp;C events from upstream server), MTConnect
/// (CONDITION data items, Gitea #626).
/// (CONDITION data items).
/// </summary>
public interface IAlarmSource
{
@@ -88,22 +88,22 @@ public sealed record AlarmAcknowledgeRequest(
/// </param>
/// <param name="OpcUaSeverity">
/// Optional exact OPC UA severity (1..1000) for a source whose own scale is finer than the four
/// <see cref="AlarmSeverity"/> buckets (Gitea #626: MTConnect Warning = 500, Fault = 800). When set it
/// <see cref="AlarmSeverity"/> buckets (MTConnect Warning = 500, Fault = 800). When set it
/// supersedes the bucket on the projected Part 9 condition (clamped to 1..1000); <c>null</c> — every
/// pre-#626 source — keeps the bucket mapping. <see cref="Severity"/> must still carry the nearest
/// earlier source — keeps the bucket mapping. <see cref="Severity"/> must still carry the nearest
/// bucket for consumers that only read the bucket.
/// </param>
/// <param name="QualityStatusCode">
/// Optional status code of the condition's SOURCE data (Part 9 <c>ConditionType.Quality</c>), for a
/// source that can report "no value for this condition" per alarm rather than per connection (Gitea
/// #626: MTConnect <c>Unavailable</c>). <c>null</c> keeps the pre-#626 behaviour (projected Good;
/// source that can report "no value for this condition" per alarm rather than per connection (for example
/// MTConnect <c>Unavailable</c>). <c>null</c> keeps the earlier behaviour (projected Good;
/// driver-wide comms loss is annotated separately by the host). An event whose <see cref="Kind"/> is
/// <see cref="AlarmTransitionKind.Unspecified"/> and which carries a quality is a
/// <b>quality-only annotation</b> (<see cref="IsQualityOnly"/>): the host updates (via its Quality-only node path) the condition's
/// Quality without touching its Active/Acked state and publishes no <c>alerts</c> row for it.
/// </param>
/// <param name="IsRestatement">
/// Gitea #656 — true when the source is RE-STATING upstream's current state rather than reporting a transition it
/// True when the source is RE-STATING upstream's current state rather than reporting a transition it
/// watched happen: a reconnect snapshot entry (Galaxy) or the first event for a condition after a driver start or
/// deploy respawn (OpcUaClient). It governs <b>history only, never node state</b>: the host still projects the
/// state, but writes no <c>alerts</c> row when the re-statement changes nothing the condition shows (a Retrigger on
@@ -111,7 +111,7 @@ public sealed record AlarmAcknowledgeRequest(
/// Acknowledge on an already-acknowledged condition) — or
/// when a flagged <see cref="AlarmTransitionKind.Unspecified"/> update moves the message/severity of an
/// already-active condition (node updated, one condition event, active/acked kept, no row). AbCip ALMA sets the
/// flag for the last shape (the governing limit changed, no new occurrence). OpcUaClient (Gitea #657) re-states a
/// flag for the last shape (the governing limit changed, no new occurrence). OpcUaClient re-states a
/// first-seen inactive+acked condition as a flagged Clear then a flagged Acknowledge, so a host already showing
/// that writes nothing and a host retaining it Active gets a real "Cleared". <c>false</c> (the default, every source that does not
/// opt in) keeps every transition's row.
@@ -147,9 +147,9 @@ public enum AlarmSeverity { Low, Medium, High, Critical }
/// Kind of alarm state change carried by <see cref="AlarmEventArgs.Kind"/>, letting a
/// consumer derive OPC UA Part 9 active/ack state. Mirrors the driver-side
/// transition-kind enums (e.g. Galaxy's <c>GalaxyAlarmTransitionKind</c>).
/// <see cref="ActiveAcknowledged"/> (Gitea #656) means "the condition is active AND acknowledged", applied by the
/// <see cref="ActiveAcknowledged"/> means "the condition is active AND acknowledged", applied by the
/// host as ONE projection — never Raise-then-Acknowledge, which would show clients an Active+unacked intermediate.
/// <see cref="InactiveUnacknowledged"/> (Gitea #657) is its mirror: "the condition is inactive AND unacknowledged"
/// <see cref="InactiveUnacknowledged"/> is its mirror: "the condition is inactive AND unacknowledged"
/// (it went active and cleared unacknowledged), applied as ONE projection — never Raise-then-Clear, which showed
/// clients the condition Active again for an instant. The host writes rows only for what actually changed:
/// "Activated" + "Cleared" when the condition was acknowledged (a new occurrence must have happened), "Cleared"
@@ -24,8 +24,8 @@ public interface IDriver
/// <summary>
/// Apply a config change in place without tearing down the driver process.
/// <para>The runtime does not call this today: since #516 a DriverConfig change is a stop + respawn,
/// and #665 deleted <c>DriverInstanceActor</c>'s in-place <c>ApplyDelta</c> path. Kept as the in-place
/// <para>The runtime does not call this today: a DriverConfig change is a stop + respawn,
/// and <c>DriverInstanceActor</c> no longer has an in-place <c>ApplyDelta</c> path. Kept as the in-place
/// contract each driver implements and tests.</para>
/// </summary>
/// <remarks>
@@ -40,12 +40,12 @@ public interface IDriver
/// <summary>Stop the driver, close connections, release resources. Called on shutdown or driver removal.</summary>
/// <remarks>
/// <para><b>Land the terminal health value AFTER the teardown, never only before it</b> (Gitea #517).
/// <para><b>Land the terminal health value AFTER the teardown, never only before it</b>.
/// Teardown drains in-flight poll / probe loops, and those loops write health as they unwind — so a
/// driver that wrote <see cref="DriverState.Unknown"/> first and then tore down could have a late
/// loop overwrite it with <see cref="DriverState.Healthy"/> and leave THAT as the resting state,
/// with every connection already closed. This is why the fleet writes last, and it is the reason
/// #517's original suggestion — "write the transitional value before disposing" — is only safe as an
/// The original suggestion — "write the transitional value before disposing" — is only safe as an
/// ADDITIONAL write, bracketing the teardown, rather than as a move.</para>
/// <para>Bracketing matters wherever a <see cref="GetHealth"/> can land mid-teardown. It cannot on
/// this path today: <c>DriverInstanceActor</c> calls this from <c>PostStop</c>, on the actor thread,
@@ -61,7 +61,7 @@ public interface IDriver
/// <summary>Current health snapshot, polled by Core for the status dashboard and ServiceLevel.
/// <para>Must reflect the driver's ACTUAL state rather than a constant — a hard-coded
/// <see cref="DriverState.Healthy"/> reports a healthy driver after shutdown and before initialize
/// alike, which is what Gitea #517 found in <c>CalculationDriver</c>. A driver with no transport still
/// alike, which was found in <c>CalculationDriver</c>. A driver with no transport still
/// has a running / not-running distinction worth reporting.</para></summary>
/// <returns>The driver's current health snapshot.</returns>
DriverHealth GetHealth();
@@ -49,7 +49,7 @@ public interface IDriverCapabilityInvoker
/// <paramref name="isIdempotent"/> is <c>false</c>, retries are disabled regardless of the
/// configured policy; when <c>true</c>, the call runs through the Write pipeline which may
/// retry if the tier configuration permits. <b>The current production default is <c>false</c></b>
/// (R2-02/S-8): <c>DriverInstanceActor.HandleWriteAsync</c> passes <c>isIdempotent: false</c> so a
/// <c>DriverInstanceActor.HandleWriteAsync</c> passes <c>isIdempotent: false</c> so a
/// command-shaped write is never replayed; per-tag opt-in via <see cref="WriteIdempotentAttribute"/>
/// is the unshipped forward path.
/// </summary>
@@ -31,7 +31,7 @@ public interface IDriverCapabilityInvokerFactory
/// <summary>
/// Release every piece of shared resilience state held for <paramref name="driverInstanceId"/> — called
/// by the host when the instance leaves the deployed plan (#609). A concrete factory drops the
/// by the host when the instance leaves the deployed plan. A concrete factory drops the
/// instance's cached pipelines and its status-tracker snapshots, so the periodic resilience publisher
/// stops re-emitting (and re-priming new telemetry subscribers with) a decommissioned driver's last
/// breaker/failure counters. Default: no-op (a factory that holds no state).
@@ -1,9 +1,9 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Optional driver capability (Gitea #676) — a driver that can tell, from its protocol, that its connection is
/// Optional driver capability — a driver that can tell, from its protocol, that its connection is
/// DEFINITIVELY gone (it can never recover in place, only a re-initialise brings it back) raises
/// <see cref="ConnectionLost"/> so the host re-initialises it at once instead of waiting out the #527 comms-loss
/// <see cref="ConnectionLost"/> so the host re-initialises it at once instead of waiting out the comms-loss
/// watchdog (~90 s of frozen <see cref="DriverHealth.LastSuccessfulRead"/>).
/// </summary>
/// <remarks>
@@ -3,7 +3,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Optional driver capability: expose a small bag of operator-facing diagnostic key/value pairs
/// (e.g. S7's negotiated PDU size) that ride the driver-health snapshot to the AdminUI <c>/hosts</c>
/// page (Gitea #640).
/// page.
/// </summary>
/// <remarks>
/// <para>
@@ -32,11 +32,11 @@ public interface IDriverHealthPublisher
/// Authored tags the driver could not build into its poll table, from
/// <see cref="ISkippedTagReporter.GetSkippedTags"/>; null when the driver is not an
/// <see cref="ISkippedTagReporter"/>. Contradicts the "Healthy, 0 errors, reads advancing" reading
/// of a driver that is in fact ignoring part or all of its configuration (Gitea #541).
/// of a driver that is in fact ignoring part or all of its configuration.
/// </param>
/// <param name="diagnostics">
/// Bounded driver diagnostics from <see cref="IDriverDiagnostics.GetDiagnostics"/>; null when the
/// driver is not an <see cref="IDriverDiagnostics"/> (Gitea #640).
/// driver is not an <see cref="IDriverDiagnostics"/>.
/// </param>
void Publish(
string clusterId,
@@ -12,7 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <i>inbound</i>: a write, an alarm ack, an alerts publish. A Sparkplug primary-host claim is
/// not inbound. It is a standing assertion the driver makes to the broker, on its own
/// connection, for as long as it holds one — there is no message for the host actor to
/// intercept. So the decision has to reach the driver instead (Gitea #511).
/// intercept. So the decision has to reach the driver instead.
/// </para>
/// <para>
/// <b>The decision, not the role.</b> Same discipline as the alarm-drain view in
@@ -61,7 +61,7 @@ public interface IDriverPrimaryRoleSource
/// publishing a role there is by definition no peer to collide with, and a closed default
/// would make an exclusive-identity claim silently never happen on the single-node deployment
/// that is the common case — an inert feature with no error, which is precisely the failure
/// mode Gitea #511 was filed about.
/// mode this contract exists to prevent.
/// </para>
/// <para>
/// The risk this shifts onto the wiring is real and is guarded there instead: a redundant
@@ -82,8 +82,7 @@ public sealed class UnmanagedDriverPrimaryRoleSource : IDriverPrimaryRoleSource
/// <inheritdoc/>
public bool ShouldActAsPrimary => true;
/// <inheritdoc/>
/// <remarks>No-op: there is no redundancy manager to publish anything here.</remarks>
/// <inheritdoc />
public void PublishPrimaryRole(bool shouldActAsPrimary)
{
}
@@ -7,18 +7,18 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// </summary>
/// <remarks>
/// <para>
/// <b>Why this exists (Gitea #541).</b> Every driver builds its <c>RawPath → definition</c>
/// <b>Why this exists.</b> Every driver builds its <c>RawPath → definition</c>
/// table at initialise and <i>tolerates</i> a per-tag miss — an unmappable <c>TagConfig</c>, an
/// unparseable address, a capability-matrix rejection. Tolerance is the right call: one bad tag
/// must not take down the other few thousand. But a skip is not an error and happens before any
/// read, so the driver stays <c>Healthy</c>, <c>errors/5 min</c> stays 0, <c>Last error</c> stays
/// empty, and <c>Last read</c> advances live off the tags that <i>did</i> map. On the Makino in
/// Gitea #540 that combination held while the driver ignored 20 of its 22 tags, and no surface
/// a past incident that combination held while the driver ignored 20 of its 22 tags, and no surface
/// anywhere said otherwise.
/// </para>
/// <para>
/// <b>Deliberately additive to driver state, never a change of it.</b> Latching a driver
/// non-Healthy over a config problem would collide with the Gitea #527 comms-loss detector,
/// non-Healthy over a config problem would collide with the comms-loss detector,
/// which trips on <i>90 s without a successful read AND non-Healthy</i>. A driver whose tags
/// were <i>all</i> skipped has no successful reads by construction, so it would satisfy both
/// halves and reconnect-loop forever against a device that is perfectly fine. The signal
@@ -32,7 +32,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <para>
/// Implementations should accumulate into a <see cref="SkippedTagLedger"/> rather than
/// hand-rolling a counter: the ledger is one implementation of the reset/commit/cap rules
/// instead of one per driver, which is precisely the duplication #541 was filed about.
/// instead of one per driver, which is precisely the duplication this type removes.
/// </para>
/// </remarks>
public interface ISkippedTagReporter
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// The one worst-of-input rule every derived-value seam uses — scripted alarms, virtual tags and
/// the Calculation driver.
///
/// <para><b>The contract it implements</b> (archreview R3-02, "the stale-Good family"): a value
/// <para><b>The contract it implements</b> ("the stale-Good family"): a value
/// consumer never reports a quality better than its source's. A node derived from driver values
/// carries, at worst, the worst status among its inputs — a Bad input makes the derived node Bad
/// with that input's sub-code, an Uncertain input makes it Uncertain, and a source that has
@@ -87,8 +87,8 @@ public sealed class PollGroupEngine : IAsyncDisposable
/// internal contract-violation throw) so the owning driver can route the failure to its
/// health surface. Defensive: an <c>onError</c> handler that
/// itself throws is silently absorbed so a buggy forwarder cannot crash the poll loop.</param>
/// <param name="backoffCap">Optional cap enabling capped-exponential failure backoff
/// (05/STAB-8). When supplied, sustained reader failures stretch the retry cadence
/// <param name="backoffCap">Optional cap enabling capped-exponential failure backoff.
/// When supplied, sustained reader failures stretch the retry cadence
/// (interval, 2×, 4×, … saturating at this cap) instead of hammering a dead device every
/// tick; a successful poll resets to the base interval. <c>null</c> (default) keeps the
/// byte-identical fixed-interval cadence for consumers that have not opted in.
@@ -260,7 +260,7 @@ public sealed class PollGroupEngine : IAsyncDisposable
consecutiveFailures = 0;
}
// Only a teardown OCE (the loop's OWN token) exits the loop. A reader that throws OCE with
// the loop token un-cancelled — a driver-internal timeout CTS (the STAB-14/STAB-15 class) —
// the loop token un-cancelled — a driver-internal timeout CTS (the class of failure) —
// must NOT tear the loop down: it falls through to the generic handler, is reported, and the
// loop retries with backoff. This is the guard that makes the S7 fork migration safe.
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
@@ -319,7 +319,7 @@ public sealed class PollGroupEngine : IAsyncDisposable
consecutiveFailures = 0;
}
// Only teardown (the loop token) exits; a reader OCE with ct un-cancelled backs off
// like any other failure instead of killing the loop (STAB-14 engine guard).
// like any other failure instead of killing the loop.
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
catch (Exception ex) when (!IsFatal(ex))
{
@@ -19,7 +19,7 @@ public enum JsonEnumRead
}
/// <summary>
/// Shared TagConfig JSON field readers for the six equipment-tag parsers (05/CONV-2). Replaces the
/// Shared TagConfig JSON field readers for the six equipment-tag parsers. Replaces the
/// six byte-identical private <c>ReadEnum</c> copies with one strict-capable home beside
/// <see cref="EquipmentTagRefResolver{TDef}"/>.
/// <para><b>Unknown-key handling is a deliberate non-goal.</b> The TagConfig blob is a SHARED
@@ -62,7 +62,7 @@ public static class TagConfigJson
/// AdminUI's typed tag-config editors hold a mutable <c>JsonObject</c> key bag, not a
/// <see cref="JsonElement"/> — and an authoring gate that RESTATED the accept/reject boundary
/// would drift from the runtime the moment either side moved, in the one direction that hurts:
/// a value the modal accepts and the driver then rejects (Gitea #549). Both doors reach this
/// a value the modal accepts and the driver then rejects. Both doors reach this
/// method.
/// </para>
/// <para>
@@ -131,7 +131,7 @@ public static class TagConfigJson
=> TryReadEnum<TEnum>(o, name, out var v) == JsonEnumRead.Valid ? v : fallback;
/// <summary>
/// Strict enum read (R2-11 Phase C — the runtime flip). Absent ⇒ <paramref name="fallback"/>;
/// Strict enum read. Absent ⇒ <paramref name="fallback"/>;
/// a present, case-insensitively-parseable string ⇒ the parsed value; a present-but-invalid
/// string (a typo) ⇒ the read FAILS (returns <see langword="false"/>) so the runtime parser can
/// reject the whole tag and the driver surfaces <c>BadNodeIdUnknown</c> — instead of silently
@@ -9,7 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <remarks>
/// Applied to tag-definition POCOs
/// (e.g. <c>ModbusTagDefinition</c>, <c>S7TagDefinition</c>, OPC UA client tag rows) at the
/// property or record level. <b>Reserved for the per-tag opt-in; NOT yet consulted</b> (R2-02/S-8) —
/// property or record level. <b>Reserved for the per-tag opt-in; NOT yet consulted</b> —
/// the dispatch site (<c>DriverInstanceActor.HandleWriteAsync</c>) currently treats every write as
/// non-idempotent, so no tag retries a write today. Threading tag-definition metadata to the dispatch
/// seam is the documented forward path; when it ships, the <c>CapabilityInvoker</c> non-idempotent arm
@@ -45,16 +45,13 @@ public sealed record AlarmHistorianEvent(
[JsonIgnore]
public Guid DeliveryId { get; init; } = Guid.NewGuid();
/// <summary>Compares the ten business fields, excluding transport delivery identity.</summary>
/// <param name="other">The event to compare.</param>
/// <returns>Whether the business fields are equal.</returns>
/// <inheritdoc />
public bool Equals(AlarmHistorianEvent? other) => ReferenceEquals(this, other) || other is not null
&& AlarmId == other.AlarmId && EquipmentPath == other.EquipmentPath && AlarmName == other.AlarmName
&& AlarmTypeName == other.AlarmTypeName && Severity == other.Severity && EventKind == other.EventKind
&& Message == other.Message && User == other.User && Comment == other.Comment && TimestampUtc == other.TimestampUtc;
/// <summary>Hashes the same business fields used by equality, excluding delivery identity.</summary>
/// <returns>The business-field hash code.</returns>
/// <inheritdoc />
public override int GetHashCode()
{
var hash = new HashCode();
@@ -106,8 +106,8 @@ public static class Part9StateMachine
if (current.Acked == AlarmAckedState.Acknowledged)
{
// A COMMENTED re-ack carries a real comment even though nothing is acknowledged (Gitea #597 review
// m1): the OPC UA node reports it — the SDK writes the comment on a re-ack — so the system of
// A COMMENTED re-ack carries a real comment even though nothing is acknowledged (review
// finding m1): the OPC UA node reports it — the SDK writes the comment on a re-ack — so the system of
// record must hold it too, or the event advertises a comment that exists only on the node that
// served the call (never persisted, never replicated to the pair peer). Recorded exactly like
// AddComment; the ack fields (LastAck*) stay those of the real acknowledge. An uncommented re-ack
@@ -387,7 +387,7 @@ public enum EmissionKind
Enabled,
Disabled,
CommentAdded,
/// <summary>#478 — the worst-of-input source quality changed bucket (Good/Uncertain/Bad) with no
/// <summary>The worst-of-input source quality changed bucket (Good/Uncertain/Bad) with no
/// accompanying Part 9 state transition. Delivered out of band via the dedicated
/// <c>WriteAlarmQuality</c> node path, never through the <c>IAlarmSource</c> fan-out (it is not a
/// state change and must not materialize or historize a condition).</summary>
@@ -97,7 +97,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
new(StringComparer.Ordinal);
/// <summary>
/// #478 — last emitted worst-of-input quality bucket per alarm (0 = Good, 1 = Uncertain,
/// Last emitted worst-of-input quality bucket per alarm (0 = Good, 1 = Uncertain,
/// 2 = Bad), computed each evaluation over the refilled read cache. A change in this bucket
/// with no accompanying Part 9 state transition drives a standalone
/// <see cref="EmissionKind.QualityChanged"/> emission (a Bad input freezes the condition — no
@@ -384,7 +384,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
=> _alarms.Values.Select(a => a.Condition).ToArray();
/// <summary>
/// #487 — the currently-held condition of every loaded alarm, packaged with the definition's
/// The currently-held condition of every loaded alarm, packaged with the definition's
/// severity, the rendered message template and the last-observed input quality, i.e. everything
/// a caller needs to <b>project</b> the alarm onto an OPC UA condition node without waiting for
/// the next transition.
@@ -740,7 +740,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
_ => new AlarmScratch(state.Inputs, state.Logger, _clock));
RefillReadCache(scratch.ReadCache, state.Inputs);
// #478 — worst OPC UA quality across the alarm's inputs, computed BEFORE the readiness
// Worst OPC UA quality across the alarm's inputs, computed BEFORE the readiness
// short-circuit so an outright-Bad input is still observed. A bucket change with no state
// transition is delivered out of band as a QualityChanged emission (see below).
var worstStatus = WorstInputStatus(scratch.ReadCache);
@@ -881,13 +881,13 @@ public sealed class ScriptedAlarmEngine : IDisposable
// adapter honors it to suppress ONLY the durable sink write; the live alerts fan-out is
// unaffected (it is not gated on this flag).
HistorizeToAveva: state.Definition.HistorizeToAveva,
// #478 — the worst input quality at evaluation time rides the transition so the projected
// The worst input quality at evaluation time rides the transition so the projected
// full snapshot keeps quality consistent (no clobber-to-Good).
WorstInputStatusCode: worstInputStatus);
}
/// <summary>
/// #478 — build a standalone <see cref="EmissionKind.QualityChanged"/> event carrying the new
/// Build a standalone <see cref="EmissionKind.QualityChanged"/> event carrying the new
/// worst-of-input quality. Emitted when the quality bucket moved but no Part 9 transition fired
/// (a Bad input freezes the condition; a Suppressed/None transition also leaves state unchanged).
/// The host routes it to the dedicated <c>WriteAlarmQuality</c> node path (annotate quality only,
@@ -913,14 +913,14 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// the <see cref="AreInputsReady"/> guard already handles by holding the condition. Counting it would
/// flash every scripted condition Bad at deploy until the first push and flood the quality path with
/// load-time annotations, so it is skipped (contributes Good). Empty / all-unpushed cache ⇒ Good (0).
/// <para><b>"Never pushed" is a specific status code, not "the value is null" (#481, closing #478
/// Finding 3).</b> This used to skip every entry whose value was null, which made a genuine runtime Bad
/// <para><b>"Never pushed" is a specific status code, not "the value is null"
/// (Finding 3).</b> This used to skip every entry whose value was null, which made a genuine runtime Bad
/// indistinguishable from cold start — and that is exactly the shape a comms-lost driver produces, so
/// the case the skip was hiding was the case that matters most. The upstream source now stamps a
/// never-pushed path <see cref="StatusNeverPushed"/> and nothing else does, so the two are tellable
/// apart. A null snapshot is still skipped: that is a cache the refill did not populate, not an
/// observation.</para>
/// <para><b>The comparison itself lives in <see cref="InputQuality.WorstOf"/></b> (archreview R3-02)
/// <para><b>The comparison itself lives in <see cref="InputQuality.WorstOf"/></b>
/// — the same rule the virtual-tag and Calculation seams now use, so "worst input wins, carrying its
/// sub-code, first-seen on a severity tie" is stated once. What stays here is the alarm-specific
/// <em>filter</em>: this engine's two skips are deliberately NOT part of the shared rule, because the
@@ -952,7 +952,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
return severity >= 2 ? 2 : (int)severity;
}
/// <summary>#478 — a canonical worst StatusCode for an alarm's last-observed quality bucket, used by
/// <summary>A canonical worst StatusCode for an alarm's last-observed quality bucket, used by
/// the operator-command + shelving-timer emission paths (which don't re-read inputs) so an ack /
/// shelve while an input is Bad still carries Bad rather than resetting the condition to Good.</summary>
private uint LastWorstStatus(string alarmId)
@@ -1264,14 +1264,14 @@ public sealed record ScriptedAlarmEvent(
DateTime TimestampUtc,
string? Comment = null,
bool HistorizeToAveva = true,
// #478 — the worst OPC UA StatusCode across the alarm's input tags at evaluation time. A raw uint,
// since Core.ScriptedAlarms does not reference Commons. Since #519 the host carries it to the condition
// The worst OPC UA StatusCode across the alarm's input tags at evaluation time. A raw uint,
// since Core.ScriptedAlarms does not reference Commons. The host carries it to the condition
// node UNCHANGED — it used to be collapsed to a 3-state bucket and re-expanded, losing the sub-code.
// Default 0u == Good keeps every existing constructor call unchanged.
uint WorstInputStatusCode = 0u);
/// <summary>
/// #487 — a loaded alarm's current condition, ready to be projected onto its OPC UA node.
/// A loaded alarm's current condition, ready to be projected onto its OPC UA node.
/// Produced by <see cref="ScriptedAlarmEngine.GetProjections"/> on demand; it is <b>not</b> an
/// emission and never travels the <see cref="ScriptedAlarmEngine.OnEvent"/> path, so it can never
/// become an <c>alerts</c> row or a historian record. Deliberately a separate type from
@@ -81,7 +81,7 @@ public sealed class ScriptedAlarmSource : IAlarmSource, IDisposable
{
if (_disposed) return;
// #478 — QualityChanged is a source-quality annotation, not a Part 9 state change. It is delivered
// QualityChanged is a source-quality annotation, not a Part 9 state change. It is delivered
// out of band via the dedicated WriteAlarmQuality node path; surfacing it here would fabricate a
// phantom AlarmEventArgs that materializes / historizes a condition. Swallow it.
if (evt.Emission == EmissionKind.QualityChanged) return;
@@ -81,7 +81,7 @@ public static class ScriptSandbox
// surface (operator-authored hosting helpers, third-party packages, app code)
// is not on the list and stays unreachable.
var references = new List<MetadataReference>();
// #603: both halves are cached. CreateFromFile reads + maps the DLL, and the BCL half is ~170
// both halves are cached. CreateFromFile reads + maps the DLL, and the BCL half is ~170
// assemblies, so doing it per compile was the dominant fixed cost of every ScriptEvaluator build.
// MetadataReference is immutable and thread-safe, so sharing instances across compilations is safe.
foreach (var asm in pinnedAssemblies)
@@ -2,7 +2,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// <summary>
/// <c>Security:Acl</c> — the switch that turns per-node ACL evaluation into the authorization
/// decision on the OPC UA data plane (#520, stage 5).
/// decision on the OPC UA data plane.
/// </summary>
/// <remarks>
/// <para>
@@ -39,7 +39,7 @@ public sealed class AclOptions
/// <summary>
/// When true, node ACL grants decide write, alarm-command and HistoryRead authorization. Default
/// false, which preserves the pre-stage-5 role gates byte-for-byte.
/// false, which preserves the previous role gates byte-for-byte.
/// </summary>
public bool Enabled { get; set; }
@@ -56,7 +56,7 @@ public sealed class AclOptions
/// </para>
/// <para>
/// <c>0</c> or negative disables the timer, leaving revalidation deploy-triggered only (the
/// pre-R3-05 behaviour). The timer is <b>inert while <see cref="Enabled"/> is false</b>: the
/// previous behaviour). The timer is <b>inert while <see cref="Enabled"/> is false</b>: the
/// pass early-returns when ACL enforcement is off.
/// </para>
/// </remarks>
@@ -16,7 +16,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// wrong-node authorization rather than a visible error.
/// </para>
/// <para>
/// <b>The string-identity rule for the whole authorization path (Gitea #533), stated here because
/// <b>The string-identity rule for the whole authorization path, stated here because
/// this type is where it was first violated:</b>
/// </para>
/// <list type="bullet">
@@ -26,7 +26,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// mean the same entity, and the sources genuinely differ in case (a <c>ClusterId</c> arrives
/// lower-cased through <c>OTOPCUA_ROLES</c> and verbatim through <c>Cluster:Roles</c> — see
/// <c>RoleParser.ClusterIdFromRole</c> — and a <c>ScopeId</c> is operator free-text in the ACL
/// editor until the stage-7 picker lands).
/// editor until the scope picker lands).
/// </item>
/// <item>
/// <b>Path segments compare <c>Ordinal</c></b> — every level of <see cref="NodeAclPath"/>,
@@ -36,7 +36,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// </item>
/// </list>
/// <para>
/// Before #533 this record struct used the compiler's default equality, i.e. ordinal, while the
/// Previously this record struct used the compiler's default equality, i.e. ordinal, while the
/// trie builder filtered <c>ClusterId</c> and hashed principals <c>OrdinalIgnoreCase</c> — three
/// rules in one path. It worked only because the resolver and the builder were handed the same
/// <c>NodeAcl</c> instances, so the <c>ScopeId</c> strings were literally the same object. Feed
@@ -49,9 +49,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// <param name="ScopeId">The authored scope id.</param>
public readonly record struct AclScopeRef(NodeAclScopeKind Kind, string ScopeId)
{
/// <summary>Equality over the kind plus a case-insensitive id — see the rule on the type.</summary>
/// <param name="other">The other reference.</param>
/// <returns><see langword="true"/> when both name the same scope.</returns>
/// <inheritdoc />
public bool Equals(AclScopeRef other) =>
Kind == other.Kind && string.Equals(ScopeId, other.ScopeId, StringComparison.OrdinalIgnoreCase);
@@ -62,7 +60,7 @@ public readonly record struct AclScopeRef(NodeAclScopeKind Kind, string ScopeId)
/// <summary>
/// Produces the <c>scopePaths</c> lookup <see cref="PermissionTrieBuilder.Build"/> consumes: for each
/// ACL scope, the trie path(s) at which a grant on it must be planted (#520 stage 3).
/// ACL scope, the trie path(s) at which a grant on it must be planted.
/// </summary>
/// <remarks>
/// <para>
@@ -131,7 +129,7 @@ public sealed class AclScopePathResolver
ArgumentNullException.ThrowIfNull(references);
// Every map is re-keyed OrdinalIgnoreCase rather than used as handed in. Ids are identifiers, so
// they follow the identifier half of the rule on AclScopeRef (Gitea #533) — and doing it HERE,
// they follow the identifier half of the rule on AclScopeRef — and doing it HERE,
// rather than documenting a comparer contract for callers, is what makes the rule impossible to
// get wrong from the outside: the two producers (EF entities at the deploy gate, artifact JSON on
// the node) build these dictionaries independently and neither can now diverge from the other.
@@ -224,7 +222,7 @@ public sealed class AclScopePathResolver
// Cluster never reaches here (the caller short-circuits it), and FolderSegment is the retired
// v2 kind with no v3 entity behind it — deliberately unresolvable. The trie builder now
// short-circuits FolderSegment too, so it reports RetiredScopeKind rather than the
// indistinguishable-from-a-typo UnresolvedScope (Gitea #533); this arm stays as the answer
// indistinguishable-from-a-typo UnresolvedScope; this arm stays as the answer
// for a direct ResolveOne caller.
case NodeAclScopeKind.Cluster:
case NodeAclScopeKind.FolderSegment:
@@ -4,7 +4,7 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// <summary>Create / list / revoke OPC UA data-plane API keys (#520).</summary>
/// <summary>Create / list / revoke OPC UA data-plane API keys.</summary>
/// <remarks>
/// A service rather than logic in the Razor page, because this repo has no bUnit — anything only a
/// page can reach is untestable, and minting credentials is not where that belongs.
@@ -1,7 +1,7 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// <summary>
/// <c>Security:ApiKeys</c> — OPC UA data-plane API key settings (#520). The data plane is
/// <c>Security:ApiKeys</c> — OPC UA data-plane API key settings. The data plane is
/// machine-to-machine, so a consuming system presents a key rather than an LDAP user.
/// </summary>
public sealed class ApiKeyOptions
@@ -34,7 +34,7 @@ public sealed class ApiKeyOptions
/// distinct Warning plus <c>otopcua.opcua.apikey_store.bounded{outcome=timeout}</c>.
/// </summary>
/// <remarks>
/// <c>0</c> or negative disables the deadline, restoring the pre-R3-05 unbounded behaviour. Do not
/// <c>0</c> or negative disables the deadline, restoring the previous unbounded behaviour. Do not
/// do that on a node with a ConfigDb: the SDK raises impersonation synchronously under a
/// <c>SessionManager</c>-wide lock, so an unanswered lookup stalls <b>every</b> session activation
/// in the server, not just its own.
@@ -5,7 +5,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// <summary>
/// Pure helpers for the OPC UA data-plane API key format <c>otua_&lt;keyId&gt;_&lt;secret&gt;</c>
/// (#520): generation, parsing, and constant-time verification. No I/O and no DI, so the
/// — generation, parsing, and constant-time verification. No I/O and no DI, so the
/// security-critical half is unit-testable on its own.
/// </summary>
/// <remarks>
@@ -5,7 +5,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// <summary>
/// The data-plane authorization inputs a node is currently serving: the API keys that may
/// authenticate, the ACL grants naming their principals, the cluster the node belongs to, and the
/// generation those were resolved at (#520, stage 4).
/// generation those were resolved at.
/// </summary>
/// <remarks>
/// <para>
@@ -215,7 +215,6 @@ public sealed class EmptyDataPlaneAuthoritySource : IDataPlaneAuthoritySource
public DataPlaneAuthoritySnapshot Current => DataPlaneAuthoritySnapshot.Empty;
/// <inheritdoc />
/// <remarks>Never raised — this source is permanently empty, so nothing derived from it can go stale.</remarks>
public event Action<DataPlaneAuthoritySnapshot>? Changed
{
add { }
@@ -252,7 +251,7 @@ public interface IDataPlaneAuthoritySink
/// </summary>
/// <remarks>
/// <para>
/// <b>It owns the <see cref="PermissionTrieCache"/> rather than being handed one</b> (#520 stage 5).
/// <b>It owns the <see cref="PermissionTrieCache"/> rather than being handed one</b>.
/// The trie is built from the same grants and the same generation as the snapshot, inside the same
/// <see cref="Apply"/> call, so the two cannot describe different deployments. Injecting the cache
/// would have made "install the snapshot" and "install the trie" two wirings, and this repo has
@@ -282,7 +281,7 @@ public sealed class DataPlaneAuthorityState : IDataPlaneAuthoritySource, IDataPl
/// <inheritdoc />
public DataPlaneAuthoritySnapshot Current => Volatile.Read(ref _current);
/// <inheritdoc />
/// <summary>Raised after <see cref="Apply"/> publishes a new snapshot.</summary>
public event Action<DataPlaneAuthoritySnapshot>? Changed;
/// <inheritdoc />
@@ -10,7 +10,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// <remarks>
/// <para>
/// The hierarchy is <c>Cluster → Realm → …node path…</c>. The node path is <b>variable length</b>,
/// which is the whole reason this type is no longer a fixed set of level properties (#520 stage 3):
/// which is the whole reason this type is no longer a fixed set of level properties:
/// </para>
/// <list type="bullet">
/// <item>
@@ -57,7 +57,7 @@ public enum NodeHierarchyKind
/// <summary><c>Area / Line / Equipment [/ EffectiveName]</c> — the UNS realm.</summary>
Uns,
/// <summary><c>folder… / Driver / Device / group… / Tag</c> — the raw device-tree realm. (Appended, #520 stage 3.)</summary>
/// <summary><c>folder… / Driver / Device / group… / Tag</c> — the raw device-tree realm. (Appended.)</summary>
Raw,
}
@@ -69,7 +69,7 @@ public enum NodeHierarchyKind
/// <para>
/// This exists because v3 <b>has no <c>Namespace</c> entity</b>. The snapshot was retired when the
/// address space went dual-namespace ("the two OPC UA namespaces are implicit"), which left the
/// trie's level-1 gate with no configured key source at all — the pre-stage-3 walker read a
/// trie's level-1 gate with no configured key source at all — the earlier walker read a
/// <c>NamespaceId</c> that nothing in the system ever produced, so the level could only ever fail
/// to match. Deriving the segment from the realm restores the level as something real without
/// reintroducing an entity.
@@ -1,6 +1,6 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// <summary>Reserved principal names for the OPC UA data plane (#520).</summary>
/// <summary>Reserved principal names for the OPC UA data plane.</summary>
/// <remarks>
/// Lives in Core rather than beside the authenticator because BOTH ends need it: the OPC UA server
/// resolves an anonymous session to it, and the AdminUI authoring path must refuse to mint a key
@@ -151,7 +151,7 @@ public sealed class PermissionTrieNode
/// </summary>
/// <remarks>
/// ⚠️ The comparer is <b>case-sensitive ordinal</b>, matching <c>RawPaths.Comparer</c> and OPC UA
/// NodeId identity. It was <c>OrdinalIgnoreCase</c> before #520 stage 3, which was a silent
/// NodeId identity. It was <c>OrdinalIgnoreCase</c> in an earlier version, which was a silent
/// widening: raw sibling names are unique under an ordinal comparer, so <c>Speed</c> and
/// <c>speed</c> can both exist under one device, and a grant on either would have matched both.
/// Case-sensitive matching is also the fail-closed direction — a case-drifted ACL row now grants
@@ -167,7 +167,7 @@ public sealed class PermissionTrieNode
/// </summary>
/// <remarks>
/// Compared <c>OrdinalIgnoreCase</c>: this holds principal names, which are identifiers, and the
/// repo's rule (#533) is that identifiers compare case-insensitively while <em>path segments</em>
/// repo's rule is that identifiers compare case-insensitively while <em>path segments</em>
/// — the <see cref="Children"/> keys — compare ordinally.
/// </remarks>
public HashSet<string> BrowseTransitPrincipals { get; } = new(StringComparer.OrdinalIgnoreCase);
@@ -14,7 +14,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// life of the cache entry. Idempotent — two builds from the same rows produce equal tries.
/// </para>
/// <para>
/// ⚠️ <b>There is no longer a "deterministic test mode".</b> Before #520 stage 3 a null
/// ⚠️ <b>There is no longer a "deterministic test mode".</b> Previously a null
/// <c>scopePaths</c> made sub-cluster rows fall back to a direct child of the trie
/// root keyed on <c>ScopeId</c>. That position is unreachable by construction — level 1 of the walk
/// is the realm — so the fallback produced grants that looked placed and could never match, and
@@ -183,7 +183,7 @@ public enum PermissionTrieBuildDiagnosticReason
/// <summary>
/// The row's <see cref="NodeAclScopeKind"/> is retired and has no v3 entity behind it
/// (<see cref="NodeAclScopeKind.FolderSegment"/>), so it can never resolve whatever its
/// <c>ScopeId</c>. Split out of <see cref="UnresolvedScope"/> by Gitea #533: the two have
/// <c>ScopeId</c>. Split out of <see cref="UnresolvedScope"/> deliberately: the two have
/// different remedies — re-scope the grant to <see cref="NodeAclScopeKind.RawFolder"/> versus
/// fix an id — and conflating them told an operator with a legacy row to go looking for a typo.
/// </summary>
@@ -7,7 +7,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// </summary>
/// <remarks>
/// ⚠️ <b>The refresh machinery below is LDAP-shaped and is largely vestigial under the
/// API-key model (#520).</b> It exists because an LDAP group membership can change
/// API-key model.</b> It exists because an LDAP group membership can change
/// out-of-band in the directory, so a long-lived session had to re-resolve periodically.
/// An API-key principal set cannot change out-of-band: keys and ACL rows both ride the
/// deployment artifact (driver-only nodes have no ConfigDb since Phase 4), so the only
@@ -141,7 +141,7 @@ public static class EquipmentNodeWalker
/// <summary>
/// Pre-loaded + pre-filtered snapshot of one cluster's UNS content (Area / Line / Equipment +
/// per-equipment VirtualTags / ScriptedAlarms). v3: raw-tag references (<c>UnsTagReference</c>)
/// are materialized by the Batch-4 dual-namespace fan-out, not by this walker, so they are not
/// are materialized by the dual-namespace fan-out, not by this walker, so they are not
/// part of this snapshot.
/// </summary>
public sealed record EquipmentNamespaceContent(
@@ -30,7 +30,7 @@ public static class IdentificationFolderBuilder
public const string FolderName = "Identification";
/// <summary>
/// Canonical decision #139 field set exposed in the Identification sub-folder. Order
/// Canonical field set exposed in the Identification sub-folder. Order
/// matches the decision-log entry so any browse-order reader can cross-reference
/// without re-sorting.
/// </summary>
@@ -132,7 +132,7 @@ public sealed class CapabilityInvoker : IDriverCapabilityInvoker
{
// Build the no-retry options snapshot ONCE per invoker lifetime (01/P-4). The options are
// immutable for the invoker's lifetime — a ResilienceConfig change respawns the driver child,
// which constructs a fresh invoker (#13 respawn-on-change) — so lazily caching the snapshot is
// which constructs a fresh invoker (respawn-on-change) — so lazily caching the snapshot is
// safe and removes the per-write allocation the previous once-per-call snapshot incurred. The
// null-check-assign tolerates a benign first-call race (both threads build an equivalent record).
var noRetryOptions = _noRetryWriteOptions ??= BuildNoRetryWriteOptions();
@@ -81,7 +81,7 @@ public sealed class DriverCapabilityInvokerFactory : IDriverCapabilityInvokerFac
// too — a retained "Breaker OPEN" from a pre-respawn outage could never self-correct (only the
// discarded pipeline's OnClosed stamps the close) and would show a false OPEN chip forever.
// Reinstate first: an instance id removed by an earlier deployment (Release ⇒ Retire) and now re-added
// must record again (#609).
// must record again.
_statusTracker.Reinstate(driverInstanceId);
_statusTracker.Invalidate(driverInstanceId);
@@ -94,17 +94,15 @@ public sealed class DriverCapabilityInvokerFactory : IDriverCapabilityInvokerFac
optionsGeneration: generation);
}
// Without this the tracker kept a removed instance's snapshots forever (only Create
// invalidated, and a removed instance is never re-created), and the resilience publisher re-emitted
// them every tick — undoing the host's telemetry-hub eviction within one interval.
// The tracker retires the instance rather than merely invalidating it: the host stops the
// child asynchronously, so a capability call it still had in flight completes through the released
// invoker afterwards, and its call-start/success/complete writes re-created the entry (observed: the
// child's in-flight SubscribeBulk). A retired id ignores those writes until a later Create
// for the same id reinstates it.
/// <inheritdoc />
/// <remarks>
/// #609: without this the tracker kept a removed instance's snapshots forever (only <see cref="Create"/>
/// invalidated, and a removed instance is never re-created), and the resilience publisher re-emitted
/// them every tick — undoing the host's telemetry-hub eviction within one interval.
/// <para>The tracker <b>retires</b> the instance rather than merely invalidating it: the host stops the
/// child asynchronously, so a capability call it still had in flight completes through the released
/// invoker afterwards, and its call-start/success/complete writes re-created the entry (observed: the
/// child's in-flight SubscribeBulk). A retired id ignores those writes until a later <see cref="Create"/>
/// for the same id reinstates it.</para>
/// </remarks>
public void Release(string driverInstanceId)
{
_builder.Invalidate(driverInstanceId);
@@ -19,7 +19,7 @@ public sealed record DriverResilienceOptions
public IReadOnlyDictionary<DriverCapability, CapabilityPolicy> CapabilityPolicies { get; init; }
= new Dictionary<DriverCapability, CapabilityPolicy>();
// RecycleIntervalSeconds is GONE (Gitea #522). It configured a scheduled recycle of a Tier C
// RecycleIntervalSeconds is GONE. It configured a scheduled recycle of a Tier C
// driver's out-of-process Host — a process that no longer exists anywhere: Galaxy reaches MXAccess
// over gRPC to the external mxaccessgw sidecar (PR 7.2 retired the in-process Host/Proxy/Shared
// projects) and FOCAS has run in-process since its managed wire client landed 2026-04-24. The
@@ -64,7 +64,7 @@ public sealed record DriverResilienceOptions
/// Per-tier per-capability default policy table, per the Phase 6.1
/// Stream A.2 specification. The parser enforces no-retry on
/// <see cref="DriverCapability.Write"/> and <see cref="DriverCapability.AlarmAcknowledge"/>
/// as an invariant (R2-02/S-8) — a JSON retryCount override on those capabilities is forced back
/// as an invariant — a JSON retryCount override on those capabilities is forced back
/// to 0; per-tag opt-in via <see cref="WriteIdempotentAttribute"/> is the future relaxation.
/// </summary>
/// <param name="tier">The driver tier to get defaults for.</param>
@@ -84,7 +84,7 @@ public sealed record DriverResilienceOptions
// Initialize: no retry (the DriverInstanceActor reconnect timer is the retry loop) and a
// threshold of 2, NOT the Read-sized 5 — the breaker samples over 30 s and the reconnect
// timer fires every 10 s, so at most three init attempts fit one window; a threshold of 5
// could never open on connect failures (R2-10 T10).
// could never open on connect failures.
[DriverCapability.Initialize] = new(TimeoutSeconds: 5, RetryCount: 0, BreakerFailureThreshold: 2),
},
DriverTier.B => new Dictionary<DriverCapability, CapabilityPolicy>
@@ -94,7 +94,7 @@ public static class DriverResilienceOptionsParser
}
}
// No recycle-interval handling: the scheduled-recycle machinery was deleted in Gitea #522
// No recycle-interval handling: the scheduled-recycle machinery was deleted
// (nothing constructed it, and IDriverSupervisor — which would have performed the recycle —
// had no implementations). A "recycleIntervalSeconds" key surviving in an existing
// ResilienceConfig blob is harmless: the shape no longer declares it, and unknown JSON
@@ -16,13 +16,13 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Resilience;
/// Pure in-memory (no EF Core / SQL Server dependency) so tests can exercise it directly.
/// The operator-facing reader is the DPS publisher named above, mirroring the driver-health
/// flow — not the DB-table/HostedService shape originally sketched for Phase 6.1 Stream E
/// (R2-10 built the DPS reader instead).
/// (the DPS reader was built instead).
/// </remarks>
public sealed class DriverResilienceStatusTracker
{
private readonly ConcurrentDictionary<StatusKey, ResilienceStatusSnapshot> _status = new();
// Instances retired by Retire (removed from the deployed plan) and not since Reinstated (#609). Every
// Instances retired by Retire (removed from the deployed plan) and not since Reinstated. Every
// Record* for one of these is ignored: the removed instance's child stops ASYNCHRONOUSLY, so a capability
// call it still had in flight completes through its invoker after the host's release and would otherwise
// re-create the entry — which the resilience publisher then re-emits every tick, forever. One entry per
@@ -127,7 +127,7 @@ public sealed class DriverResilienceStatusTracker
/// <summary>
/// Drop every snapshot for a driver instance that has been REMOVED from the deployed plan, and ignore
/// every later write for it until <see cref="Reinstate"/> (#609). Called by
/// every later write for it until <see cref="Reinstate"/>. Called by
/// <c>DriverCapabilityInvokerFactory.Release</c>. Unlike <see cref="Invalidate"/> (a respawn, whose new
/// invoker records straight away), the removed instance's child is still stopping — a call it had in
/// flight finishes after this returns, and without the hold its RecordCallStart / RecordSuccess /
@@ -261,7 +261,7 @@ public sealed record ResilienceStatusSnapshot
/// <c>driver-resilience-status</c> telemetry channel.
/// <para>It used to say this fed <c>DriverInstanceResilienceStatus.CurrentBulkheadDepth</c> "until
/// the full telemetry observer lands". The telemetry observer landed (Phase 5); that table never
/// had a writer and was dropped in Gitea #524.</para>
/// had a writer and was since dropped.</para>
/// </summary>
public int CurrentInFlight { get; init; }
}