feat(mqtt): Sparkplug primary-host STATE publishing, Primary-gated (#511)
`ActAsPrimaryHost` was accepted by config and by the AdminUI form and did nothing but
log a warning. It now delivers the whole three-part contract or none of it: a retained
`ONLINE` on `spBv1.0/STATE/{hostId}`, a matching retained `OFFLINE` armed as the MQTT
Last Will, and an explicit `OFFLINE` on a clean stand-down.
**The claim is a dedicated connection, not a message.** A Will can only be registered in
the CONNECT packet — it cannot be added to a live session, changed, or withdrawn. So a
claim that is acquired and released at runtime cannot ride the long-lived ingest session.
`SparkplugPrimaryHost` opens its own connection (client id `{clientId}-spbhost`, forced
clean-session) when the node gains Primary and closes it when the node loses Primary; the
connection's lifetime *is* the claim, so every leg follows from its lifecycle. Ingest is
untouched by a role change — no dropped subscriptions, no lost birth cache, no data gap at
the Secondary->Primary transition.
**Primary-gated** (owner decision, recorded on the issue). Both pair nodes subscribe
independently, so an ungated claim means both claim the same host id — and the sharp
failure is not the two fighting over the retained STATE, it is that the SECONDARY's death
fires its Will and publishes OFFLINE while the Primary is healthy, stopping every edge node
in the group. Registering the Will only on whichever node is Primary at connect time fails
the other way: a node that later gains Primary would publish ONLINE with no Will behind it.
New seam: `IDriverPrimaryRoleSource` (`Core.Abstractions`) carrying the *data-plane* verdict
(`PrimaryGatePolicy.ShouldServiceAsPrimary`), not the alarm-drain verdict — an unknown role
with a driver peer present must deny. Registered unconditionally by `AddOtOpcUaRuntime`,
deliberately NOT alongside `IRedundancyRoleView`, which `AddAlarmHistorian` registers only
when that section is enabled: piggybacking would leave a pair with no alarm historian
falling back to "claim unconditionally". Polled on a 1s timer rather than evented — a
process singleton raising events at per-instance drivers outlives every driver it holds.
Accepted trade: a brief edge-visible OFFLINE window during failover. That is a genuine
state transition, not a glitch to hide.
Live-gated 5/5 against the Mosquitto fixture, including the Will leg — `AbandonForTests`
drops the socket with no DISCONNECT, because a clean disconnect makes the broker discard
the Will by protocol rule, so a test driven through `StopAsync` would observe the
stand-down OFFLINE and pass against an implementation that never armed a Will. Falsified:
removing the Will turns that leg and only that leg red.
Also corrects two stale claims found en route: the AdminUI form still said MQTT tags are
read-only (#508 shipped), and the driver doc still said nothing consumes `IRediscoverable`.
Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
+30
-14
@@ -225,7 +225,7 @@
|
||||
<div class="col-md-3">
|
||||
<label class="form-label" for="mqtt-spb-host">Host ID (optional)</label>
|
||||
<InputText id="mqtt-spb-host" @bind-Value="_form.SparkplugHostId" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="scada-primary" />
|
||||
<div class="form-text">Sparkplug Host Application identity. Receive-only in this version.</div>
|
||||
<div class="form-text">Sparkplug Host Application identity — the <code class="mono">spBv1.0/STATE/{hostId}</code> topic. Required to act as Primary Host.</div>
|
||||
</div>
|
||||
<div class="col-md-3 d-flex flex-column justify-content-center">
|
||||
<div class="form-check form-switch">
|
||||
@@ -237,25 +237,42 @@
|
||||
<InputCheckbox id="mqtt-spb-primary" @bind-Value="_form.SparkplugActAsPrimaryHost" @bind-Value:after="EmitAsync" class="form-check-input" />
|
||||
<label class="form-check-label" for="mqtt-spb-primary">
|
||||
Act as Primary Host
|
||||
<span class="badge text-bg-warning">not implemented</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_form.SparkplugActAsPrimaryHost)
|
||||
{
|
||||
@* Shown rather than the flag being hidden: an operator who needs a primary host must
|
||||
learn it is absent HERE, not from a plant that never sees a STATE message. The
|
||||
driver logs the same warning at startup so the two cannot drift. *@
|
||||
<div class="col-12">
|
||||
<div class="alert alert-warning py-2 px-3 mb-0 small" role="alert">
|
||||
<strong>Primary-host STATE publishing is not implemented.</strong> This driver is
|
||||
receive-only: it will not publish <code class="mono">spBv1.0/STATE/@(string.IsNullOrWhiteSpace(_form.SparkplugHostId) ? "{HostId}" : _form.SparkplugHostId)</code>,
|
||||
so edge nodes will not treat it as their primary host. The driver logs a warning at
|
||||
startup rather than being silently inert. Leave this off unless you are staging
|
||||
configuration ahead of that feature.
|
||||
@* Two distinct messages, and which one shows is decided by SparkplugPrimaryHostAdvice —
|
||||
a plain static, not markup logic, because this repo has no bUnit and a Razor-only rule
|
||||
is untestable. A blank host id is a REFUSAL (there is no topic to publish on) and must
|
||||
not read like the ordinary case; the driver logs the same distinction at startup so the
|
||||
two surfaces cannot drift. *@
|
||||
@if (SparkplugPrimaryHostAdvice.DescribeBlockingProblem(_form.SparkplugHostId) is { } problem)
|
||||
{
|
||||
<div class="col-12">
|
||||
<div class="alert alert-warning py-2 px-3 mb-0 small" role="alert">
|
||||
<strong>This host claim cannot be made.</strong> @problem
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="col-12">
|
||||
<div class="alert alert-info py-2 px-3 mb-0 small" role="alert">
|
||||
This node will publish a retained <code class="mono">ONLINE</code> on
|
||||
<code class="mono">spBv1.0/STATE/@_form.SparkplugHostId.Trim()</code> and arm a matching
|
||||
<code class="mono">OFFLINE</code> as its MQTT Last Will, so edge nodes learn promptly
|
||||
whether the host is alive — including after a crash.
|
||||
<strong>Only the node holding the redundancy Primary role claims it.</strong>
|
||||
Two nodes claiming one host id would let a <em>Secondary's</em> death publish
|
||||
<code class="mono">OFFLINE</code> over a healthy Primary and stop the whole group, so a
|
||||
failover leaves a brief window in which edge nodes correctly see no host.
|
||||
At most one primary host may exist per host id per broker — do not set this on two
|
||||
separate deployments pointing at the same broker.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<div class="col-12">
|
||||
@@ -263,7 +280,6 @@
|
||||
Sparkplug tags bind by the <code class="mono">group / edge node / device / metric</code>
|
||||
tuple, not by topic — author them with <strong>Browse device…</strong> on a device under
|
||||
this driver, which builds its tree from observed birth certificates.
|
||||
<strong>MQTT tags are read-only</strong> (write-through is not implemented).
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
+12
-6
@@ -142,17 +142,23 @@ public sealed class MqttDriverFormModel
|
||||
public string SparkplugGroupId { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Sparkplug mode: the Host Application identity. Optional, and <b>receive-only</b> in this
|
||||
/// version — see <see cref="SparkplugActAsPrimaryHost"/>.
|
||||
/// Sparkplug mode: the Host Application identity — the <c>spBv1.0/STATE/{hostId}</c> topic the
|
||||
/// driver observes, and publishes on when <see cref="SparkplugActAsPrimaryHost"/> is set.
|
||||
/// Optional for observation; <b>required</b> to claim.
|
||||
/// </summary>
|
||||
public string SparkplugHostId { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Sparkplug mode: claim the Primary Host Application role. <b>Not implemented</b> — STATE
|
||||
/// publishing is out of scope, and the driver logs a warning rather than being silently inert
|
||||
/// when this is set. Surfaced (rather than hidden) so an operator who needs it learns that from
|
||||
/// the form instead of from a quiet plant.
|
||||
/// Sparkplug mode: claim the Primary Host Application role (Gitea #511) — a retained
|
||||
/// <c>ONLINE</c>, a matching <c>OFFLINE</c> armed as the MQTT Last Will, and an explicit
|
||||
/// <c>OFFLINE</c> on clean shutdown.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Primary-gated:</b> only the node holding the redundancy Primary role claims it, so the two
|
||||
/// nodes of a pair cannot fight over one host id. At most one primary host may exist per host id
|
||||
/// per broker, which the form states — it is not enforceable from here, since a second
|
||||
/// deployment pointing at the same broker is invisible to this one.
|
||||
/// </remarks>
|
||||
public bool SparkplugActAsPrimaryHost { get; set; }
|
||||
|
||||
/// <summary>Sparkplug mode: answer a detected sequence-number gap with a rebirth request (NCMD).</summary>
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
|
||||
|
||||
/// <summary>
|
||||
/// The rule deciding which primary-host advisory <c>MqttDriverForm</c> shows when
|
||||
/// <c>Act as Primary Host</c> is on (Gitea #511).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>A plain static rather than markup logic, because this repo has no bUnit.</b> A condition
|
||||
/// written inline in a <c>.razor</c> file is unreachable from any test, and the failure mode
|
||||
/// here is specific: an operator who ticks the box with a blank host id must see a
|
||||
/// <i>refusal</i>, not the ordinary "here is what will happen" note. Getting that backwards
|
||||
/// tells them a claim is being made when the driver is in fact declining to make one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Deliberately mirrors <c>SparkplugPrimaryHost.DescribeUnusable</c>'s blank-host-id arm. The
|
||||
/// mode arm is not duplicated: this panel only renders inside the SparkplugB branch of the
|
||||
/// form, so mode cannot be wrong here.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class SparkplugPrimaryHostAdvice
|
||||
{
|
||||
/// <summary>
|
||||
/// Describes why a primary-host claim cannot be made for <paramref name="hostId"/>, or
|
||||
/// <see langword="null"/> when it can.
|
||||
/// </summary>
|
||||
/// <param name="hostId">The Sparkplug host id as typed into the form.</param>
|
||||
/// <returns>An operator-facing reason, or <see langword="null"/>.</returns>
|
||||
public static string? DescribeBlockingProblem(string? hostId) =>
|
||||
string.IsNullOrWhiteSpace(hostId)
|
||||
? "A Sparkplug host claim is published on spBv1.0/STATE/{hostId}, so it needs a Host ID. "
|
||||
+ "Set one above, or turn this off — the driver will otherwise log a warning at startup "
|
||||
+ "and publish nothing."
|
||||
: null;
|
||||
}
|
||||
@@ -55,7 +55,16 @@ public static class DriverFactoryBootstrap
|
||||
// The Galaxy driver resolves its Gateway.ApiKeySecretRef secret: arm through the
|
||||
// shared ISecretResolver (registered unconditionally by AddZbSecrets on every node).
|
||||
var secretResolver = sp.GetRequiredService<ISecretResolver>();
|
||||
Register(registry, loggerFactory, secretResolver, scriptRoot);
|
||||
// Gitea #511: the MQTT driver's Sparkplug primary-host claim is Primary-gated, and unlike
|
||||
// every other gated operation it is a STANDING assertion on the driver's own connection —
|
||||
// there is no inbound message for DriverHostActor to intercept — so the verdict has to
|
||||
// reach the driver itself. Registered unconditionally on driver-role nodes by
|
||||
// AddOtOpcUaRuntime. GetRequiredService, not GetService: a null here silently falls the
|
||||
// driver back to "claim unconditionally", which on a redundant pair is the dual-claim this
|
||||
// whole mechanism exists to prevent, and it would fail as a plant outage rather than as a
|
||||
// startup error.
|
||||
var primaryRoleSource = sp.GetRequiredService<IDriverPrimaryRoleSource>();
|
||||
Register(registry, loggerFactory, secretResolver, primaryRoleSource, scriptRoot);
|
||||
return registry;
|
||||
});
|
||||
services.AddSingleton<IDriverFactory>(sp =>
|
||||
@@ -142,6 +151,7 @@ public static class DriverFactoryBootstrap
|
||||
DriverFactoryRegistry registry,
|
||||
ILoggerFactory? loggerFactory,
|
||||
ISecretResolver secretResolver,
|
||||
IDriverPrimaryRoleSource primaryRoleSource,
|
||||
ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger? scriptRoot = null)
|
||||
{
|
||||
Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry);
|
||||
@@ -150,7 +160,7 @@ public static class DriverFactoryBootstrap
|
||||
Driver.FOCAS.FocasDriverFactoryExtensions.Register(registry);
|
||||
Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, secretResolver, loggerFactory);
|
||||
Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||
Driver.Mqtt.MqttDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||
Driver.Mqtt.MqttDriverFactoryExtensions.Register(registry, loggerFactory, primaryRoleSource);
|
||||
// Tier A (the Register default): fully managed — HttpClient + System.Xml.Linq, no native SDK,
|
||||
// no COM. Tier C is the only tier that arms process-level recycle (MemoryRecycle hard-breach /
|
||||
// ScheduledRecycleScheduler), which would be wrong here: the driver is in-process and killing
|
||||
|
||||
@@ -273,6 +273,19 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
/// today, the alarm store-and-forward drain. Null on nodes that do not wire one.</summary>
|
||||
private readonly IRedundancyRoleView? _redundancyRoleView;
|
||||
|
||||
/// <summary>
|
||||
/// Where the Primary <b>data-plane</b> verdict is published for drivers that must know it
|
||||
/// directly (Gitea #511). Null on nodes that wire no redundancy manager.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Note the different verdict from <see cref="_redundancyRoleView"/>: that one carries
|
||||
/// <see cref="PrimaryGatePolicy.ShouldDrainAlarmHistory"/>, which OPENS on an unknown role. A
|
||||
/// Sparkplug host-id claim is an exclusive assertion on a shared broker, so it takes the
|
||||
/// data-plane verdict, which closes. Publishing the drain verdict here would let both nodes of
|
||||
/// a pair claim the host id during every boot window.
|
||||
/// </remarks>
|
||||
private readonly IDriverPrimaryRoleSource? _primaryRoleSource;
|
||||
|
||||
/// <summary>
|
||||
/// Host of this node's LocalDb replication partner — the only node that holds a copy of this
|
||||
/// node's alarm queue, and therefore the only node it may ever stand down in favour of.
|
||||
@@ -408,7 +421,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
bool fetchAndCacheMode = false,
|
||||
IDeploymentArtifactFetcher? artifactFetcher = null,
|
||||
IAlarmStateStore? alarmStateStore = null,
|
||||
ITelemetryLocalHub? telemetryHub = null) =>
|
||||
ITelemetryLocalHub? telemetryHub = null,
|
||||
IDriverPrimaryRoleSource? primaryRoleSource = null) =>
|
||||
// WARNING: this forwarding list is POSITIONAL, and Props.Create compiles it into an
|
||||
// expression tree. Six IActorRef? parameters and several interface-typed ones mean a
|
||||
// mis-ordered argument is usually type-compatible and therefore compiles clean, then binds
|
||||
@@ -419,7 +433,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
healthPublisher, virtualTagEvaluator, historyWriter, virtualTagHostOverride,
|
||||
loggerFactory, scriptRootLogger, scriptedAlarmHostOverride, invokerFactory, driverMemberCountProvider,
|
||||
deploymentArtifactCache, redundancyRoleView, replicationPeerHost, fetchAndCacheMode, artifactFetcher,
|
||||
alarmStateStore, telemetryHub));
|
||||
alarmStateStore, telemetryHub, primaryRoleSource));
|
||||
|
||||
/// <summary>Initializes a new DriverHostActor with the specified dependencies.</summary>
|
||||
/// <param name="dbFactory">Database context factory for configuration database access.</param>
|
||||
@@ -460,6 +474,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
/// <param name="telemetryHub">Per-cluster mesh Phase 5: optional node-local live-telemetry hub each
|
||||
/// Primary-gated native-alarm transition is also emitted into, and which is threaded into the spawned
|
||||
/// VirtualTag + ScriptedAlarm hosts. Null (admin-only nodes / tests) skips the emit and passes null down.</param>
|
||||
/// <param name="primaryRoleSource">Gitea #511: where the Primary <i>data-plane</i> verdict is published
|
||||
/// for drivers that assert a standing exclusive identity on a shared external system — today the MQTT
|
||||
/// driver's Sparkplug primary-host STATE claim. Deliberately a separate parameter from
|
||||
/// <paramref name="redundancyRoleView"/> even though the production object implements both: a test that
|
||||
/// supplies only a role-view double must not silently also satisfy this, or its primary-role assertions
|
||||
/// pass without the publish ever happening. Null on nodes that wire no redundancy manager.</param>
|
||||
public DriverHostActor(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext>? dbFactory,
|
||||
CommonsNodeId localNode,
|
||||
@@ -483,11 +503,13 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
bool fetchAndCacheMode = false,
|
||||
IDeploymentArtifactFetcher? artifactFetcher = null,
|
||||
IAlarmStateStore? alarmStateStore = null,
|
||||
ITelemetryLocalHub? telemetryHub = null)
|
||||
ITelemetryLocalHub? telemetryHub = null,
|
||||
IDriverPrimaryRoleSource? primaryRoleSource = null)
|
||||
{
|
||||
_telemetryHub = telemetryHub;
|
||||
_deploymentArtifactCache = deploymentArtifactCache;
|
||||
_redundancyRoleView = redundancyRoleView;
|
||||
_primaryRoleSource = primaryRoleSource;
|
||||
_replicationPeerHost = replicationPeerHost;
|
||||
_fetchAndCacheMode = fetchAndCacheMode;
|
||||
_artifactFetcher = artifactFetcher;
|
||||
@@ -1331,6 +1353,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
|
||||
_redundancyRoleView?.Publish(
|
||||
PrimaryGatePolicy.ShouldDrainAlarmHistory(_localRole, queueSharingPeerIsPrimary: peerIsPrimary));
|
||||
|
||||
// The DATA-PLANE verdict, deliberately — the same one HandleRouteNodeWrite gates on, not the
|
||||
// drain verdict published immediately above. A driver reading this claims an exclusive identity
|
||||
// on a shared external system (Gitea #511: the Sparkplug host id), where two claimants is the
|
||||
// harm, so an unknown role with a driver peer present must deny.
|
||||
_primaryRoleSource?.PublishPrimaryRole(ShouldServiceAsPrimary());
|
||||
}
|
||||
|
||||
private void Stale()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Redundancy;
|
||||
@@ -39,8 +40,22 @@ public interface IRedundancyRoleView
|
||||
void Publish(bool shouldDrainAlarmHistory);
|
||||
}
|
||||
|
||||
/// <summary>Thread-safe <see cref="IRedundancyRoleView"/> backed by a volatile field.</summary>
|
||||
public sealed class RedundancyRoleView : IRedundancyRoleView
|
||||
/// <summary>
|
||||
/// Thread-safe <see cref="IRedundancyRoleView"/> + <see cref="IDriverPrimaryRoleSource"/>, each
|
||||
/// backed by its own volatile field.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Two verdicts, one object, on purpose.</b> Both are published from the same
|
||||
/// <c>DriverHostActor</c> snapshot handler and both are read off-actor, so a second class
|
||||
/// would duplicate the publish plumbing to hold a second bool. They are kept as distinct
|
||||
/// <i>properties</i> rather than collapsed into one because they are genuinely different
|
||||
/// decisions from different policies — see <see cref="IRedundancyRoleView"/>'s remarks on why
|
||||
/// the drain gate must open on an unknown role where the data-plane gate closes. Collapsing
|
||||
/// them is the bug that live gate already found once.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class RedundancyRoleView : IRedundancyRoleView, IDriverPrimaryRoleSource
|
||||
{
|
||||
// Seeded with the decision for "role unknown" rather than a bare false. An unpublished view and a
|
||||
// node whose role nothing ever reports are genuinely the same situation, and they must behave the
|
||||
@@ -49,9 +64,22 @@ public sealed class RedundancyRoleView : IRedundancyRoleView
|
||||
private volatile bool _shouldDrainAlarmHistory =
|
||||
PrimaryGatePolicy.ShouldDrainAlarmHistory(localRole: null, queueSharingPeerIsPrimary: false);
|
||||
|
||||
// Seeded from the same policy that will later publish here, for the unknown-role/no-peer case:
|
||||
// a node nothing has reported a role for, on a cluster with no driver peer, is a single-node
|
||||
// deployment and may claim. The moment a snapshot arrives this is replaced by the real verdict,
|
||||
// and a multi-driver cluster's unknown role denies.
|
||||
private volatile bool _shouldActAsPrimary =
|
||||
PrimaryGatePolicy.ShouldServiceAsPrimary(localRole: null, driverMemberCount: 1);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool ShouldDrainAlarmHistory => _shouldDrainAlarmHistory;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool ShouldActAsPrimary => _shouldActAsPrimary;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Publish(bool shouldDrainAlarmHistory) => _shouldDrainAlarmHistory = shouldDrainAlarmHistory;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void PublishPrimaryRole(bool shouldActAsPrimary) => _shouldActAsPrimary = shouldActAsPrimary;
|
||||
}
|
||||
|
||||
@@ -75,6 +75,22 @@ public static class ServiceCollectionExtensions
|
||||
// telemetry (never DPS) to the Phase-5 gRPC streaming service central dials in on. AddOtOpcUaRuntime
|
||||
// runs only inside Program.cs's hasDriver block, so this lands on driver-role nodes only.
|
||||
services.TryAddSingleton<ITelemetryLocalHub, TelemetryLocalHub>();
|
||||
|
||||
// Gitea #511: the Primary data-plane verdict, published for drivers that assert a standing
|
||||
// exclusive identity on a shared external system (today the MQTT driver's Sparkplug host id).
|
||||
//
|
||||
// Registered HERE, unconditionally on every driver-role node, and NOT alongside the alarm
|
||||
// drain's IRedundancyRoleView — which AddAlarmHistorian registers only when that section is
|
||||
// enabled. Piggybacking on it would leave a redundant pair with no alarm historian configured
|
||||
// with no primary-role source at all, and the driver's documented no-source fallback is
|
||||
// "claim" — so both nodes of that pair would claim the same host id and their Wills would
|
||||
// fight over the retained STATE. Exactly the failure #511 exists to prevent, reached by a
|
||||
// registration accident in an unrelated section.
|
||||
//
|
||||
// Both interfaces are forwarded to ONE RedundancyRoleView instance, so a node that has both
|
||||
// publishes two verdicts into the same object rather than into two that disagree.
|
||||
services.TryAddSingleton<RedundancyRoleView>();
|
||||
services.TryAddSingleton<IDriverPrimaryRoleSource>(sp => sp.GetRequiredService<RedundancyRoleView>());
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -117,8 +133,10 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
// The view is a plain singleton so it exists even on nodes whose DriverHostActor never
|
||||
// publishes to it — an unpublished view reads as "no peer, drain", which is the correct
|
||||
// posture for a deployment that runs no redundancy at all.
|
||||
services.TryAddSingleton<IRedundancyRoleView, RedundancyRoleView>();
|
||||
// posture for a deployment that runs no redundancy at all. Forwarded to the same instance
|
||||
// AddOtOpcUaRuntime registered for IDriverPrimaryRoleSource: one object, two verdicts.
|
||||
services.TryAddSingleton<RedundancyRoleView>();
|
||||
services.TryAddSingleton<IRedundancyRoleView>(sp => sp.GetRequiredService<RedundancyRoleView>());
|
||||
|
||||
// THE GATE ONLY APPLIES TO A REPLICATED QUEUE. Standing down is only ever safe because some
|
||||
// other node holds the same rows and will send them instead — and the only node that does is
|
||||
@@ -499,7 +517,11 @@ public static class ServiceCollectionExtensions
|
||||
// Phase 5: the node-local live-telemetry hub (registered in AddOtOpcUaRuntime). The host
|
||||
// emits its Primary-gated native-alarm transitions into it and threads it into the
|
||||
// VirtualTag + ScriptedAlarm hosts it spawns.
|
||||
telemetryHub: resolver.GetService<ITelemetryLocalHub>()),
|
||||
telemetryHub: resolver.GetService<ITelemetryLocalHub>(),
|
||||
// Gitea #511: where this actor publishes its Primary DATA-PLANE verdict for drivers
|
||||
// that claim an exclusive external identity. Registered unconditionally by
|
||||
// AddOtOpcUaRuntime, unlike redundancyRoleView above — see the comment there.
|
||||
primaryRoleSource: resolver.GetService<IDriverPrimaryRoleSource>()),
|
||||
DriverHostActorName);
|
||||
registry.Register<DriverHostActorKey>(driverHost);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user