Merge review R1 fixes: removed driver's resilience tracker released; SDK timed-unshelve no-op (engine owns expiry); commented re-ack stored as AddComment; native-ack Primary verdict published from start; drain gate SharesRowsWithPeer; malformed cache bytes don't adopt the revision; HostAbortedException passthrough; ClusterRoleInfo seed filtered
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -93,6 +93,16 @@ Two postures are deliberate:
|
|||||||
- **Unset (the default) means drain.** So does an unpublished view. A deployment
|
- **Unset (the default) means drain.** So does an unpublished view. A deployment
|
||||||
that runs no redundancy never publishes a role, and defaulting closed would
|
that runs no redundancy never publishes a role, and defaulting closed would
|
||||||
silently stop its alarm history forever.
|
silently stop its alarm history forever.
|
||||||
|
- **Only a shared queue is gated.** Standing down is safe only because a peer
|
||||||
|
holds the same rows, so the gate applies only when
|
||||||
|
`LocalDbReplicationPolicy.SharesRowsWithPeer` holds:
|
||||||
|
`LocalDb:Replication:ApiKey` **AND** (`LocalDb:Replication:PeerAddress` **OR**
|
||||||
|
`LocalDb:SyncListenPort > 0`) — an authenticated sync path in either
|
||||||
|
direction. Any other node always drains its own queue. This is deliberately
|
||||||
|
stricter than the table-registration predicate (`PeerAddress` OR `ApiKey`):
|
||||||
|
a key with no dial and no listener (e.g. a fleet-wide secret on an unpaired
|
||||||
|
node) captures changes but shares them with nobody, and gating it would park
|
||||||
|
its events behind a Primary that does not hold them (review R1 m3).
|
||||||
- **A gate that throws means "not now", never permission.** Draining on a gate
|
- **A gate that throws means "not now", never permission.** Draining on a gate
|
||||||
fault would put a pair into dual delivery exactly when its redundancy signal is
|
fault would put a pair into dual delivery exactly when its redundancy signal is
|
||||||
sick.
|
sick.
|
||||||
|
|||||||
+28
-14
@@ -350,15 +350,16 @@ is client-visible.
|
|||||||
> (ScadaBridge included) that acks against the Secondary now gets a Bad result
|
> (ScadaBridge included) that acks against the Secondary now gets a Bad result
|
||||||
> instead of a Good that later reverts — so ack against the Primary (the higher
|
> instead of a Good that later reverts — so ack against the Primary (the higher
|
||||||
> ServiceLevel). Scripted conditions are **not** gated: their engine runs on
|
> ServiceLevel). Scripted conditions are **not** gated: their engine runs on
|
||||||
> both nodes, so an ack on the Secondary is real. Residual: the published
|
> both nodes, so an ack on the Secondary is real. The published verdict
|
||||||
> verdict changes only on a redundancy snapshot, so in the boot window of a
|
> mirrors `DriverHostActor`'s live check from the moment the host starts, not
|
||||||
> multi-node cluster (no snapshot yet) the node still accepts, while
|
> only from the first redundancy snapshot: the host publishes it once at start
|
||||||
> `DriverHostActor`'s live check may drop the route — the old flap, limited to
|
> and again whenever cluster membership changes, so in the boot window of a
|
||||||
> that window.
|
> pair (role still unknown, a driver peer Up) the gate already denies, exactly
|
||||||
|
> as the route does (review R1 m2 — previously the view sat on its single-node
|
||||||
|
> "allow" seed until the first snapshot, reopening the flap for that window).
|
||||||
|
|
||||||
> **Best-effort during reconnect.** If a native ack arrives while the owning
|
> **Best-effort during reconnect.** If a native ack arrives while the owning
|
||||||
> driver is `Connecting`/`Reconnecting` (or, in the boot window above, on a
|
> driver is `Connecting`/`Reconnecting`, it is dropped with a debug log and **not**
|
||||||
> node whose role is not yet known), it is dropped with a debug log and **not**
|
|
||||||
> retried upstream — the OPC UA Part 9 `AlarmConditionState` was already
|
> retried upstream — the OPC UA Part 9 `AlarmConditionState` was already
|
||||||
> committed locally at step 1, so the rare outcome is "acked locally but not
|
> committed locally at step 1, so the rare outcome is "acked locally but not
|
||||||
> propagated to AVEVA." Re-issue the ack once the driver is reconnected if
|
> propagated to AVEVA." Re-issue the ack once the driver is reconnected if
|
||||||
@@ -408,8 +409,10 @@ On allow, the handler publishes a `Commons.OpcUa.AlarmCommand` onto the
|
|||||||
action is a settable `Action<AlarmCommand>` injected at boot by the
|
action is a settable `Action<AlarmCommand>` injected at boot by the
|
||||||
hosted service.
|
hosted service.
|
||||||
|
|
||||||
`OnTimedUnshelve` (the SDK's automatic unshelve timer) bypasses the
|
`OnTimedUnshelve` (the SDK's automatic unshelve timer) is a **no-op** — it
|
||||||
operator gate — it is system-initiated.
|
routes nothing (review R1 M2). The engine owns timed-shelve expiry (its 5 s
|
||||||
|
shelving check records `AutoUnshelve` by `system`), and its re-projection
|
||||||
|
unshelves the node; see §"`UnshelveTime` is the real remaining time" below.
|
||||||
|
|
||||||
### One condition event per method call (Gitea #587)
|
### One condition event per method call (Gitea #587)
|
||||||
|
|
||||||
@@ -448,7 +451,14 @@ reports nothing.
|
|||||||
AddComment always reports (the comment is the change), and so does a re-ack
|
AddComment always reports (the comment is the change), and so does a re-ack
|
||||||
**carrying a comment** (Gitea #597): the SDK writes that comment to the node,
|
**carrying a comment** (Gitea #597): the SDK writes that comment to the node,
|
||||||
so it is reported once, with Acked/Confirmed unchanged. Before #597 the
|
so it is reported once, with Acked/Confirmed unchanged. Before #597 the
|
||||||
comment landed on the node but no subscriber ever saw it.
|
comment landed on the node but no subscriber ever saw it. For a **scripted**
|
||||||
|
condition the comment is also durable (review R1 m1): the routed Acknowledge
|
||||||
|
reaches an engine that is already acknowledged, and
|
||||||
|
`Part9StateMachine.ApplyAcknowledge` records a commented re-ack exactly as an
|
||||||
|
AddComment (kind `AddComment`, the caller as user) — persisted and replicated
|
||||||
|
with the condition state, the original ack's `LastAck*` fields untouched. An
|
||||||
|
uncommented re-ack is still a no-op in the engine. The same applies to a
|
||||||
|
commented re-ack from the AdminUI `/alerts` page.
|
||||||
- **A native re-ack is still routed to the driver** (deliberate, #597). The
|
- **A native re-ack is still routed to the driver** (deliberate, #597). The
|
||||||
route is fire-and-forget, so a node showing Acked does not prove the
|
route is fire-and-forget, so a node showing Acked does not prove the
|
||||||
upstream system acknowledged — an ack dropped during a reconnect leaves
|
upstream system acknowledged — an ack dropped during a reconnect leaves
|
||||||
@@ -471,10 +481,14 @@ reports nothing.
|
|||||||
engine's projection carries its expiry (`AlarmConditionSnapshot.UnshelveAtUtc`),
|
engine's projection carries its expiry (`AlarmConditionSnapshot.UnshelveAtUtc`),
|
||||||
so `ShelvingState/UnshelveTime` counts down to the engine's expiry whichever
|
so `ShelvingState/UnshelveTime` counts down to the engine's expiry whichever
|
||||||
path shelved it (the AdminUI `/alerts` timed shelve included). This arms the
|
path shelved it (the AdminUI `/alerts` timed shelve included). This arms the
|
||||||
SDK's own unshelve timer at the real instant; it changes no node state and
|
SDK's own unshelve timer at the real instant, and `OnTimedUnshelve` — the
|
||||||
only routes an `Unshelve` (user `system`) back to the engine — a no-op if the
|
timer's callback — is deliberately a **no-op** (review R1 M2): it used to
|
||||||
engine's 5 s expiry sweep got there first, otherwise it simply lands the
|
route an operator-style `Unshelve` by `system`, which, firing before the
|
||||||
expiry on time.
|
engine's 5 s expiry sweep, pre-empted the engine's `AutoUnshelve` on every
|
||||||
|
expiry, from both pair nodes, rewriting the audit trail as a manual unshelve.
|
||||||
|
The SDK's timer changes no shelving state itself, so the node stays
|
||||||
|
TimedShelved (with `UnshelveTime` at zero) for up to one sweep after expiry,
|
||||||
|
until the engine's re-projection reports the one expiry event.
|
||||||
- **Accepted, not fixed — the SDK's `EventsMonitored` unit for late conditions**
|
- **Accepted, not fixed — the SDK's `EventsMonitored` unit for late conditions**
|
||||||
(Gitea #597 item 6, from the #586 review). The SDK's `AreEventsMonitored`
|
(Gitea #597 item 6, from the #586 review). The SDK's `AreEventsMonitored`
|
||||||
counter never reaches a condition materialised under a folder that already had
|
counter never reaches a condition materialised under a folder that already had
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ See the [ClusterClient→gRPC migration design](plans/2026-08-06-clusterclient-t
|
|||||||
| `GrpcListenPort` | int | `0` | Dedicated **h2c-only** Kestrel port for the artifact gRPC service. `0` = disabled (nothing bound). Must differ from the main HTTP port and from `LocalDb:SyncListenPort` (h2c cannot share a cleartext HTTP/1 port). Mapped only on admin-role nodes. |
|
| `GrpcListenPort` | int | `0` | Dedicated **h2c-only** Kestrel port for the artifact gRPC service. `0` = disabled (nothing bound). Must differ from the main HTTP port and from `LocalDb:SyncListenPort` (h2c cannot share a cleartext HTTP/1 port). Mapped only on admin-role nodes. |
|
||||||
| `ApiKey` | string | `""` | Shared bearer key the serve-side interceptor checks (constant-time, **fail-closed**: an unset key rejects every call). **Supply via env `ConfigServe__ApiKey` — never commit.** |
|
| `ApiKey` | string | `""` | Shared bearer key the serve-side interceptor checks (constant-time, **fail-closed**: an unset key rejects every call). **Supply via env `ConfigServe__ApiKey` — never commit.** |
|
||||||
|
|
||||||
**It is a dark switch; the serve side is always wired.** Central maps the `DeploymentArtifactService` behind the `ConfigServeAuthInterceptor` (path-scoped, `FixedTimeEquals`, fail-closed) whenever `ConfigServe:GrpcListenPort > 0`, in both modes — so flipping a node to `FetchAndCache` is a config change, not a redeploy. A `FetchAndCache` node that cannot fetch (all endpoints down, `NotFound`, SHA mismatch) **fails the apply and keeps last-known-good** — a null fetch is "no answer," never an empty configuration (the [#485](plans/2026-07-15-v3-batch4-address-space-plan.md) contract). At boot it restores served state from the LocalDb pointer with no SQL read; an empty or unreadable cache lands Steady-with-no-revision (the first dispatch fetches), never Stale.
|
**It is a dark switch; the serve side is always wired.** Central maps the `DeploymentArtifactService` behind the `ConfigServeAuthInterceptor` (path-scoped, `FixedTimeEquals`, fail-closed) whenever `ConfigServe:GrpcListenPort > 0`, in both modes — so flipping a node to `FetchAndCache` is a config change, not a redeploy. A `FetchAndCache` node that cannot fetch (all endpoints down, `NotFound`, SHA mismatch) **fails the apply and keeps last-known-good** — a null fetch is "no answer," never an empty configuration (the [#485](plans/2026-07-15-v3-batch4-address-space-plan.md) contract). At boot it restores served state from the LocalDb pointer with no SQL read; an empty or unreadable cache — including a cached row whose bytes are malformed (review R1 m4) — lands Steady-with-no-revision (the first dispatch fetches, even of the same revision), never Stale.
|
||||||
|
|
||||||
**The listener is dedicated h2c and coexists with the LocalDb-sync listener.** A fused admin+driver node with **both** `ConfigServe:GrpcListenPort` and `LocalDb:SyncListenPort` set re-binds its existing HTTP surface exactly once and adds both dedicated HTTP/2-only ports on top — see the same Kestrel-takeover caveat under [`LocalDb`](#localdb-pair-local-config-cache--driver-role-only).
|
**The listener is dedicated h2c and coexists with the LocalDb-sync listener.** A fused admin+driver node with **both** `ConfigServe:GrpcListenPort` and `LocalDb:SyncListenPort` set re-binds its existing HTTP surface exactly once and adds both dedicated HTTP/2-only ports on top — see the same Kestrel-takeover caveat under [`LocalDb`](#localdb-pair-local-config-cache--driver-role-only).
|
||||||
|
|
||||||
@@ -431,7 +431,10 @@ replication stays inert until the sync port + peer are set.
|
|||||||
`PeerAddress` **OR** `ApiKey` non-blank — the OR because only the *dialling* half sets `PeerAddress`, so
|
`PeerAddress` **OR** `ApiKey` non-blank — the OR because only the *dialling* half sets `PeerAddress`, so
|
||||||
keying on it alone would strip change capture from every passive node and leave the pair converging in one
|
keying on it alone would strip change capture from every passive node and leave the pair converging in one
|
||||||
direction only. `SyncListenPort` is deliberately **not** part of it (a port with no `ApiKey` is fail-closed
|
direction only. `SyncListenPort` is deliberately **not** part of it (a port with no `ApiKey` is fail-closed
|
||||||
and syncs with nobody). A configured node registers all four tables with `baselineExistingRows: true`, so
|
and syncs with nobody). (The alarm-historian **drain gate** uses a stricter predicate on purpose —
|
||||||
|
`ApiKey` AND (`PeerAddress` OR `SyncListenPort > 0`), `LocalDbReplicationPolicy.SharesRowsWithPeer` — because
|
||||||
|
gating a node that shares its queue with nobody strands its alarm history; see `docs/AlarmHistorian.md`
|
||||||
|
§"The drain gate".) A configured node registers all four tables with `baselineExistingRows: true`, so
|
||||||
turning replication on converges the rows **already in the file** and not merely subsequent writes; an
|
turning replication on converges the rows **already in the file** and not merely subsequent writes; an
|
||||||
unconfigured node **deregisters** them at boot, dropping capture triggers an earlier build installed. ⚠️ The
|
unconfigured node **deregisters** them at boot, dropping capture triggers an earlier build installed. ⚠️ The
|
||||||
sync handshake compares registered-table digests **fail-closed**, so flipping either key is a
|
sync handshake compares registered-table digests **fail-closed**, so flipping either key is a
|
||||||
|
|||||||
+6
-3
@@ -526,13 +526,13 @@ Three data-plane surfaces are **Primary-gated** so a warm standby never touches
|
|||||||
|
|
||||||
Before archreview 03/S4 the unknown-role case defaulted to *allow* unconditionally, opening a dual-primary window: a freshly-booted secondary on a multi-node cluster serviced shared-device writes and emitted duplicate fleet-wide alerts for up to the ~10 s `RedundancyStateActor` heartbeat (and indefinitely if the snapshot's NodeId never matched this node). Membership is authoritative much earlier than DPS delivery, so an unknown role on a **multi-driver** cluster now default-**denies**. The driver member count is read live from `Cluster.State.Members` (Up members with the `driver` role); a non-cluster ActorRefProvider yields 0 ⇒ single-node posture. If a snapshot arrives that never mentions this node while a driver peer exists (the 03/S5 identity-mismatch shape), `DriverHostActor` logs a one-time Warning so the silent mismatch is diagnosable.
|
Before archreview 03/S4 the unknown-role case defaulted to *allow* unconditionally, opening a dual-primary window: a freshly-booted secondary on a multi-node cluster serviced shared-device writes and emitted duplicate fleet-wide alerts for up to the ~10 s `RedundancyStateActor` heartbeat (and indefinitely if the snapshot's NodeId never matched this node). Membership is authoritative much earlier than DPS delivery, so an unknown role on a **multi-driver** cluster now default-**denies**. The driver member count is read live from `Cluster.State.Members` (Up members with the `driver` role); a non-cluster ActorRefProvider yields 0 ⇒ single-node posture. If a snapshot arrives that never mentions this node while a driver peer exists (the 03/S5 identity-mismatch shape), `DriverHostActor` logs a one-time Warning so the silent mismatch is diagnosable.
|
||||||
|
|
||||||
**What a client sees during the boot window (multi-node, role unknown):** an inbound write is **rejected** (not queued — OT actuation seconds late is a surprise). The reject rides the sanctioned optimistic-write self-correction surface: the node reverts to its prior value with a transient Bad-quality blip and a Part 8 `AuditWriteUpdateEvent`; the client's synchronous write call still returned Good (exactly as any failed device write behaves since the #5 write-outcome self-correction). The rejection reason distinguishes the boot window (`"not primary (role unknown)"`) from a steady-state Secondary (`"not primary"`). Acks are dropped (Warning-logged when role-unknown). The alerts emit is skipped (the Primary peer publishes the single fleet copy). The window closes on the first delivered snapshot.
|
**What a client sees during the boot window (multi-node, role unknown):** an inbound write is **rejected** (not queued — OT actuation seconds late is a surprise). The reject rides the sanctioned optimistic-write self-correction surface: the node reverts to its prior value with a transient Bad-quality blip and a Part 8 `AuditWriteUpdateEvent`; the client's synchronous write call still returned Good (exactly as any failed device write behaves since the #5 write-outcome self-correction). The rejection reason distinguishes the boot window (`"not primary (role unknown)"`) from a steady-state Secondary (`"not primary"`). Acks are dropped (Warning-logged when role-unknown) — and since review R1 m2 a native OPC UA Acknowledge is refused up front (`BadInvalidState`) in the same window: `DriverHostActor` publishes its Primary verdict to `IDriverPrimaryRoleSource` (the native-ack gate, and the MQTT Sparkplug primary-host claim) once at start and again on every cluster membership change, not only on redundancy snapshots, so the published verdict denies as soon as a driver peer is Up rather than sitting on the single-node "allow" seed until the first snapshot. The alerts emit is skipped (the Primary peer publishes the single fleet copy). The window closes on the first delivered snapshot.
|
||||||
|
|
||||||
Under warm/hot redundancy both cluster nodes run `ScriptedAlarmHostActor` and evaluate scripted alarms, keeping each node's address space and engine state warm for instant failover. To avoid duplicate rows on `/alerts` and duplicate historian writes, only the Primary node publishes externally:
|
Under warm/hot redundancy both cluster nodes run `ScriptedAlarmHostActor` and evaluate scripted alarms, keeping each node's address space and engine state warm for instant failover. To avoid duplicate rows on `/alerts` and duplicate historian writes, only the Primary node publishes externally:
|
||||||
|
|
||||||
- **`alerts` topic emission** — `ScriptedAlarmHostActor` and `DriverHostActor.ForwardNativeAlarm` subscribe to the `redundancy-state` DPS topic and cache the local node's `RedundancyRole`, then gate the cluster `alerts` publish through `PrimaryGatePolicy` (table above). The OPC UA condition-node write and inbound ack/shelve command processing remain **ungated** on both nodes so the secondary is always ready to serve clients after a failover.
|
- **`alerts` topic emission** — `ScriptedAlarmHostActor` and `DriverHostActor.ForwardNativeAlarm` subscribe to the `redundancy-state` DPS topic and cache the local node's `RedundancyRole`, then gate the cluster `alerts` publish through `PrimaryGatePolicy` (table above). The OPC UA condition-node write and inbound ack/shelve command processing remain **ungated** on both nodes so the secondary is always ready to serve clients after a failover.
|
||||||
- **`HistorianAdapterActor` historization** — likewise Primary-gated so alarm historization is exactly-once across all alarm sources. The actor subscribes to the `alerts` DPS topic and translates each `AlarmTransitionEvent` → `AlarmHistorianEvent` before enqueuing it on the sink; scripted alarms therefore historize exactly once regardless of cluster size.
|
- **`HistorianAdapterActor` historization** — likewise Primary-gated so alarm historization is exactly-once across all alarm sources. The actor subscribes to the `alerts` DPS topic and translates each `AlarmTransitionEvent` → `AlarmHistorianEvent` before enqueuing it on the sink; scripted alarms therefore historize exactly once regardless of cluster size.
|
||||||
- **The alarm sink's DRAIN** — a second, independent gate on the same decision. The enqueue gate above is not sufficient any more: since Phase 2 the store-and-forward buffer lives in the replicated LocalDb, so the Secondary holds a full copy of the Primary's queued rows and an ungated drain there would re-deliver every event continuously. `LocalDbStoreAndForwardSink` takes a `drainGate` fed from `IRedundancyRoleView` — a singleton `DriverHostActor` publishes its `PrimaryGatePolicy` verdict to, because the drain runs on a timer and cannot receive `RedundancyStateChanged` itself. A gated-off node reports `HistorianDrainState.NotPrimary`. Delivery is **at-least-once across a failover** by design: rows the old Primary delivered just before dying may not have replicated their deletes yet. See [AlarmHistorian.md](AlarmHistorian.md).
|
- **The alarm sink's DRAIN** — a second, independent gate on the same decision. The enqueue gate above is not sufficient any more: since Phase 2 the store-and-forward buffer lives in the replicated LocalDb, so the Secondary holds a full copy of the Primary's queued rows and an ungated drain there would re-deliver every event continuously. `LocalDbStoreAndForwardSink` takes a `drainGate` fed from `IRedundancyRoleView` — a singleton `DriverHostActor` publishes its `PrimaryGatePolicy` verdict to, because the drain runs on a timer and cannot receive `RedundancyStateChanged` itself. A gated-off node reports `HistorianDrainState.NotPrimary`. The gate applies only to a node whose queue a peer actually holds — `LocalDbReplicationPolicy.SharesRowsWithPeer`: `LocalDb:Replication:ApiKey` AND (`PeerAddress` OR `LocalDb:SyncListenPort` > 0); any other node always drains (review R1 m3). Delivery is **at-least-once across a failover** by design: rows the old Primary delivered just before dying may not have replicated their deletes yet. See [AlarmHistorian.md](AlarmHistorian.md).
|
||||||
|
|
||||||
- **Continuous-historization value CAPTURE** (Gitea #537) — a third gate on the same role rule, and the
|
- **Continuous-historization value CAPTURE** (Gitea #537) — a third gate on the same role rule, and the
|
||||||
one that gates the *opposite* half of its pipeline. Both pair nodes serve the same address space and see
|
one that gates the *opposite* half of its pipeline. Both pair nodes serve the same address space and see
|
||||||
@@ -622,7 +622,10 @@ and [AlarmHistorian.md](AlarmHistorian.md). Phase 1 caches the **deployed-config
|
|||||||
artifact** (chunked). Its job is a single failure mode redundancy does not otherwise cover: a driver
|
artifact** (chunked). Its job is a single failure mode redundancy does not otherwise cover: a driver
|
||||||
node restarting into a **central-SQL-Server outage** would come up with no configuration at all. With
|
node restarting into a **central-SQL-Server outage** would come up with no configuration at all. With
|
||||||
the cache, it **boots from the last artifact it applied** instead, and logs a running-from-cache
|
the cache, it **boots from the last artifact it applied** instead, and logs a running-from-cache
|
||||||
signal.
|
signal. A cached artifact whose bytes are empty or malformed is **no answer** (#601): nothing is applied,
|
||||||
|
the node does **not** adopt that revision or claim running-from-cache, and it goes **Stale** (retrying the
|
||||||
|
ConfigDb) — so a later dispatch of that same revision really applies instead of being acked as already
|
||||||
|
current (review R1 m4). A `FetchAndCache` node lands Steady-with-no-revision instead, as for an empty cache.
|
||||||
|
|
||||||
- **What replicates.** The cache can *optionally* replicate to the node's redundant pair peer over
|
- **What replicates.** The cache can *optionally* replicate to the node's redundant pair peer over
|
||||||
the LocalDb library's gRPC sync (HLC-stamped, last-writer-wins). Two tables replicate:
|
the LocalDb library's gRPC sync (HLC-stamped, last-writer-wins). Two tables replicate:
|
||||||
|
|||||||
@@ -118,7 +118,8 @@ rationale is recorded in the engine's class doc so it is not re-proposed:
|
|||||||
- `ApplyOneShotShelve` / `ApplyTimedShelve(unshelveAtUtc)` / `ApplyUnshelve` — shelving transitions. `Timed` requires `unshelveAtUtc > nowUtc`.
|
- `ApplyOneShotShelve` / `ApplyTimedShelve(unshelveAtUtc)` / `ApplyUnshelve` — shelving transitions. `Timed` requires `unshelveAtUtc > nowUtc`.
|
||||||
- `ApplyEnable` / `ApplyDisable` — operator enable/disable. Disabled alarms ignore predicate results until re-enabled; on enable, `ActiveState` is re-derived from the next evaluation.
|
- `ApplyEnable` / `ApplyDisable` — operator enable/disable. Disabled alarms ignore predicate results until re-enabled; on enable, `ActiveState` is re-derived from the next evaluation.
|
||||||
- `ApplyAddComment(text)` — append-only audit entry, no state change.
|
- `ApplyAddComment(text)` — append-only audit entry, no state change.
|
||||||
- `ApplyShelvingCheck(nowUtc)` — called by the 5s timer; promotes expired `Timed` shelving to `Unshelved` with a `system / AutoUnshelve` audit entry.
|
- `ApplyShelvingCheck(nowUtc)` — called by the 5s timer; promotes expired `Timed` shelving to `Unshelved` with a `system / AutoUnshelve` audit entry. The sole owner of timed-shelve expiry — the OPC UA node's own SDK timer does nothing.
|
||||||
|
- `ApplyAcknowledge` on an already-acknowledged alarm is a no-op **unless it carries a comment**, which is recorded as an `AddComment` (emission `CommentAdded`; `LastAck*` untouched) — the OPC UA node reports a commented re-ack, so the system of record must hold the comment too.
|
||||||
|
|
||||||
Two invariants the machine enforces:
|
Two invariants the machine enforces:
|
||||||
|
|
||||||
@@ -354,11 +355,11 @@ On allow, the handler publishes a `Commons.OpcUa.AlarmCommand` (containing comma
|
|||||||
- **Scripted conditions** — all eight Part 9 operations (including Enable/Disable) route through `AlarmCommandRouter` onto the `alarm-commands` topic, which `ScriptedAlarmHostActor` dispatches to the engine (`EnableAsync` / `DisableAsync` / `AcknowledgeAsync` / …). On enable, `ActiveState` is re-derived from the next predicate evaluation.
|
- **Scripted conditions** — all eight Part 9 operations (including Enable/Disable) route through `AlarmCommandRouter` onto the `alarm-commands` topic, which `ScriptedAlarmHostActor` dispatches to the engine (`EnableAsync` / `DisableAsync` / `AcknowledgeAsync` / …). On enable, `ActiveState` is re-derived from the next predicate evaluation.
|
||||||
- **Native (driver-fed) conditions** — `OnAcknowledge` branches to `NativeAlarmAckRouter` and routes the ack to the owning driver rather than the scripted engine (see §[Native-alarm OPC UA operator operations](#native-alarm-opc-ua-operator-operations) above). `OnEnableDisable`, `OnConfirm` and `OnShelve` return `BadNotSupported` immediately — a native condition supports Acknowledge and AddComment only (no engine-side surface, no upstream path, and the driver projection owns its Confirmed/Shelving; Gitea #587 review).
|
- **Native (driver-fed) conditions** — `OnAcknowledge` branches to `NativeAlarmAckRouter` and routes the ack to the owning driver rather than the scripted engine (see §[Native-alarm OPC UA operator operations](#native-alarm-opc-ua-operator-operations) above). `OnEnableDisable`, `OnConfirm` and `OnShelve` return `BadNotSupported` immediately — a native condition supports Acknowledge and AddComment only (no engine-side surface, no upstream path, and the driver projection owns its Confirmed/Shelving; Gitea #587 review).
|
||||||
|
|
||||||
`OnTimedUnshelve` (the SDK's internal auto-unshelve timer) bypasses the client gate — it is system-initiated and not subject to operator role checks.
|
`OnTimedUnshelve` (the SDK's internal auto-unshelve timer, armed for the real remaining time since Gitea #597) is a **no-op**: the engine owns timed-shelve expiry via `ApplyShelvingCheck` (`system / AutoUnshelve`), and routing an `Unshelve` from the timer pre-empted that on every expiry as an operator-style unshelve from both pair nodes (review R1 M2).
|
||||||
|
|
||||||
### Delta-gate de-duplication
|
### Delta-gate de-duplication
|
||||||
|
|
||||||
`WriteAlarmCondition` fires a Part 9 condition event only when the incoming state differs from the node's current live state. For an OPC UA method call (Acknowledge, Confirm, AddComment, the shelve methods, Enable/Disable) the SDK applies the new state to the node, and the node manager then reports **the one** condition event for it from the method path (Gitea #587 — the SDK reports none of its own, since `AutoReportStateChanges` stays false; the shelve methods' state is applied by the node manager, because the SDK's never applies it; a call that changed nothing — e.g. an uncommented re-ack — reports nothing; a re-ack carrying a comment reports that comment once, Gitea #597). Native (driver-fed) conditions support only Acknowledge and AddComment; their Confirm, shelve and Enable/Disable methods return `BadNotSupported`, and their Acknowledge is refused with `BadInvalidState` on a node that is not the redundancy Primary (Gitea #597). A timed shelve's `UnshelveTime` counts down to the engine's expiry (the projection carries `UnshelveAtUtc`). The engine's later re-projection of the same transition finds the node already in that state and is suppressed, so each accepted call yields exactly one event. See `docs/AlarmTracking.md` §"One condition event per method call".
|
`WriteAlarmCondition` fires a Part 9 condition event only when the incoming state differs from the node's current live state. For an OPC UA method call (Acknowledge, Confirm, AddComment, the shelve methods, Enable/Disable) the SDK applies the new state to the node, and the node manager then reports **the one** condition event for it from the method path (Gitea #587 — the SDK reports none of its own, since `AutoReportStateChanges` stays false; the shelve methods' state is applied by the node manager, because the SDK's never applies it; a call that changed nothing — e.g. an uncommented re-ack — reports nothing; a re-ack carrying a comment reports that comment once, Gitea #597, and the engine records it as an `AddComment` so it is persisted and replicated, review R1 m1). Native (driver-fed) conditions support only Acknowledge and AddComment; their Confirm, shelve and Enable/Disable methods return `BadNotSupported`, and their Acknowledge is refused with `BadInvalidState` on a node that is not the redundancy Primary (Gitea #597). A timed shelve's `UnshelveTime` counts down to the engine's expiry (the projection carries `UnshelveAtUtc`). The engine's later re-projection of the same transition finds the node already in that state and is suppressed, so each accepted call yields exactly one event. See `docs/AlarmTracking.md` §"One condition event per method call".
|
||||||
|
|
||||||
### AdminUI path
|
### AdminUI path
|
||||||
|
|
||||||
|
|||||||
@@ -255,6 +255,11 @@ driver node**:
|
|||||||
reconnect loses whatever transitions happened while disconnected, same as a DPS resubscribe would.
|
reconnect loses whatever transitions happened while disconnected, same as a DPS resubscribe would.
|
||||||
The cache is evicted per instance (`ITelemetryLocalHub.Evict`) when `DriverHostActor.StopChild` removes a
|
The cache is evicted per instance (`ITelemetryLocalHub.Evict`) when `DriverHostActor.StopChild` removes a
|
||||||
driver from the plan (#609), so a decommissioned instance's last state is not replayed to new subscribers.
|
driver from the plan (#609), so a decommissioned instance's last state is not replayed to new subscribers.
|
||||||
|
`StopChild` first releases the instance's resilience state (`IDriverCapabilityInvokerFactory.Release` →
|
||||||
|
pipeline-builder + `DriverResilienceStatusTracker.Invalidate`): `DriverResilienceStatusPublisherService`
|
||||||
|
re-emits the whole tracker every 5 s, so evicting only the hub was undone within one tick (review R1 M1).
|
||||||
|
Residual: a Health emit already in flight from the stopping child can land after the eviction and
|
||||||
|
re-cache that one snapshot — a narrow race, not a recurring replay.
|
||||||
- **Connection indicator** — the `/alerts` and `/script-log` live/disconnected pill is driven by
|
- **Connection indicator** — the `/alerts` and `/script-log` live/disconnected pill is driven by
|
||||||
**aggregate** stream health across all dialers: connected when **at least one** node stream is
|
**aggregate** stream health across all dialers: connected when **at least one** node stream is
|
||||||
up, disconnected when all are down. This matches today's DPS `SubscribeAck`/`PostStop` pill
|
up, disconnected when all are down. This matches today's DPS `SubscribeAck`/`PostStop` pill
|
||||||
|
|||||||
@@ -92,7 +92,9 @@ Replication has two knobs, both under `LocalDb`:
|
|||||||
> 2026-08-18 `LocalDbSetup.OnReady` installs the change-capture triggers only when the node is
|
> 2026-08-18 `LocalDbSetup.OnReady` installs the change-capture triggers only when the node is
|
||||||
> configured to replicate — the predicate is **`PeerAddress` OR `ApiKey` non-blank** (the OR because
|
> configured to replicate — the predicate is **`PeerAddress` OR `ApiKey` non-blank** (the OR because
|
||||||
> the passive node has no `PeerAddress`; `SyncListenPort` is deliberately not part of it, since a
|
> the passive node has no `PeerAddress`; `SyncListenPort` is deliberately not part of it, since a
|
||||||
> port with no key is fail-closed and syncs with nobody). Consequences at enablement time:
|
> port with no key is fail-closed and syncs with nobody). The alarm-historian drain gate uses the
|
||||||
|
> stricter `ApiKey` AND (`PeerAddress` OR `SyncListenPort > 0`) — so set the key **and** the port/peer
|
||||||
|
> together, or the node keeps draining its own queue. Consequences at enablement time:
|
||||||
>
|
>
|
||||||
> - A configured node registers with **`baselineExistingRows: true`**, so the rows **already in the
|
> - A configured node registers with **`baselineExistingRows: true`**, so the rows **already in the
|
||||||
> file** are seeded into the row-version ledger at the LWW floor (HLC `0`) and actually travel.
|
> file** are seeded into the row-version ledger at the LWW floor (HLC `0`) and actually travel.
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SeedFromCurrentState();
|
SeedFrom(_cluster.State);
|
||||||
_subscriber = system.ActorOf(Props.Create(() => new SubscriberActor(this)), "clusterroleinfo-subscriber");
|
_subscriber = system.ActorOf(Props.Create(() => new SubscriberActor(this)), "clusterroleinfo-subscriber");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,12 +121,19 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
|
|||||||
/// <summary>Occurs when the leader for a role changes.</summary>
|
/// <summary>Occurs when the leader for a role changes.</summary>
|
||||||
public event EventHandler<RoleLeaderChangedEventArgs>? RoleLeaderChanged;
|
public event EventHandler<RoleLeaderChangedEventArgs>? RoleLeaderChanged;
|
||||||
|
|
||||||
private void SeedFromCurrentState()
|
/// <summary>Seeds the member/leader maps from a cluster-state snapshot (the constructor passes the live
|
||||||
|
/// <c>Cluster.State</c>; exposed for tests).</summary>
|
||||||
|
/// <param name="snapshot">The cluster-state snapshot.</param>
|
||||||
|
internal void SeedFrom(ClusterEvent.CurrentClusterState snapshot)
|
||||||
{
|
{
|
||||||
var snapshot = _cluster.State;
|
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
foreach (var member in snapshot.Members)
|
// Cluster-002 (review R1 m6): 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
|
||||||
|
// Joining member is added by its MemberUp.
|
||||||
|
foreach (var member in snapshot.Members.Where(m => m.Status is MemberStatus.Up or MemberStatus.WeaklyUp))
|
||||||
foreach (var role in member.Roles)
|
foreach (var role in member.Roles)
|
||||||
{
|
{
|
||||||
if (!_membersByRole.TryGetValue(role, out var set))
|
if (!_membersByRole.TryGetValue(role, out var set))
|
||||||
|
|||||||
@@ -28,6 +28,16 @@ public interface IDriverCapabilityInvokerFactory
|
|||||||
/// </param>
|
/// </param>
|
||||||
/// <returns>A per-instance <see cref="IDriverCapabilityInvoker"/>.</returns>
|
/// <returns>A per-instance <see cref="IDriverCapabilityInvoker"/>.</returns>
|
||||||
IDriverCapabilityInvoker Create(string driverInstanceId, string driverType, string? resilienceConfigJson = null);
|
IDriverCapabilityInvoker Create(string driverInstanceId, string driverType, string? resilienceConfigJson = null);
|
||||||
|
|
||||||
|
/// <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
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="driverInstanceId">The driver instance that was removed.</param>
|
||||||
|
void Release(string driverInstanceId) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -105,7 +105,17 @@ public static class Part9StateMachine
|
|||||||
throw new ArgumentException("User identity required for audit.", nameof(user));
|
throw new ArgumentException("User identity required for audit.", nameof(user));
|
||||||
|
|
||||||
if (current.Acked == AlarmAckedState.Acknowledged)
|
if (current.Acked == AlarmAckedState.Acknowledged)
|
||||||
return TransitionResult.NoOp(current, "already 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
|
||||||
|
// 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
|
||||||
|
// is still a no-op.
|
||||||
|
return string.IsNullOrWhiteSpace(comment)
|
||||||
|
? TransitionResult.NoOp(current, "already acknowledged")
|
||||||
|
: ApplyAddComment(current, user, comment, nowUtc);
|
||||||
|
}
|
||||||
|
|
||||||
var audit = AppendComment(current.Comments, nowUtc, user, "Acknowledge", comment);
|
var audit = AppendComment(current.Comments, nowUtc, user, "Acknowledge", comment);
|
||||||
var next = current with
|
var next = current with
|
||||||
|
|||||||
@@ -90,4 +90,16 @@ public sealed class DriverCapabilityInvokerFactory : IDriverCapabilityInvokerFac
|
|||||||
statusTracker: _statusTracker,
|
statusTracker: _statusTracker,
|
||||||
optionsGeneration: generation);
|
optionsGeneration: generation);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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.
|
||||||
|
/// </remarks>
|
||||||
|
public void Release(string driverInstanceId)
|
||||||
|
{
|
||||||
|
_builder.Invalidate(driverInstanceId);
|
||||||
|
_statusTracker.Invalidate(driverInstanceId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ public static class LocalDbRegistration
|
|||||||
/// Configuration key carrying the dedicated h2c sync listener's port. Zero or absent means
|
/// Configuration key carrying the dedicated h2c sync listener's port. Zero or absent means
|
||||||
/// no listener is bound and the passive sync endpoint is not mapped.
|
/// no listener is bound and the passive sync endpoint is not mapped.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const string SyncListenPortKey = "LocalDb:SyncListenPort";
|
public const string SyncListenPortKey = Runtime.LocalDbReplicationPolicy.SyncListenPortKey;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The port the dedicated cleartext-HTTP/2 sync listener should bind, or <c>0</c> when the
|
/// The port the dedicated cleartext-HTTP/2 sync listener should bind, or <c>0</c> when the
|
||||||
|
|||||||
@@ -170,9 +170,9 @@ public static class LocalDbSetup
|
|||||||
/// <returns><c>true</c> when this node is configured to replicate.</returns>
|
/// <returns><c>true</c> when this node is configured to replicate.</returns>
|
||||||
public static bool ReplicationIsConfigured(IConfiguration configuration)
|
public static bool ReplicationIsConfigured(IConfiguration configuration)
|
||||||
{
|
{
|
||||||
// Shared with the alarm-historian drain gate (#603): one predicate, so the nodes that capture
|
// Defined beside the alarm-historian drain gate's stricter predicate (#603, review R1 m3): every node
|
||||||
// changes and the nodes that stand down for a peer can never disagree.
|
// the gate treats as sharing rows also captures, so the two never disagree in the harmful direction.
|
||||||
return Runtime.LocalDbReplicationPolicy.IsConfigured(configuration);
|
return Runtime.LocalDbReplicationPolicy.CapturesChanges(configuration);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Opts every replicated table into change capture, baselining what is already there.</summary>
|
/// <summary>Opts every replicated table into change capture, baselining what is already there.</summary>
|
||||||
|
|||||||
@@ -749,7 +749,10 @@ if (LogPipelineDiagnostics.CheckFileSinkGrowth(app.Configuration,
|
|||||||
|
|
||||||
await app.RunAsync();
|
await app.RunAsync();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
// HostAbortedException is how design-time tooling (EF `dotnet ef … --startup-project`, OpenAPI generators —
|
||||||
|
// HostFactoryResolver) stops Program once it has the built host. It is not a failure: letting it reach the
|
||||||
|
// Fatal + Environment.Exit(1) below would kill the tool's own process (#618, review R1 m5).
|
||||||
|
catch (Exception ex) when (ex is not HostAbortedException)
|
||||||
{
|
{
|
||||||
// Log.Logger is still the silent bootstrap logger when the failure happened before AddZbSerilog
|
// Log.Logger is still the silent bootstrap logger when the failure happened before AddZbSerilog
|
||||||
// ran (e.g. EffectiveRoles.Resolve), so ALSO write to stderr — the one channel that always exists.
|
// ran (e.g. EffectiveRoles.Resolve), so ALSO write to stderr — the one channel that always exists.
|
||||||
|
|||||||
@@ -874,8 +874,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
// (shelved, oneShot) flag pair. Timed shelving's expiry is owned by the engine; the time
|
// (shelved, oneShot) flag pair. Timed shelving's expiry is owned by the engine; the time
|
||||||
// passed here is the engine's remaining time, so the node's UnshelveTime counts down to the
|
// passed here is the engine's remaining time, so the node's UnshelveTime counts down to the
|
||||||
// real expiry (Gitea #597 — a 0 here reads as MaxTimeShelved, ~24.8 days). The SDK timer it
|
// real expiry (Gitea #597 — a 0 here reads as MaxTimeShelved, ~24.8 days). The SDK timer it
|
||||||
// arms only routes an Unshelve back to the engine (OnTimedUnshelve), a no-op once the
|
// arms does nothing (OnTimedUnshelve is a no-op): the engine's own expiry re-projects.
|
||||||
// engine's own expiry has run.
|
|
||||||
condition.SetShelvingState(
|
condition.SetShelvingState(
|
||||||
SystemContext,
|
SystemContext,
|
||||||
shelved: state.Shelving != AlarmShelvingKind.Unshelved,
|
shelved: state.Shelving != AlarmShelvingKind.Unshelved,
|
||||||
@@ -1375,25 +1374,17 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
: ("TimedShelve", DateTime.UtcNow + TimeSpan.FromMilliseconds(shelvingTime));
|
: ("TimedShelve", DateTime.UtcNow + TimeSpan.FromMilliseconds(shelvingTime));
|
||||||
return HandleAlarmCommand(context, condition, operation, comment: null, unshelveAt);
|
return HandleAlarmCommand(context, condition, operation, comment: null, unshelveAt);
|
||||||
};
|
};
|
||||||
// The auto-unshelve timer callback is SDK-initiated (the TimedShelve duration expired); the SDK
|
// The SDK's auto-unshelve timer (armed by SetShelvingState for the real remaining time since Gitea
|
||||||
// fires it with the node manager's system context — there is NO session and NO user identity.
|
// #597) fires with a session-less system context. It is deliberately a NO-OP: the engine owns
|
||||||
// Routing through HandleAlarmCommand would hit the AlarmAck gate and return BadUserAccessDenied,
|
// timed-shelve expiry (ScriptedAlarmEngine's shelving check → Part9StateMachine.ApplyShelvingCheck,
|
||||||
// leaving the alarm permanently shelved. Instead, bypass the client gate, extract the AlarmId the
|
// recorded as "AutoUnshelve" by "system" with its "Timed shelving expired" comment), and its
|
||||||
// same way HandleAlarmCommand does, and route an Unshelve command so the engine clears its shelve
|
// re-projection is what unshelves this node. Routing an operator-style "Unshelve" from here used to
|
||||||
// state. The manual-client Unshelve path goes through OnShelve(shelving:false) and stays gated.
|
// pre-empt that check on every expiry — from BOTH pair nodes, rewriting the audit trail as a manual
|
||||||
alarm.OnTimedUnshelve = (context, condition) =>
|
// unshelve — and a timer callback already queued when a re-shelve disposed it could unshelve the
|
||||||
{
|
// fresh shelve. The SDK's own OnTimerExpired changes no shelving state itself (it only refreshes
|
||||||
var alarmId = condition.NodeId.Identifier?.ToString() ?? string.Empty;
|
// UnshelveTime after this callback), so returning without routing leaves the node shelved until
|
||||||
// User MUST be non-empty: the engine's Part9StateMachine.ApplyUnshelve rejects a
|
// the engine's expiry lands. Native conditions are never timed-shelved (BadNotSupported above).
|
||||||
// null/whitespace user (ArgumentException "User required."), and that exception is swallowed
|
alarm.OnTimedUnshelve = (_, _) => ServiceResult.Good;
|
||||||
// downstream — an empty user would make the timed auto-unshelve silently no-op, so a
|
|
||||||
// TimedShelve would never auto-expire. There is no client principal on a system-timer
|
|
||||||
// unshelve, so label it the canonical engine-internal "system" user (matching the engine's
|
|
||||||
// own AutoUnshelve audit user and the codebase-wide "system" system-actor convention). The
|
|
||||||
// AlarmAck gate bypass is intentional and preserved — this is a session-less SDK timer.
|
|
||||||
AlarmCommandRouter?.Invoke(new AlarmCommand(alarmId, "Unshelve", "system", null, null));
|
|
||||||
return ServiceResult.Good;
|
|
||||||
};
|
|
||||||
|
|
||||||
// H4 — inbound Part 9 Enable/Disable (the condition type's built-in Enable/Disable methods route
|
// H4 — inbound Part 9 Enable/Disable (the condition type's built-in Enable/Disable methods route
|
||||||
// here via this delegate). The engine handles Enable/Disable for SCRIPTED alarms
|
// here via this delegate). The engine handles Enable/Disable for SCRIPTED alarms
|
||||||
@@ -1596,9 +1587,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
/// <b>Only a real change is reported.</b> The condition's gate-relevant state is read before and after
|
/// <b>Only a real change is reported.</b> The condition's gate-relevant state is read before and after
|
||||||
/// the SDK handler, and nothing is reported when it did not move. The SDK accepts acknowledging an
|
/// the SDK handler, and nothing is reported when it did not move. The SDK accepts acknowledging an
|
||||||
/// acknowledged condition and sets <c>Confirmed = false</c> again; a re-ack therefore also restores the
|
/// acknowledged condition and sets <c>Confirmed = false</c> again; a re-ack therefore also restores the
|
||||||
/// pre-call Confirmed and Retain, so a scripted node never drifts from its engine (which ignores a
|
/// pre-call Confirmed and Retain, so a scripted node never drifts from its engine (which ignores an
|
||||||
/// re-ack) and the call is silent — unless it carried a comment, which the SDK writes and which is then
|
/// uncommented re-ack) and the call is silent — unless it carried a comment, which the SDK writes and
|
||||||
/// reported once like AddComment (Gitea #597). AddComment always reports: adding a comment is itself
|
/// which is then reported once like AddComment (Gitea #597). The scripted engine records that comment
|
||||||
|
/// too (<c>Part9StateMachine.ApplyAcknowledge</c> treats a commented re-ack as AddComment), so the
|
||||||
|
/// reported comment is persisted and replicated, not node-local. AddComment always reports: adding a comment is itself
|
||||||
/// the change. A NATIVE re-ack is still routed to the driver: the route is fire-and-forget, so a node
|
/// the change. A NATIVE re-ack is still routed to the driver: the route is fire-and-forget, so a node
|
||||||
/// showing Acked does not prove the upstream system acknowledged, and re-acking is the documented retry.
|
/// showing Acked does not prove the upstream system acknowledged, and re-acking is the documented retry.
|
||||||
/// </para>
|
/// </para>
|
||||||
@@ -1635,7 +1628,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
// The requested duration, so UnshelveTime counts down to the real expiry (Gitea #597 — 0 reads as
|
// The requested duration, so UnshelveTime counts down to the real expiry (Gitea #597 — 0 reads as
|
||||||
// MaxTimeShelved, ~24.8 days). The scripted engine still owns the expiry and re-projects Unshelved
|
// MaxTimeShelved, ~24.8 days). The scripted engine still owns the expiry and re-projects Unshelved
|
||||||
// when it passes (with its own remaining time meanwhile — WriteAlarmCondition); the SDK timer this
|
// when it passes (with its own remaining time meanwhile — WriteAlarmCondition); the SDK timer this
|
||||||
// arms only routes an Unshelve back to it (OnTimedUnshelve), a no-op once the engine has expired it.
|
// arms does nothing (OnTimedUnshelve is a no-op — the engine's AutoUnshelve owns the expiry).
|
||||||
timedShelve.OnCall = (context, method, objectId, shelvingTime) => ReportAfterMethod(
|
timedShelve.OnCall = (context, method, objectId, shelvingTime) => ReportAfterMethod(
|
||||||
() => timed(context, method, objectId, shelvingTime),
|
() => timed(context, method, objectId, shelvingTime),
|
||||||
() => alarm.SetShelvingState(SystemContext, shelved: true, oneShot: false, shelvingTime: shelvingTime));
|
() => alarm.SetShelvingState(SystemContext, shelved: true, oneShot: false, shelvingTime: shelvingTime));
|
||||||
|
|||||||
@@ -788,9 +788,91 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
|||||||
// Same rationale for the ScriptedAlarm host — the bootstrap-restore path Tells it
|
// Same rationale for the ScriptedAlarm host — the bootstrap-restore path Tells it
|
||||||
// ApplyScriptedAlarms through the same PushDesiredSubscriptions pass.
|
// ApplyScriptedAlarms through the same PushDesiredSubscriptions pass.
|
||||||
SpawnScriptedAlarmHost();
|
SpawnScriptedAlarmHost();
|
||||||
|
StartPrimaryVerdictPublishing();
|
||||||
Bootstrap();
|
Bootstrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Review R1 m2 (Gitea #597): the published Primary data-plane verdict (<see cref="IDriverPrimaryRoleSource"/>
|
||||||
|
/// — the native-ack gate and the Sparkplug primary-host claim) must mirror <see cref="ShouldServiceAsPrimary"/>
|
||||||
|
/// from the moment this host starts, not only from the first redundancy snapshot. Before this, the view sat
|
||||||
|
/// on its single-node seed (allow) through the whole boot window, so on a pair BOTH nodes accepted a native
|
||||||
|
/// ack while this host — which evaluates the member count live — dropped the upstream route on the
|
||||||
|
/// non-Primary: the acked → unacked revert #597 targets. So publish once now, and again whenever cluster
|
||||||
|
/// membership changes (a driver peer coming Up turns an unknown role from allow into deny before any
|
||||||
|
/// snapshot names this node). A non-cluster ActorRefProvider (legacy/test harnesses) just keeps the
|
||||||
|
/// initial publish.
|
||||||
|
/// </summary>
|
||||||
|
private void StartPrimaryVerdictPublishing()
|
||||||
|
{
|
||||||
|
if (_primaryRoleSource is null) return;
|
||||||
|
PublishPrimaryVerdict();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Akka.Cluster.Cluster.Get(Context.System).Subscribe(
|
||||||
|
Self, Akka.Cluster.ClusterEvent.SubscriptionInitialStateMode.InitialStateAsSnapshot,
|
||||||
|
typeof(Akka.Cluster.ClusterEvent.IMemberEvent));
|
||||||
|
_subscribedToMemberEvents = true;
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Non-cluster ActorRefProvider — no membership to follow; DriverMemberCount() reads 0 there too.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A cluster member changed status: re-publish the Primary verdict now and once more shortly after.
|
||||||
|
/// The deferred pass covers the cluster read-view (what <see cref="DriverMemberCount"/> reads) updating on its
|
||||||
|
/// own subscriber AFTER this event was delivered here — the two are not ordered.</summary>
|
||||||
|
private void OnDriverMembershipChanged(Akka.Cluster.ClusterEvent.IMemberEvent _)
|
||||||
|
{
|
||||||
|
PublishPrimaryVerdictIfChanged();
|
||||||
|
// One keyed timer: a burst of member events collapses into a single deferred pass.
|
||||||
|
Timers.StartSingleTimer("republish-primary-verdict", RepublishPrimaryVerdict.Instance, MembershipRepublishDelay);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Publishes <see cref="ShouldServiceAsPrimary"/> to the primary-role source (no-op when none is wired).
|
||||||
|
/// The snapshot path publishes on every snapshot, as the other verdicts do.</summary>
|
||||||
|
private void PublishPrimaryVerdict()
|
||||||
|
{
|
||||||
|
if (_primaryRoleSource is null) return;
|
||||||
|
var verdict = ShouldServiceAsPrimary();
|
||||||
|
_lastPublishedPrimaryVerdict = verdict;
|
||||||
|
_primaryRoleSource.PublishPrimaryRole(verdict);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The membership path publishes only a CHANGED verdict — member events are frequent and carry no
|
||||||
|
/// role information, so republishing an unchanged verdict on each would be noise.</summary>
|
||||||
|
private void PublishPrimaryVerdictIfChanged()
|
||||||
|
{
|
||||||
|
if (_primaryRoleSource is null || ShouldServiceAsPrimary() == _lastPublishedPrimaryVerdict) return;
|
||||||
|
PublishPrimaryVerdict();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void PostStop()
|
||||||
|
{
|
||||||
|
if (_subscribedToMemberEvents)
|
||||||
|
{
|
||||||
|
try { Akka.Cluster.Cluster.Get(Context.System).Unsubscribe(Self); }
|
||||||
|
catch (Exception) { /* system shutting down */ }
|
||||||
|
}
|
||||||
|
base.PostStop();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Self-message: re-evaluate and re-publish the Primary verdict (see <see cref="OnDriverMembershipChanged"/>).</summary>
|
||||||
|
internal sealed class RepublishPrimaryVerdict
|
||||||
|
{
|
||||||
|
/// <summary>The shared instance.</summary>
|
||||||
|
public static readonly RepublishPrimaryVerdict Instance = new();
|
||||||
|
private RepublishPrimaryVerdict() { }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>How long after a membership event the Primary verdict is re-published a second time.</summary>
|
||||||
|
private static readonly TimeSpan MembershipRepublishDelay = TimeSpan.FromSeconds(1);
|
||||||
|
|
||||||
|
private bool _subscribedToMemberEvents;
|
||||||
|
private bool? _lastPublishedPrimaryVerdict;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Spawns the single <see cref="VirtualTagHostActor"/> child that owns the Equipment-namespace
|
/// Spawns the single <see cref="VirtualTagHostActor"/> child that owns the Equipment-namespace
|
||||||
/// VirtualTagActors and bridges their results onto the OPC UA publish actor. A test-supplied
|
/// VirtualTagActors and bridges their results onto the OPC UA publish actor. A test-supplied
|
||||||
@@ -990,12 +1072,24 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
|||||||
var deploymentId = DeploymentId.Parse(cached.DeploymentId);
|
var deploymentId = DeploymentId.Parse(cached.DeploymentId);
|
||||||
var revision = RevisionHash.Parse(cached.RevisionHash);
|
var revision = RevisionHash.Parse(cached.RevisionHash);
|
||||||
|
|
||||||
_currentRevision = revision;
|
|
||||||
BecomeSteady();
|
|
||||||
// Restore drivers + address space + subscriptions from the cached bytes — no SQL read, no
|
// Restore drivers + address space + subscriptions from the cached bytes — no SQL read, no
|
||||||
// re-ack (the deployment is already Applied). Same in-hand-bytes apply as the Direct-mode
|
// re-ack (the deployment is already Applied). Same in-hand-bytes apply as the Direct-mode
|
||||||
// cache boot, but this is the steady-state config source, not an outage fallback.
|
// cache boot, but this is the steady-state config source, not an outage fallback.
|
||||||
ApplyCachedArtifact(deploymentId, cached.Artifact);
|
if (!ApplyCachedArtifact(deploymentId, cached.Artifact))
|
||||||
|
{
|
||||||
|
// #601 / review R1 m4: unusable cached bytes applied nothing, so they must not become the
|
||||||
|
// current revision — that would ack a re-dispatch of the same revision as already applied
|
||||||
|
// without ever applying it. Steady-with-no-revision, exactly as for an empty cache.
|
||||||
|
_log.Warning(
|
||||||
|
"DriverHost {Node}: FetchAndCache boot — the cached artifact for deployment {Id} (rev {Rev}) " +
|
||||||
|
"is unreadable; entering Steady (no revision). The next dispatch will fetch.",
|
||||||
|
_localNode, deploymentId, revision);
|
||||||
|
BecomeSteady();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_currentRevision = revision;
|
||||||
|
BecomeSteady();
|
||||||
|
|
||||||
_log.Info(
|
_log.Info(
|
||||||
"DriverHost {Node}: FetchAndCache boot — restored served state for deployment {Id} " +
|
"DriverHost {Node}: FetchAndCache boot — restored served state for deployment {Id} " +
|
||||||
@@ -1059,14 +1153,24 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
|||||||
var deploymentId = DeploymentId.Parse(cached.DeploymentId);
|
var deploymentId = DeploymentId.Parse(cached.DeploymentId);
|
||||||
var revision = RevisionHash.Parse(cached.RevisionHash);
|
var revision = RevisionHash.Parse(cached.RevisionHash);
|
||||||
|
|
||||||
|
// #601 / review R1 m4: unusable cached bytes applied nothing — the node is NOT serving a
|
||||||
|
// configuration, so it must not claim RUNNING FROM CACHE, adopt the revision (a re-dispatch of
|
||||||
|
// it would then be acked without being applied) or skip Stale's ConfigDb retry loop.
|
||||||
|
if (!ApplyCachedArtifact(deploymentId, cached.Artifact))
|
||||||
|
{
|
||||||
|
_log.Error(
|
||||||
|
"DriverHost {Node}: the cached artifact for deployment {Id} (rev {Rev}) is unreadable; " +
|
||||||
|
"not booting from it — entering Stale with no configuration.",
|
||||||
|
_localNode, deploymentId, revision);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Steady, not Stale: this node is serving a real configuration. The retry-db timer that
|
// Steady, not Stale: this node is serving a real configuration. The retry-db timer that
|
||||||
// Stale would have started does not run here, so recovery rides on the next dispatch —
|
// Stale would have started does not run here, so recovery rides on the next dispatch —
|
||||||
// matching how a normally-booted node behaves.
|
// matching how a normally-booted node behaves.
|
||||||
_currentRevision = revision;
|
_currentRevision = revision;
|
||||||
BecomeSteady();
|
BecomeSteady();
|
||||||
|
|
||||||
ApplyCachedArtifact(deploymentId, cached.Artifact);
|
|
||||||
|
|
||||||
_isRunningFromCache = true;
|
_isRunningFromCache = true;
|
||||||
_log.Warning(
|
_log.Warning(
|
||||||
"DriverHost {Node}: RUNNING FROM CACHE — central ConfigDb is unreachable, so this node " +
|
"DriverHost {Node}: RUNNING FROM CACHE — central ConfigDb is unreachable, so this node " +
|
||||||
@@ -1094,16 +1198,16 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
|||||||
/// <c>UpsertNodeDeploymentState</c> and <c>SendAck</c> for the same reason: both write to or
|
/// <c>UpsertNodeDeploymentState</c> and <c>SendAck</c> for the same reason: both write to or
|
||||||
/// depend on central.
|
/// depend on central.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private void ApplyCachedArtifact(DeploymentId deploymentId, byte[] blob)
|
/// <returns><see langword="false"/> when the bytes were empty or malformed and nothing was applied — the
|
||||||
|
/// caller must not adopt the cached revision (#601, review R1 m4).</returns>
|
||||||
|
private bool ApplyCachedArtifact(DeploymentId deploymentId, byte[] blob)
|
||||||
{
|
{
|
||||||
var correlation = CorrelationId.NewId();
|
var correlation = CorrelationId.NewId();
|
||||||
|
|
||||||
// #601: null = the cached bytes were empty or malformed — no answer, not an empty configuration.
|
|
||||||
// Keep last-known-good: no address-space rebuild and no subscription push from unusable bytes.
|
|
||||||
// #601: null = the cached bytes were empty or malformed — no answer, not an empty configuration.
|
// #601: null = the cached bytes were empty or malformed — no answer, not an empty configuration.
|
||||||
// Keep last-known-good: no address-space rebuild and no subscription push from unusable bytes.
|
// Keep last-known-good: no address-space rebuild and no subscription push from unusable bytes.
|
||||||
if (ReconcileDriversFromBlob(deploymentId, blob) is null)
|
if (ReconcileDriversFromBlob(deploymentId, blob) is null)
|
||||||
return;
|
return false;
|
||||||
|
|
||||||
// Hand the cached blob to the rebuild so it materialises the client-facing address space from
|
// Hand the cached blob to the rebuild so it materialises the client-facing address space from
|
||||||
// it directly. Passing only the deploymentId would drive a ConfigDb read — unreachable here by
|
// it directly. Passing only the deploymentId would drive a ConfigDb read — unreachable here by
|
||||||
@@ -1113,6 +1217,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
|||||||
new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.RebuildAddressSpace(correlation, deploymentId, blob));
|
new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.RebuildAddressSpace(correlation, deploymentId, blob));
|
||||||
|
|
||||||
PushDesiredSubscriptionsFromArtifact(deploymentId, blob);
|
PushDesiredSubscriptionsFromArtifact(deploymentId, blob);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -1170,6 +1275,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
|||||||
Receive<RouteNodeWrite>(HandleRouteNodeWrite);
|
Receive<RouteNodeWrite>(HandleRouteNodeWrite);
|
||||||
Receive<RouteNativeAlarmAck>(HandleRouteNativeAlarmAck);
|
Receive<RouteNativeAlarmAck>(HandleRouteNativeAlarmAck);
|
||||||
Receive<RedundancyStateChanged>(OnRedundancyStateChanged);
|
Receive<RedundancyStateChanged>(OnRedundancyStateChanged);
|
||||||
|
Receive<Akka.Cluster.ClusterEvent.IMemberEvent>(OnDriverMembershipChanged);
|
||||||
|
Receive<RepublishPrimaryVerdict>(_ => PublishPrimaryVerdictIfChanged());
|
||||||
|
Receive<Akka.Cluster.ClusterEvent.CurrentClusterState>(_ => PublishPrimaryVerdictIfChanged());
|
||||||
// The redundancy-state topic also carries OpcUaProbeResult (OpcUaPublishActor peer-probes).
|
// The redundancy-state topic also carries OpcUaProbeResult (OpcUaPublishActor peer-probes).
|
||||||
// We don't consume it here — drop it so it doesn't dead-letter (matches PeerProbeSupervisor).
|
// We don't consume it here — drop it so it doesn't dead-letter (matches PeerProbeSupervisor).
|
||||||
Receive<ZB.MOM.WW.OtOpcUa.Runtime.Health.PeerOpcUaProbeActor.OpcUaProbeResult>(_ => { });
|
Receive<ZB.MOM.WW.OtOpcUa.Runtime.Health.PeerOpcUaProbeActor.OpcUaProbeResult>(_ => { });
|
||||||
@@ -1228,6 +1336,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
|||||||
Receive<RouteNodeWrite>(HandleRouteNodeWrite);
|
Receive<RouteNodeWrite>(HandleRouteNodeWrite);
|
||||||
Receive<RouteNativeAlarmAck>(HandleRouteNativeAlarmAck);
|
Receive<RouteNativeAlarmAck>(HandleRouteNativeAlarmAck);
|
||||||
Receive<RedundancyStateChanged>(OnRedundancyStateChanged);
|
Receive<RedundancyStateChanged>(OnRedundancyStateChanged);
|
||||||
|
Receive<Akka.Cluster.ClusterEvent.IMemberEvent>(OnDriverMembershipChanged);
|
||||||
|
Receive<RepublishPrimaryVerdict>(_ => PublishPrimaryVerdictIfChanged());
|
||||||
|
Receive<Akka.Cluster.ClusterEvent.CurrentClusterState>(_ => PublishPrimaryVerdictIfChanged());
|
||||||
// The redundancy-state topic also carries OpcUaProbeResult (OpcUaPublishActor peer-probes).
|
// The redundancy-state topic also carries OpcUaProbeResult (OpcUaPublishActor peer-probes).
|
||||||
// We don't consume it here — drop it so it doesn't dead-letter (matches PeerProbeSupervisor).
|
// We don't consume it here — drop it so it doesn't dead-letter (matches PeerProbeSupervisor).
|
||||||
Receive<ZB.MOM.WW.OtOpcUa.Runtime.Health.PeerOpcUaProbeActor.OpcUaProbeResult>(_ => { });
|
Receive<ZB.MOM.WW.OtOpcUa.Runtime.Health.PeerOpcUaProbeActor.OpcUaProbeResult>(_ => { });
|
||||||
@@ -1975,7 +2086,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
|||||||
// drain verdict published immediately above. A driver reading this claims an exclusive identity
|
// 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
|
// 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.
|
// harm, so an unknown role with a driver peer present must deny.
|
||||||
_primaryRoleSource?.PublishPrimaryRole(ShouldServiceAsPrimary());
|
PublishPrimaryVerdict();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Stale()
|
private void Stale()
|
||||||
@@ -1998,6 +2109,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
|||||||
Receive<ReprovisionDeferred>(m => ReprovisionChild(m.Request, m.Child));
|
Receive<ReprovisionDeferred>(m => ReprovisionChild(m.Request, m.Child));
|
||||||
Receive<ReconnectDriver>(HandleReconnectDriver);
|
Receive<ReconnectDriver>(HandleReconnectDriver);
|
||||||
Receive<RedundancyStateChanged>(OnRedundancyStateChanged);
|
Receive<RedundancyStateChanged>(OnRedundancyStateChanged);
|
||||||
|
Receive<Akka.Cluster.ClusterEvent.IMemberEvent>(OnDriverMembershipChanged);
|
||||||
|
Receive<RepublishPrimaryVerdict>(_ => PublishPrimaryVerdictIfChanged());
|
||||||
|
Receive<Akka.Cluster.ClusterEvent.CurrentClusterState>(_ => PublishPrimaryVerdictIfChanged());
|
||||||
// The redundancy-state topic also carries OpcUaProbeResult (OpcUaPublishActor peer-probes).
|
// The redundancy-state topic also carries OpcUaProbeResult (OpcUaPublishActor peer-probes).
|
||||||
// We don't consume it here — drop it so it doesn't dead-letter (matches PeerProbeSupervisor).
|
// We don't consume it here — drop it so it doesn't dead-letter (matches PeerProbeSupervisor).
|
||||||
Receive<ZB.MOM.WW.OtOpcUa.Runtime.Health.PeerOpcUaProbeActor.OpcUaProbeResult>(_ => { });
|
Receive<ZB.MOM.WW.OtOpcUa.Runtime.Health.PeerOpcUaProbeActor.OpcUaProbeResult>(_ => { });
|
||||||
@@ -3233,7 +3347,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
|||||||
Context.Stop(entry.Actor);
|
Context.Stop(entry.Actor);
|
||||||
_children.Remove(driverInstanceId);
|
_children.Remove(driverInstanceId);
|
||||||
// #609: the instance left the plan (StopChild is only reached via plan.ToStop), so its cached
|
// #609: the instance left the plan (StopChild is only reached via plan.ToStop), so its cached
|
||||||
// Health/Resilience snapshots must not replay to new telemetry subscribers.
|
// Health/Resilience snapshots must not replay to new telemetry subscribers. Release the shared
|
||||||
|
// resilience state FIRST: the resilience publisher re-emits the tracker's snapshots every tick, so
|
||||||
|
// evicting the hub alone was undone within one interval.
|
||||||
|
_invokerFactory.Release(driverInstanceId);
|
||||||
_telemetryHub?.Evict(driverInstanceId);
|
_telemetryHub?.Evict(driverInstanceId);
|
||||||
_log.Info("DriverHost {Node}: stopped driver child {Id}", _localNode, driverInstanceId);
|
_log.Info("DriverHost {Node}: stopped driver child {Id}", _localNode, driverInstanceId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,26 +3,36 @@ using Microsoft.Extensions.Configuration;
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Runtime;
|
namespace ZB.MOM.WW.OtOpcUa.Runtime;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The ONE definition of "this node's LocalDb is replicated to a peer" (#603). Two consumers must agree
|
/// The definitions of "this node's LocalDb is replicated to a peer" (#603) — one place, two deliberately
|
||||||
/// on it, and used to carry separate predicates that diverged: the Host's table-registration step
|
/// different predicates for the two consumers: the Host's table-registration step
|
||||||
/// (<c>LocalDbSetup.ReplicationIsConfigured</c> - which nodes capture changes) and the alarm-historian
|
/// (<c>LocalDbSetup.ReplicationIsConfigured</c> → <see cref="CapturesChanges"/>: which nodes capture
|
||||||
/// drain gate (<c>AddAlarmHistorian</c> - which nodes stand down in favour of a peer). Lives in Runtime
|
/// changes) and the alarm-historian drain gate (<c>AddAlarmHistorian</c> → <see cref="SharesRowsWithPeer"/>:
|
||||||
/// because Host references Runtime and not the reverse.
|
/// which nodes stand down in favour of a peer). Lives in Runtime because Host references Runtime and not
|
||||||
|
/// the reverse.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Predicate: <c>LocalDb:Replication:PeerAddress</c> OR <c>LocalDb:Replication:ApiKey</c> is
|
/// <b>Why two predicates (review R1 m3).</b> The two answers cost differently when wrong. Registering a
|
||||||
/// non-blank.</b> The OR is required: only the dialling half of a pair sets <c>PeerAddress</c>, while
|
/// node that has no working sync path only costs capture triggers. Gating the drain of a node that
|
||||||
/// the passive half carries the shared <c>ApiKey</c> (plus a listen port) and nothing else.
|
/// shares its queue with nobody parks its alarm events behind a Primary that does not hold them —
|
||||||
|
/// undelivered until the node is promoted, and drop-oldest at <c>Capacity</c> meanwhile. So
|
||||||
|
/// registration stays permissive and the gate is strict. Every node the gate treats as sharing also
|
||||||
|
/// captures, so the two never disagree in the harmful direction.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Why the drain gate takes the same predicate (it previously used PeerAddress OR
|
/// <b>Registration: <c>LocalDb:Replication:PeerAddress</c> OR <c>LocalDb:Replication:ApiKey</c>.</b>
|
||||||
/// <c>SyncListenPort</c>).</b> The gate's question is "does a peer hold the same rows?". The passive
|
/// The OR is required: only the dialling half of a pair sets <c>PeerAddress</c>, while the passive
|
||||||
/// sync endpoint is fail-closed on the key, so a node with a listen port but no <c>ApiKey</c> rejects
|
/// half carries the shared <c>ApiKey</c> (plus a listen port) and nothing else. Keying on
|
||||||
/// every inbound stream and shares its queue with nobody - gating it would park its alarm events
|
/// <c>PeerAddress</c> alone would strip capture from every passive node.
|
||||||
/// behind a Primary that cannot deliver them. Conversely an <c>ApiKey</c>-only passive node IS
|
/// </para>
|
||||||
/// registered and does receive its peer's rows, so it must be gated or both halves drain. The port
|
/// <para>
|
||||||
/// is not evidence of a peer; the key is.
|
/// <b>Drain gate: <c>ApiKey</c> AND (<c>PeerAddress</c> OR <c>LocalDb:SyncListenPort</c> > 0).</b> A
|
||||||
|
/// peer holds this node's rows only over a working sync path: a dial (<c>PeerAddress</c>) or a
|
||||||
|
/// listener (<c>SyncListenPort</c>), each authenticated by the key. Both auth directions fail closed,
|
||||||
|
/// so a path without the key shares nothing, and a key with neither path (e.g. a fleet-wide
|
||||||
|
/// secret/env <c>ApiKey</c> on a node with no peer) shares nothing either. Every docker-dev shape
|
||||||
|
/// gives the same answer under both predicates: site-a-1 (PeerAddress + ApiKey + port) and site-a-2
|
||||||
|
/// (ApiKey + port) are gated; central and site-b (neither) are not.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static class LocalDbReplicationPolicy
|
public static class LocalDbReplicationPolicy
|
||||||
@@ -30,14 +40,31 @@ public static class LocalDbReplicationPolicy
|
|||||||
/// <summary>Configuration section holding the LocalDb replication options.</summary>
|
/// <summary>Configuration section holding the LocalDb replication options.</summary>
|
||||||
public const string ReplicationSectionPath = "LocalDb:Replication";
|
public const string ReplicationSectionPath = "LocalDb:Replication";
|
||||||
|
|
||||||
/// <summary>Whether this node's LocalDb replicates to a pair peer.</summary>
|
/// <summary>The dedicated passive-sync listener port key (same key as <c>LocalDbRegistration.SyncListenPortKey</c>).</summary>
|
||||||
|
public const string SyncListenPortKey = "LocalDb:SyncListenPort";
|
||||||
|
|
||||||
|
/// <summary>Whether this node registers its replicated tables for change capture (table registration).</summary>
|
||||||
/// <param name="configuration">The application configuration.</param>
|
/// <param name="configuration">The application configuration.</param>
|
||||||
/// <returns><c>true</c> when <c>PeerAddress</c> or <c>ApiKey</c> is set under the replication section.</returns>
|
/// <returns><c>true</c> when <c>PeerAddress</c> or <c>ApiKey</c> is set under the replication section.</returns>
|
||||||
public static bool IsConfigured(IConfiguration configuration)
|
public static bool CapturesChanges(IConfiguration configuration)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(configuration);
|
ArgumentNullException.ThrowIfNull(configuration);
|
||||||
var section = configuration.GetSection(ReplicationSectionPath);
|
var section = configuration.GetSection(ReplicationSectionPath);
|
||||||
return !string.IsNullOrWhiteSpace(section["PeerAddress"])
|
return !string.IsNullOrWhiteSpace(section["PeerAddress"])
|
||||||
|| !string.IsNullOrWhiteSpace(section["ApiKey"]);
|
|| !string.IsNullOrWhiteSpace(section["ApiKey"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether a pair peer actually holds this node's replicated rows (the alarm drain gate).</summary>
|
||||||
|
/// <param name="configuration">The application configuration.</param>
|
||||||
|
/// <returns><c>true</c> when <c>ApiKey</c> is set AND either <c>PeerAddress</c> is set or
|
||||||
|
/// <c>LocalDb:SyncListenPort</c> is a positive port.</returns>
|
||||||
|
public static bool SharesRowsWithPeer(IConfiguration configuration)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(configuration);
|
||||||
|
var section = configuration.GetSection(ReplicationSectionPath);
|
||||||
|
if (string.IsNullOrWhiteSpace(section["ApiKey"])) return false;
|
||||||
|
var dials = !string.IsNullOrWhiteSpace(section["PeerAddress"]);
|
||||||
|
var listens = int.TryParse(configuration[SyncListenPortKey], out var port) && port > 0;
|
||||||
|
return dials || listens;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,11 +177,13 @@ public static class ServiceCollectionExtensions
|
|||||||
// possibly deliver its events. Scoping the gate to "is my queue actually shared?" is what
|
// possibly deliver its events. Scoping the gate to "is my queue actually shared?" is what
|
||||||
// keeps the two scopes from disagreeing.
|
// keeps the two scopes from disagreeing.
|
||||||
// BOTH halves of a pair share the queue, but only one of them dials: the initiator sets
|
// BOTH halves of a pair share the queue, but only one of them dials: the initiator sets
|
||||||
// Replication:PeerAddress, its partner carries the shared ApiKey and waits. The predicate is
|
// Replication:PeerAddress, its partner carries the shared ApiKey plus SyncListenPort and waits.
|
||||||
// LocalDbReplicationPolicy - the same one LocalDbSetup uses to decide which nodes capture
|
// The predicate is LocalDbReplicationPolicy.SharesRowsWithPeer: ApiKey AND (PeerAddress OR
|
||||||
// changes at all. It deliberately does NOT count SyncListenPort: the passive endpoint is
|
// SyncListenPort > 0) - an authenticated sync path in either direction. Both auth directions
|
||||||
// fail-closed on the key, so a port with no ApiKey shares nothing and must keep draining.
|
// fail closed, so a path without the key, or a key with no path (a fleet-wide secret on an
|
||||||
var replicated = LocalDbReplicationPolicy.IsConfigured(configuration);
|
// unpaired node), shares nothing and must keep draining. Deliberately stricter than the
|
||||||
|
// registration predicate (CapturesChanges) - see the policy's remarks.
|
||||||
|
var replicated = LocalDbReplicationPolicy.SharesRowsWithPeer(configuration);
|
||||||
|
|
||||||
if (!replicated)
|
if (!replicated)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -97,4 +97,30 @@ public sealed class ClusterRoleInfoMemberEventTests
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Review R1 m6: the initial seed lists only Up / WeaklyUp members. A member already departing at seed
|
||||||
|
/// time never fires a later Left/Exited/Downed event, so seeding it would list it until MemberRemoved.
|
||||||
|
/// The Up and WeaklyUp members are the positive control (the seed does list members).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Initial_seed_lists_only_up_and_weakly_up_members()
|
||||||
|
{
|
||||||
|
await WithInfoAsync(info =>
|
||||||
|
{
|
||||||
|
var members = ImmutableSortedSet.Create<Member>(
|
||||||
|
NewMember(21001, MemberStatus.Up),
|
||||||
|
NewMember(21002, MemberStatus.WeaklyUp),
|
||||||
|
NewMember(21003, MemberStatus.Leaving),
|
||||||
|
NewMember(21004, MemberStatus.Exiting),
|
||||||
|
NewMember(21005, MemberStatus.Down),
|
||||||
|
NewMember(21006, MemberStatus.Joining));
|
||||||
|
|
||||||
|
info.SeedFrom(new ClusterEvent.CurrentClusterState().Copy(members: members));
|
||||||
|
|
||||||
|
info.MembersWithRole("driver").Select(n => n.Value).OrderBy(v => v).ShouldBe(
|
||||||
|
["127.0.0.1:21001", "127.0.0.1:21002"]);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,28 @@ public sealed class Part9StateMachineTests
|
|||||||
r.Emission.ShouldBe(EmissionKind.None);
|
r.Emission.ShouldBe(EmissionKind.None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gitea #597 review m1 — a COMMENTED re-ack records its comment (as AddComment would) so the comment the
|
||||||
|
/// OPC UA event advertised is durable; the original acknowledge's ack fields are untouched.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Commented_reack_records_the_comment_without_reacknowledging()
|
||||||
|
{
|
||||||
|
var active = Part9StateMachine.ApplyPredicate(Fresh(), true, T0.AddSeconds(1)).State;
|
||||||
|
var acked = Part9StateMachine.ApplyAcknowledge(active, "alice", "first look", T0.AddSeconds(2)).State;
|
||||||
|
|
||||||
|
var r = Part9StateMachine.ApplyAcknowledge(acked, "bob", "still on it", T0.AddSeconds(3));
|
||||||
|
|
||||||
|
r.Emission.ShouldBe(EmissionKind.CommentAdded);
|
||||||
|
r.State.Comments.Count.ShouldBe(2);
|
||||||
|
r.State.Comments[1].User.ShouldBe("bob");
|
||||||
|
r.State.Comments[1].Kind.ShouldBe("AddComment");
|
||||||
|
r.State.Comments[1].Text.ShouldBe("still on it");
|
||||||
|
r.State.LastAckUser.ShouldBe("alice");
|
||||||
|
r.State.LastAckComment.ShouldBe("first look");
|
||||||
|
r.State.LastAckUtc.ShouldBe(T0.AddSeconds(2));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that acknowledging without a user throws ArgumentException.</summary>
|
/// <summary>Verifies that acknowledging without a user throws ArgumentException.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Acknowledge_without_user_throws()
|
public void Acknowledge_without_user_throws()
|
||||||
|
|||||||
+42
-8
@@ -263,14 +263,18 @@ public sealed class ConditionMethodEventTests
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A scripted TimedShelve expires: the engine's expiry comes back as an Unshelved re-projection, which
|
/// A scripted TimedShelve expires: the engine's expiry comes back as an Unshelved re-projection, which
|
||||||
/// reports exactly one unshelve event after the one shelve event.
|
/// reports exactly one unshelve event after the one shelve event — and the SDK's own expiry timer routes
|
||||||
|
/// nothing (review R1 M2).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The expiry is the engine's (computed from the command's <c>UnshelveAtUtc</c>). Since Gitea #597 the node
|
/// The expiry is the engine's (computed from the command's <c>UnshelveAtUtc</c>). Since Gitea #597 the node
|
||||||
/// is shelved for the real duration, so the SDK's own timer fires at the same instant — but it only routes
|
/// is shelved for the real duration, so the SDK's own timer fires at that instant. It used to route an
|
||||||
/// an Unshelve back to the engine (ignored by this stand-in, a no-op on an unshelved engine alarm) and
|
/// operator-style <c>Unshelve</c> by <c>system</c>, which — because the real engine only checks expiry every
|
||||||
/// changes no node state, so it adds no event. The stand-in engine here schedules its expiry from the real
|
/// 5 s — pre-empted the engine's <c>AutoUnshelve</c> on every expiry. This stand-in engine therefore
|
||||||
/// <c>UnshelveAtUtc</c> the method handler computed.
|
/// (a) expires LATE, like the real one, so the SDK timer demonstrably fires first; and (b) ACTS on a routed
|
||||||
|
/// <c>Unshelve</c> as the real engine would, recording who unshelved it. The wrapped
|
||||||
|
/// <c>OnTimedUnshelve</c> is the positive control: the SDK timer did fire, and still nothing was routed and
|
||||||
|
/// the unshelve on record is the engine's own.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task A_scripted_timed_shelve_expires_with_exactly_one_unshelve_event()
|
public async Task A_scripted_timed_shelve_expires_with_exactly_one_unshelve_event()
|
||||||
@@ -283,21 +287,47 @@ public sealed class ConditionMethodEventTests
|
|||||||
rig.MaterialiseScripted();
|
rig.MaterialiseScripted();
|
||||||
var current = PreState("TimedShelve", native: false) with { Message = "base" };
|
var current = PreState("TimedShelve", native: false) with { Message = "base" };
|
||||||
Task? expiry = null;
|
Task? expiry = null;
|
||||||
|
var routed = new System.Collections.Concurrent.ConcurrentQueue<string>();
|
||||||
|
string? unshelvedBy = null;
|
||||||
|
var engineLag = TimeSpan.FromMilliseconds(1500); // the real engine's 5 s shelving-check granularity
|
||||||
rig.Nm.AlarmCommandRouter = cmd =>
|
rig.Nm.AlarmCommandRouter = cmd =>
|
||||||
{
|
{
|
||||||
|
routed.Enqueue(cmd.Operation);
|
||||||
|
if (cmd.Operation == "Unshelve")
|
||||||
|
{
|
||||||
|
// What the real engine does with a routed Unshelve: an operator-style unshelve.
|
||||||
|
unshelvedBy ??= $"Unshelve:{cmd.User}";
|
||||||
|
current = current with { Shelving = AlarmShelvingKind.Unshelved, UnshelveAtUtc = null };
|
||||||
|
rig.ProjectLater(native: false, current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (cmd.Operation != "TimedShelve") return;
|
if (cmd.Operation != "TimedShelve") return;
|
||||||
current = current with { Shelving = AlarmShelvingKind.Timed };
|
// The real engine's projection carries its expiry, so the re-projection keeps the SDK timer armed
|
||||||
|
// for the real remaining time (without it, it would re-arm for MaxTimeShelved and never fire here).
|
||||||
|
current = current with { Shelving = AlarmShelvingKind.Timed, UnshelveAtUtc = cmd.UnshelveAtUtc };
|
||||||
rig.ProjectLater(native: false, current);
|
rig.ProjectLater(native: false, current);
|
||||||
var unshelveAt = cmd.UnshelveAtUtc!.Value;
|
var unshelveAt = cmd.UnshelveAtUtc!.Value;
|
||||||
expiry = Task.Run(async () =>
|
expiry = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
var wait = unshelveAt - DateTime.UtcNow;
|
var wait = unshelveAt + engineLag - DateTime.UtcNow;
|
||||||
if (wait > TimeSpan.Zero) await Task.Delay(wait);
|
if (wait > TimeSpan.Zero) await Task.Delay(wait);
|
||||||
current = current with { Shelving = AlarmShelvingKind.Unshelved };
|
if (current.Shelving != AlarmShelvingKind.Timed) return; // ApplyShelvingCheck: Timed only
|
||||||
|
unshelvedBy ??= "AutoUnshelve:system";
|
||||||
|
current = current with { Shelving = AlarmShelvingKind.Unshelved, UnshelveAtUtc = null };
|
||||||
rig.Project(native: false, current);
|
rig.Project(native: false, current);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Count the SDK's own expiry-timer firings without changing what the production handler does.
|
||||||
|
var condition = rig.Nm.TryGetAlarmCondition(ScriptedAlarmId)!;
|
||||||
|
var sdkTimerFired = 0;
|
||||||
|
var production = condition.OnTimedUnshelve!;
|
||||||
|
condition.OnTimedUnshelve = (context, c) =>
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref sdkTimerFired);
|
||||||
|
return production(context, c);
|
||||||
|
};
|
||||||
|
|
||||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||||
var events = new RecordedEvents();
|
var events = new RecordedEvents();
|
||||||
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
|
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
|
||||||
@@ -315,6 +345,10 @@ public sealed class ConditionMethodEventTests
|
|||||||
rig.Project(false, current = current with { Message = "sentinel" });
|
rig.Project(false, current = current with { Message = "sentinel" });
|
||||||
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel"));
|
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel"));
|
||||||
|
|
||||||
|
Volatile.Read(ref sdkTimerFired).ShouldBeGreaterThanOrEqualTo(1, "the SDK's timed-unshelve timer fired");
|
||||||
|
routed.ShouldNotContain("Unshelve", "the SDK timer must not route an operator-style Unshelve");
|
||||||
|
unshelvedBy.ShouldBe("AutoUnshelve:system", "the engine's own expiry owns the unshelve");
|
||||||
|
|
||||||
var shelving = events.ConditionEvents(item, "base").Select(e => e[RecordedEvents.ShelvingField].Value).ToList();
|
var shelving = events.ConditionEvents(item, "base").Select(e => e[RecordedEvents.ShelvingField].Value).ToList();
|
||||||
shelving.ShouldBe(
|
shelving.ShouldBe(
|
||||||
[ObjectIds.ShelvedStateMachineType_Unshelved, ObjectIds.ShelvedStateMachineType_TimedShelved,
|
[ObjectIds.ShelvedStateMachineType_Unshelved, ObjectIds.ShelvedStateMachineType_TimedShelved,
|
||||||
|
|||||||
@@ -268,19 +268,15 @@ public sealed class AlarmCommandRouterTests : IDisposable
|
|||||||
await host.DisposeAsync();
|
await host.DisposeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>OnTimedUnshelve fires with the SDK's system context (no session, no user identity) —
|
/// <summary>OnTimedUnshelve fires with the SDK's system context (no session, no user identity) when the
|
||||||
/// the real SDK path when a TimedShelve duration expires. The gate must NOT veto: the result must be
|
/// SDK's own TimedShelve timer expires. It must return Good and route NOTHING (review R1 M2, Gitea #597):
|
||||||
/// Good, the router must be invoked exactly once with Operation == "Unshelve", and no UnshelveAtUtc is
|
/// the scripted engine owns timed-shelve expiry (its shelving check records an "AutoUnshelve" by
|
||||||
/// carried.
|
/// "system"), and since #597 arms the SDK timer for the real remaining time, an "Unshelve" routed from
|
||||||
/// <para>
|
/// here pre-empted that check on every expiry, from both pair nodes, as an operator-style unshelve.
|
||||||
/// Regression (OpcUaServer-007): the routed command's <see cref="AlarmCommand.User"/> must be a
|
/// <para>Positive control: on the same condition and router, a client Unshelve (OnShelve with
|
||||||
/// NON-EMPTY system user (<c>"system"</c>). The engine's <c>Part9StateMachine.ApplyUnshelve</c> rejects
|
/// <c>shelving:false</c>) DOES route — so the empty capture is not a disconnected router.</para></summary>
|
||||||
/// a null/whitespace user with <c>ArgumentException("User required.")</c>; that exception is swallowed
|
|
||||||
/// downstream, so an empty user made the timed auto-unshelve SILENTLY no-op — a TimedShelve would never
|
|
||||||
/// auto-expire. Asserting a non-empty user here pins the value the state machine requires.
|
|
||||||
/// </para></summary>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task OnTimedUnshelve_with_system_context_returns_good_and_routes_unshelve()
|
public async Task OnTimedUnshelve_with_system_context_returns_good_and_routes_nothing()
|
||||||
{
|
{
|
||||||
var (host, server) = await BootAsync();
|
var (host, server) = await BootAsync();
|
||||||
var nm = server.NodeManager!;
|
var nm = server.NodeManager!;
|
||||||
@@ -299,14 +295,13 @@ public sealed class AlarmCommandRouterTests : IDisposable
|
|||||||
var result = condition.OnTimedUnshelve!(ctx, condition);
|
var result = condition.OnTimedUnshelve!(ctx, condition);
|
||||||
|
|
||||||
result.ShouldBe(ServiceResult.Good);
|
result.ShouldBe(ServiceResult.Good);
|
||||||
captured.Count.ShouldBe(1);
|
captured.ShouldBeEmpty();
|
||||||
var cmd = captured[0];
|
|
||||||
cmd.AlarmId.ShouldBe("alm-tu"); // == ScriptedAlarmId / condition NodeId identifier
|
// Positive control: the router is live for a genuine operator Unshelve on the same condition.
|
||||||
cmd.Operation.ShouldBe("Unshelve");
|
var operatorCtx = SessionContext(server, "grace", OpcUaDataPlaneRoles.AlarmAck);
|
||||||
// System-initiated (no client principal), but the user MUST be non-empty so ApplyUnshelve accepts it.
|
condition.OnShelve!(operatorCtx, condition, shelving: false, oneShot: false, shelvingTime: 0.0)
|
||||||
cmd.User.ShouldNotBeNullOrWhiteSpace();
|
.ShouldBe(ServiceResult.Good);
|
||||||
cmd.User.ShouldBe("system");
|
captured.Select(c => c.Operation).ShouldBe(["Unshelve"]);
|
||||||
cmd.UnshelveAtUtc.ShouldBeNull();
|
|
||||||
|
|
||||||
await host.DisposeAsync();
|
await host.DisposeAsync();
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-1
@@ -102,8 +102,14 @@ public sealed class DriverHostActorBootFromCacheTests : RuntimeActorTestBase
|
|||||||
|
|
||||||
// Barrier: diagnostics are served only after PreStart's boot work ran, so the absence below is
|
// Barrier: diagnostics are served only after PreStart's boot work ran, so the absence below is
|
||||||
// asserted after the boot path had its chance to Tell (positive control is the valid-bytes test).
|
// asserted after the boot path had its chance to Tell (positive control is the valid-bytes test).
|
||||||
AskDiagnostics(actor);
|
var snapshot = AskDiagnostics(actor);
|
||||||
publishProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
publishProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||||
|
|
||||||
|
// Review R1 m4: nothing was applied, so the node is NOT running from cache and has NOT adopted the
|
||||||
|
// cached revision (Stale with no configuration, retrying the ConfigDb) — the valid-bytes test above
|
||||||
|
// is the control that both flip when the bytes are usable.
|
||||||
|
snapshot.RunningFromCache.ShouldBeFalse();
|
||||||
|
snapshot.CurrentRevision.ShouldBeNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
+33
@@ -111,6 +111,39 @@ public sealed class DriverHostActorFetchAndCacheBootstrapTests : RuntimeActorTes
|
|||||||
fetcher.Calls.ShouldBe(1);
|
fetcher.Calls.ShouldBe(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #601 / review R1 m4 — a cache row whose bytes are malformed applied nothing, so its revision must not
|
||||||
|
/// become current: the boot lands Steady-with-no-revision (as for an empty cache), and a re-dispatch of
|
||||||
|
/// the SAME revision really applies (fetches) instead of being acked by the "already at this rev"
|
||||||
|
/// short-circuit. <see cref="WithCachedArtifact_RestoresServedState_WithoutReadingCentralSql"/> is the
|
||||||
|
/// control: valid bytes do adopt the revision.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void WithMalformedCachedBytes_TheRevisionIsNotAdopted_AndARedispatchOfItActuallyApplies()
|
||||||
|
{
|
||||||
|
var cached = new CachedDeploymentArtifact(
|
||||||
|
DeploymentId.NewId().ToString(), CachedRev.Value,
|
||||||
|
System.Text.Encoding.UTF8.GetBytes("{\"DriverInstances\": ["), DateTimeOffset.UtcNow.AddHours(-1));
|
||||||
|
var fetcher = new RecordingFetcher(Artifact());
|
||||||
|
var coordinator = CreateTestProbe();
|
||||||
|
|
||||||
|
var actor = Sys.ActorOf(DriverHostActor.Props(
|
||||||
|
new ThrowingDbFactory(), TestNode, coordinator.Ref,
|
||||||
|
localRoles: new HashSet<string> { "driver" },
|
||||||
|
deploymentArtifactCache: new StubArtifactCache(cached),
|
||||||
|
fetchAndCacheMode: true,
|
||||||
|
artifactFetcher: fetcher));
|
||||||
|
|
||||||
|
var snapshot = AskDiagnostics(actor);
|
||||||
|
snapshot.CurrentRevision.ShouldBeNull();
|
||||||
|
snapshot.RunningFromCache.ShouldBeFalse();
|
||||||
|
|
||||||
|
actor.Tell(new DispatchDeployment(DeploymentId.Parse(cached.DeploymentId), CachedRev, CorrelationId.NewId()));
|
||||||
|
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
||||||
|
fetcher.Calls.ShouldBe(1);
|
||||||
|
AskDiagnostics(actor).CurrentRevision.ShouldBe(CachedRev);
|
||||||
|
}
|
||||||
|
|
||||||
private NodeDiagnosticsSnapshot AskDiagnostics(IActorRef actor)
|
private NodeDiagnosticsSnapshot AskDiagnostics(IActorRef actor)
|
||||||
{
|
{
|
||||||
var probe = CreateTestProbe();
|
var probe = CreateTestProbe();
|
||||||
|
|||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
using Akka.Actor;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Review R1 m2 (Gitea #597) — the Primary data-plane verdict published to <see cref="IDriverPrimaryRoleSource"/>
|
||||||
|
/// (read by the native-ack gate and the Sparkplug primary-host claim) mirrors the host's own live gate from the
|
||||||
|
/// moment the host starts, not only from the first redundancy snapshot.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <c>RedundancyRoleView</c> is seeded with the single-node verdict (allow). Before this fix nothing published to
|
||||||
|
/// it until a snapshot arrived, so on a pair both nodes accepted a native ack through the whole boot window while
|
||||||
|
/// <c>DriverHostActor</c> — which evaluates the driver member count live — denied the upstream route on the
|
||||||
|
/// non-Primary. Assertions are on what was PUBLISHED: the seed already reads <c>true</c>, so a current-value
|
||||||
|
/// assertion of the single-node case would pass with no publish at all.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class DriverHostActorPrimaryRoleSourceTests : RuntimeActorTestBase
|
||||||
|
{
|
||||||
|
private static readonly NodeId TestNode = NodeId.Parse("driver-primary-source-test");
|
||||||
|
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
|
/// <summary>On a pair, an unknown role publishes DENY at start — before any snapshot. The later Primary snapshot
|
||||||
|
/// is the positive control: the source is wired, and the deny was the verdict, not a missing publish.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void A_paired_host_publishes_deny_before_the_first_snapshot()
|
||||||
|
{
|
||||||
|
var source = new RecordingPrimaryRoleSource();
|
||||||
|
var host = SpawnHost(source, () => 2);
|
||||||
|
|
||||||
|
AwaitAssert(() => source.Published.ShouldBe([false]), duration: Timeout);
|
||||||
|
|
||||||
|
host.Tell(Snapshot(RedundancyRole.Primary));
|
||||||
|
AwaitAssert(() => source.Published.ShouldBe([false, true]), duration: Timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A single-driver node keeps the allow posture — it is published, not merely seeded.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void A_single_node_host_publishes_allow_at_start()
|
||||||
|
{
|
||||||
|
var source = new RecordingPrimaryRoleSource();
|
||||||
|
SpawnHost(source, () => 1);
|
||||||
|
|
||||||
|
AwaitAssert(() => source.Published.ShouldBe([true]), duration: Timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A driver peer coming Up while the role is still unknown flips the published verdict to deny, with no
|
||||||
|
/// snapshot involved — the membership re-publish path.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void A_peer_joining_before_any_snapshot_flips_the_published_verdict_to_deny()
|
||||||
|
{
|
||||||
|
var source = new RecordingPrimaryRoleSource();
|
||||||
|
var members = 1;
|
||||||
|
var host = SpawnHost(source, () => Volatile.Read(ref members));
|
||||||
|
AwaitAssert(() => source.Published.ShouldBe([true]), duration: Timeout);
|
||||||
|
|
||||||
|
Volatile.Write(ref members, 2);
|
||||||
|
host.Tell(DriverHostActor.RepublishPrimaryVerdict.Instance);
|
||||||
|
|
||||||
|
AwaitAssert(() => source.Published.ShouldBe([true, false]), duration: Timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IActorRef SpawnHost(IDriverPrimaryRoleSource source, Func<int> driverMemberCount) =>
|
||||||
|
Sys.ActorOf(DriverHostActor.Props(
|
||||||
|
NewInMemoryDbFactory(),
|
||||||
|
TestNode,
|
||||||
|
ackRouter: null,
|
||||||
|
driverMemberCountProvider: driverMemberCount,
|
||||||
|
primaryRoleSource: source));
|
||||||
|
|
||||||
|
private static RedundancyStateChanged Snapshot(RedundancyRole role) =>
|
||||||
|
new(
|
||||||
|
[
|
||||||
|
new NodeRedundancyState(TestNode, role,
|
||||||
|
IsClusterLeader: role == RedundancyRole.Primary,
|
||||||
|
IsDriverPrimary: role == RedundancyRole.Primary,
|
||||||
|
AsOfUtc: DateTime.UtcNow),
|
||||||
|
],
|
||||||
|
CorrelationId.NewId());
|
||||||
|
|
||||||
|
/// <summary>Remembers every published verdict, so tests assert on the actor's action, not on the seed.</summary>
|
||||||
|
private sealed class RecordingPrimaryRoleSource : IDriverPrimaryRoleSource
|
||||||
|
{
|
||||||
|
private readonly List<bool> _published = [];
|
||||||
|
private readonly Lock _gate = new();
|
||||||
|
|
||||||
|
/// <summary>Every value published so far, in order.</summary>
|
||||||
|
public IReadOnlyList<bool> Published
|
||||||
|
{
|
||||||
|
get { lock (_gate) return _published.ToArray(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool ShouldActAsPrimary
|
||||||
|
{
|
||||||
|
get { lock (_gate) return _published.Count == 0 || _published[^1]; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public void PublishPrimaryRole(bool shouldActAsPrimary)
|
||||||
|
{
|
||||||
|
lock (_gate) _published.Add(shouldActAsPrimary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
@@ -227,6 +227,77 @@ public sealed class DriverHostActorUnreadableArtifactTests : RuntimeActorTestBas
|
|||||||
replayed.ShouldContain("other-node-driver");
|
replayed.ShouldContain("other-node-driver");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #609 (review R1 M1) — the Resilience half of the eviction must SURVIVE the next resilience-publisher
|
||||||
|
/// tick. <c>DriverResilienceStatusPublisherService</c> re-emits <c>tracker.Snapshot()</c> into the hub
|
||||||
|
/// every interval, so evicting only the hub cache was undone within one tick: the host must also
|
||||||
|
/// release the instance's tracker state. The tick below is the publisher's own body
|
||||||
|
/// (<c>BuildMessages</c> → <c>hub.Emit</c>) minus the DPS publish, run against the REAL factory +
|
||||||
|
/// tracker the Host wires. The neighbour instance is the positive control: it is still emitted by
|
||||||
|
/// the same tick, so an absent drv-1 is not an empty tick.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Removing_a_driver_instance_releases_its_resilience_state_so_the_next_publisher_tick_omits_it()
|
||||||
|
{
|
||||||
|
var db = NewInMemoryDbFactory();
|
||||||
|
var factory = new SubscribingDriverFactory("Modbus");
|
||||||
|
var hub = new ZB.MOM.WW.OtOpcUa.Runtime.Telemetry.TelemetryLocalHub();
|
||||||
|
var tracker = new ZB.MOM.WW.OtOpcUa.Core.Resilience.DriverResilienceStatusTracker();
|
||||||
|
var invokerFactory = new ZB.MOM.WW.OtOpcUa.Core.Resilience.DriverCapabilityInvokerFactory(
|
||||||
|
new ZB.MOM.WW.OtOpcUa.Core.Resilience.DriverResiliencePipelineBuilder(statusTracker: tracker),
|
||||||
|
tracker,
|
||||||
|
_ => ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTier.A);
|
||||||
|
var coordinator = CreateTestProbe();
|
||||||
|
var actor = Sys.ActorOf(DriverHostActor.Props(
|
||||||
|
db, TestNode, coordinator.Ref,
|
||||||
|
driverFactory: factory,
|
||||||
|
localRoles: new HashSet<string> { "driver" },
|
||||||
|
invokerFactory: invokerFactory,
|
||||||
|
telemetryHub: hub));
|
||||||
|
|
||||||
|
actor.Tell(new DispatchDeployment(SeedV3Deployment(db, RevA), RevA, CorrelationId.NewId()));
|
||||||
|
coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
||||||
|
|
||||||
|
// Resilience counters for the running driver and for an instance this host does not own.
|
||||||
|
tracker.RecordBreakerOpen("drv-1", "dev1", DateTime.UtcNow);
|
||||||
|
tracker.RecordFailure("neighbour-drv", "dev9", DateTime.UtcNow);
|
||||||
|
PublisherTick(tracker, hub);
|
||||||
|
ReplayedResilienceIds(hub).ShouldContain("drv-1"); // control: drv-1 is primed before the removal
|
||||||
|
|
||||||
|
actor.Tell(new DispatchDeployment(SeedDriverlessDeployment(db, RevB), RevB, CorrelationId.NewId()));
|
||||||
|
coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
||||||
|
AwaitAssert(
|
||||||
|
() => AskDiagnostics(actor).Drivers.Select(d => d.Name).ShouldNotContain("drv-1"),
|
||||||
|
duration: Timeout);
|
||||||
|
|
||||||
|
// The publisher's next tick, after the removal.
|
||||||
|
PublisherTick(tracker, hub);
|
||||||
|
|
||||||
|
var replayed = ReplayedResilienceIds(hub);
|
||||||
|
replayed.ShouldNotContain("drv-1");
|
||||||
|
replayed.ShouldContain("neighbour-drv");
|
||||||
|
|
||||||
|
static void PublisherTick(
|
||||||
|
ZB.MOM.WW.OtOpcUa.Core.Resilience.DriverResilienceStatusTracker t,
|
||||||
|
ZB.MOM.WW.OtOpcUa.Runtime.Telemetry.TelemetryLocalHub h)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
foreach (var e in t.Snapshot())
|
||||||
|
h.Emit(new ZB.MOM.WW.OtOpcUa.Runtime.Telemetry.TelemetryItem.Resilience(new(
|
||||||
|
e.DriverInstanceId, e.HostName, e.Snapshot.IsBreakerOpen, e.Snapshot.ConsecutiveFailures,
|
||||||
|
e.Snapshot.CurrentInFlight, e.Snapshot.LastBreakerOpenUtc, e.Snapshot.LastSampledUtc, now)));
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<string> ReplayedResilienceIds(ZB.MOM.WW.OtOpcUa.Runtime.Telemetry.TelemetryLocalHub h)
|
||||||
|
{
|
||||||
|
using var sub = h.Subscribe(64);
|
||||||
|
var ids = new List<string>();
|
||||||
|
while (sub.Reader.TryRead(out var i))
|
||||||
|
if (i is ZB.MOM.WW.OtOpcUa.Runtime.Telemetry.TelemetryItem.Resilience r) ids.Add(r.E.DriverInstanceId);
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Spawns the host with the subscribing factory, dispatches <paramref name="deploymentId"/> and
|
/// <summary>Spawns the host with the subscribing factory, dispatches <paramref name="deploymentId"/> and
|
||||||
/// waits for the Applied ACK so the child + the initial SubscribeBulk pass are in place.</summary>
|
/// waits for the Applied ACK so the child + the initial SubscribeBulk pass are in place.</summary>
|
||||||
private (IActorRef Actor, Akka.TestKit.TestProbe Coordinator) SpawnHostAndApply(
|
private (IActorRef Actor, Akka.TestKit.TestProbe Coordinator) SpawnHostAndApply(
|
||||||
|
|||||||
+9
-4
@@ -109,7 +109,8 @@ public sealed class AlarmHistorianRegistrationTests
|
|||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Standing down is only safe because a peer holds the same rows and will send them
|
/// Standing down is only safe because a peer holds the same rows and will send them
|
||||||
/// instead. Without <c>LocalDb:Replication:PeerAddress</c> there is no such peer: the rows
|
/// instead. Without an authenticated sync path (<c>ApiKey</c> plus a dial or a listener —
|
||||||
|
/// <c>LocalDbReplicationPolicy.SharesRowsWithPeer</c>) there is no such peer: the rows
|
||||||
/// are here and nowhere else, so any deferral means nobody ever delivers them.
|
/// are here and nowhere else, so any deferral means nobody ever delivers them.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
@@ -124,14 +125,18 @@ public sealed class AlarmHistorianRegistrationTests
|
|||||||
// Neither key: an unpaired node. Its rows exist nowhere else.
|
// Neither key: an unpaired node. Its rows exist nowhere else.
|
||||||
[InlineData(null, null, null, true)]
|
[InlineData(null, null, null, true)]
|
||||||
// The dialing half of a pair — gated, because its partner holds the same rows.
|
// The dialing half of a pair — gated, because its partner holds the same rows.
|
||||||
[InlineData("http://peer:9001", null, null, false)]
|
[InlineData("http://peer:9001", null, "k", false)]
|
||||||
// The LISTENING half. Same shared queue, but it never dials, so a PeerAddress-only test would
|
// The LISTENING half. Same shared queue, but it never dials, so a PeerAddress-only test would
|
||||||
// wrongly class it as unpaired and leave it permanently ungated. It is identified by the shared
|
// wrongly class it as unpaired and leave it permanently ungated. Key + listen port = a peer.
|
||||||
// ApiKey (the same predicate LocalDbSetup registers tables on), not by its listen port.
|
|
||||||
[InlineData(null, "9001", "k", false)]
|
[InlineData(null, "9001", "k", false)]
|
||||||
// A listen port with NO key: the fail-closed passive endpoint rejects every inbound stream, so the
|
// A listen port with NO key: the fail-closed passive endpoint rejects every inbound stream, so the
|
||||||
// queue is shared with nobody and the node must keep draining (#603: this used to be gated).
|
// queue is shared with nobody and the node must keep draining (#603: this used to be gated).
|
||||||
[InlineData(null, "9001", null, true)]
|
[InlineData(null, "9001", null, true)]
|
||||||
|
// A dial with NO key: the peer's fail-closed endpoint rejects it — shares nothing, keeps draining.
|
||||||
|
[InlineData("http://peer:9001", null, null, true)]
|
||||||
|
// A key with NEITHER path (review R1 m3: e.g. a fleet-wide secret on an unpaired node) — no listener,
|
||||||
|
// no dial, so no peer holds these rows. Registered for capture, but must keep draining.
|
||||||
|
[InlineData(null, null, "k", true)]
|
||||||
public async Task Only_an_unreplicated_node_ignores_the_role_view(
|
public async Task Only_an_unreplicated_node_ignores_the_role_view(
|
||||||
string? peerAddress, string? syncListenPort, string? apiKey, bool expectDrain)
|
string? peerAddress, string? syncListenPort, string? apiKey, bool expectDrain)
|
||||||
{
|
{
|
||||||
|
|||||||
+47
-12
@@ -4,29 +4,64 @@ using Xunit;
|
|||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Historian;
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Historian;
|
||||||
|
|
||||||
/// <summary>#603: the single "is this node's LocalDb replicated" predicate shared by table registration and the alarm drain gate.</summary>
|
/// <summary>
|
||||||
|
/// #603 + review R1 m3: the two "is this node's LocalDb replicated" predicates — permissive for table
|
||||||
|
/// registration (<see cref="LocalDbReplicationPolicy.CapturesChanges"/>), strict for the alarm drain gate
|
||||||
|
/// (<see cref="LocalDbReplicationPolicy.SharesRowsWithPeer"/>).
|
||||||
|
/// </summary>
|
||||||
public sealed class LocalDbReplicationPolicyTests
|
public sealed class LocalDbReplicationPolicyTests
|
||||||
{
|
{
|
||||||
|
private const string Peer = "LocalDb:Replication:PeerAddress";
|
||||||
|
private const string Key = "LocalDb:Replication:ApiKey";
|
||||||
|
private const string Port = "LocalDb:SyncListenPort";
|
||||||
|
|
||||||
private static IConfiguration Config(params (string Key, string? Value)[] pairs) =>
|
private static IConfiguration Config(params (string Key, string? Value)[] pairs) =>
|
||||||
new ConfigurationBuilder()
|
new ConfigurationBuilder()
|
||||||
.AddInMemoryCollection(pairs.Select(p => new KeyValuePair<string, string?>(p.Key, p.Value)))
|
.AddInMemoryCollection(pairs.Select(p => new KeyValuePair<string, string?>(p.Key, p.Value)))
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
/// <summary>PeerAddress OR ApiKey counts; the listen port alone and blank values do not.</summary>
|
/// <summary>Registration: PeerAddress OR ApiKey counts; the listen port alone and blank values do not.</summary>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("LocalDb:Replication:PeerAddress", "http://peer:9001", true)]
|
[InlineData(Peer, "http://peer:9001", true)]
|
||||||
[InlineData("LocalDb:Replication:ApiKey", "k", true)]
|
[InlineData(Key, "k", true)]
|
||||||
[InlineData("LocalDb:SyncListenPort", "9001", false)]
|
[InlineData(Port, "9001", false)]
|
||||||
[InlineData("LocalDb:Replication:ApiKey", " ", false)]
|
[InlineData(Key, " ", false)]
|
||||||
public void IsConfigured_follows_the_PeerAddress_or_ApiKey_rule(string key, string value, bool expected)
|
public void CapturesChanges_follows_the_PeerAddress_or_ApiKey_rule(string key, string value, bool expected)
|
||||||
{
|
{
|
||||||
LocalDbReplicationPolicy.IsConfigured(Config((key, value))).ShouldBe(expected);
|
LocalDbReplicationPolicy.CapturesChanges(Config((key, value))).ShouldBe(expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>No replication keys at all is an unpaired node.</summary>
|
/// <summary>Drain gate: ApiKey AND (PeerAddress OR SyncListenPort > 0).</summary>
|
||||||
[Fact]
|
[Theory]
|
||||||
public void IsConfigured_is_false_for_an_empty_configuration()
|
[InlineData("http://peer:9001", "k", null, true)] // dialling half of a pair
|
||||||
|
[InlineData(null, "k", "9001", true)] // listening half of a pair
|
||||||
|
[InlineData("http://peer:9001", "k", "9001", true)] // both
|
||||||
|
[InlineData(null, "k", null, false)] // key, no path: shares nothing (review R1 m3)
|
||||||
|
[InlineData(null, "k", "0", false)] // port 0 = no listener
|
||||||
|
[InlineData(null, "k", "nope", false)] // unparseable port = no listener
|
||||||
|
[InlineData("http://peer:9001", null, "9001", false)] // paths, no key: both auth directions fail closed
|
||||||
|
[InlineData(null, " ", "9001", false)] // blank key
|
||||||
|
public void SharesRowsWithPeer_needs_a_key_and_a_sync_path(string? peer, string? key, string? port, bool expected)
|
||||||
{
|
{
|
||||||
LocalDbReplicationPolicy.IsConfigured(Config()).ShouldBeFalse();
|
LocalDbReplicationPolicy.SharesRowsWithPeer(Config((Peer, peer), (Key, key), (Port, port))).ShouldBe(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Every node the gate treats as sharing rows also captures — never a gated node with no capture.</summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("http://peer:9001", "k", null)]
|
||||||
|
[InlineData(null, "k", "9001")]
|
||||||
|
public void A_node_sharing_rows_always_captures(string? peer, string? key, string? port)
|
||||||
|
{
|
||||||
|
var config = Config((Peer, peer), (Key, key), (Port, port));
|
||||||
|
LocalDbReplicationPolicy.SharesRowsWithPeer(config).ShouldBeTrue();
|
||||||
|
LocalDbReplicationPolicy.CapturesChanges(config).ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>No replication keys at all is an unpaired node under both predicates.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Both_predicates_are_false_for_an_empty_configuration()
|
||||||
|
{
|
||||||
|
LocalDbReplicationPolicy.CapturesChanges(Config()).ShouldBeFalse();
|
||||||
|
LocalDbReplicationPolicy.SharesRowsWithPeer(Config()).ShouldBeFalse();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user