Merge #597/#599: real UnshelveTime, commented re-ack reports, native ack refused off-Primary, /alerts ack-path test; surgically-added raw nodes announced, equipment-with-references removal keeps references severed; Backing* parity guard
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+66
-20
@@ -292,8 +292,9 @@ branches on native-ness and routes through a dedicated path — separate
|
||||
from the scripted `AlarmCommandRouter`:
|
||||
|
||||
1. **`OtOpcUaNodeManager.HandleNativeAlarmAck`** — gates on the caller's
|
||||
`AlarmAck` role (fails closed: no role → `BadUserAccessDenied`), then
|
||||
dispatches a `NativeAlarmAck(ConditionNodeId, Comment, OperatorUser)`
|
||||
`AlarmAck` role (fails closed: no role → `BadUserAccessDenied`), then on the
|
||||
node's redundancy role (not the Primary → `BadInvalidState`, see
|
||||
§"Acknowledge on a non-Primary node" below), then dispatches a `NativeAlarmAck(ConditionNodeId, Comment, OperatorUser)`
|
||||
to the `NativeAlarmAckRouter` seam (fire-and-forget, non-blocking under
|
||||
the node-manager Lock). `OperatorUser` carries the authenticated
|
||||
session principal's display name.
|
||||
@@ -331,20 +332,38 @@ projection's Retain rule, `Active || !Acked`) after the SDK handler, before the
|
||||
one ack event. ScadaBridge maps `ConfirmedState` into its alarm state, so this
|
||||
is client-visible.
|
||||
|
||||
> **Secondary-node ack flap.** On a node that is not the redundancy Primary,
|
||||
> an OPC UA Acknowledge is applied to the local condition and reported (one
|
||||
> event, Acked), but `DriverHostActor`'s Primary gate drops the route upstream.
|
||||
> The driver never acknowledges, so its next transition for that alarm is
|
||||
> projected with `Acked = false` and the node reverts — with its own event. A
|
||||
> client acking against the Secondary sees acked → unacked. Ack against the
|
||||
> Primary (the higher ServiceLevel) for the ack to stick.
|
||||
> **Acknowledge on a non-Primary node is refused** (Gitea #597). On a node
|
||||
> that is not the redundancy Primary, an OPC UA Acknowledge of a **native**
|
||||
> condition returns **`BadInvalidState`** ("not the redundancy Primary;
|
||||
> acknowledge on the Primary") — nothing is routed, applied or reported. The
|
||||
> verdict is the same data-plane one the device-write gate and
|
||||
> `DriverHostActor`'s ack route use (`PrimaryGatePolicy.ShouldServiceAsPrimary`,
|
||||
> published to `IDriverPrimaryRoleSource` on every redundancy snapshot and
|
||||
> wired onto the node manager by `OtOpcUaServerHostedService`). It is checked
|
||||
> after authorization, so a caller who may not ack learns nothing about the
|
||||
> node's role.
|
||||
>
|
||||
> This replaces a documented flap: the Secondary used to apply and report the
|
||||
> ack locally while `DriverHostActor` dropped the route upstream, so the driver
|
||||
> never acknowledged, its next transition projected `Acked = false`, and the
|
||||
> client saw acked → unacked. An honest refusal is client-visible — a client
|
||||
> (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
|
||||
> ServiceLevel). Scripted conditions are **not** gated: their engine runs on
|
||||
> both nodes, so an ack on the Secondary is real. Residual: the published
|
||||
> verdict changes only on a redundancy snapshot, so in the boot window of a
|
||||
> multi-node cluster (no snapshot yet) the node still accepts, while
|
||||
> `DriverHostActor`'s live check may drop the route — the old flap, limited to
|
||||
> that window.
|
||||
|
||||
> **Best-effort during reconnect.** If a native ack arrives while the owning
|
||||
> driver is `Connecting`/`Reconnecting` (or on a non-Primary node), it is
|
||||
> dropped with a debug log and **not** retried upstream — the OPC UA Part 9
|
||||
> `AlarmConditionState` was already 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 upstream propagation is required.
|
||||
> driver is `Connecting`/`Reconnecting` (or, in the boot window above, on a
|
||||
> 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
|
||||
> 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
|
||||
> upstream propagation is required — a re-ack of a node already showing Acked
|
||||
> is still routed to the driver for exactly this reason.
|
||||
|
||||
### Legacy sub-attribute path
|
||||
|
||||
@@ -412,8 +431,9 @@ reports nothing.
|
||||
`AreEventsMonitored` bookkeeping, which is imprecise for conditions
|
||||
materialised under an already-subscribed folder.
|
||||
- **The SDK's shelve methods never apply shelving state** (they only call
|
||||
`OnShelve`), so the wrapper applies it — with the same
|
||||
`(shelved, oneShot, 0)` mapping the projection uses — before reporting.
|
||||
`OnShelve`), so the wrapper applies it — with the same `(shelved, oneShot)`
|
||||
mapping the projection uses, and a TimedShelve with its requested duration —
|
||||
before reporting.
|
||||
- **The re-projection does not duplicate it.** The engine's (or driver's)
|
||||
answer to the routed command reaches `WriteAlarmCondition`'s delta-gate,
|
||||
which compares against the node's current state — already the new state —
|
||||
@@ -425,7 +445,16 @@ reports nothing.
|
||||
nothing. The SDK accepts acknowledging an acknowledged condition and sets
|
||||
`Confirmed = false` again, so a re-ack also restores the pre-call Confirmed
|
||||
and Retain — silent, and a scripted node never drifts from its engine.
|
||||
AddComment always reports (the comment is the change).
|
||||
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,
|
||||
so it is reported once, with Acked/Confirmed unchanged. Before #597 the
|
||||
comment landed on the node but no subscriber ever saw it.
|
||||
- **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
|
||||
upstream system acknowledged — an ack dropped during a reconnect leaves
|
||||
exactly that state — and re-acking is the documented retry (§"Best-effort
|
||||
during reconnect"). The cost is a duplicate ack upstream for an alarm that
|
||||
was already acknowledged there.
|
||||
- **Native conditions: Acknowledge and AddComment only** — see §"Native alarm
|
||||
acknowledge → AVEVA" above. The other methods are refused
|
||||
(`BadNotSupported`, no event), and a native ack keeps the condition
|
||||
@@ -433,9 +462,26 @@ reports nothing.
|
||||
- **TimedShelve works (scripted).** `Create()` left `MaxTimeShelved` at 0, and
|
||||
the SDK rejects any shelving time above it, so every client TimedShelve
|
||||
failed with `BadShelvingTimeOutOfRange`. It is now `int.MaxValue` ms (≈24.8
|
||||
days). The node applies the shelve with no SDK timer of its own; the engine
|
||||
owns the expiry and its Unshelved re-projection reports the one expiry
|
||||
event.
|
||||
days). The engine owns the expiry and its Unshelved re-projection reports
|
||||
the one expiry event.
|
||||
- **`UnshelveTime` is the real remaining time** (Gitea #597). Both the method
|
||||
path and the projection used to apply a timed shelve with `shelvingTime 0`,
|
||||
which the SDK reads as `MaxTimeShelved` — so every timed shelve advertised
|
||||
≈24.8 days remaining. The method now applies the requested duration, and the
|
||||
engine's projection carries its expiry (`AlarmConditionSnapshot.UnshelveAtUtc`),
|
||||
so `ShelvingState/UnshelveTime` counts down to the engine's expiry whichever
|
||||
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
|
||||
only routes an `Unshelve` (user `system`) back to the engine — a no-op if the
|
||||
engine's 5 s expiry sweep got there first, otherwise it simply lands the
|
||||
expiry on time.
|
||||
- **Accepted, not fixed — the SDK's `EventsMonitored` unit for late conditions**
|
||||
(Gitea #597 item 6, from the #586 review). The SDK's `AreEventsMonitored`
|
||||
counter never reaches a condition materialised under a folder that already had
|
||||
an event subscriber. Our own reporting does not depend on it (above); the one
|
||||
thing it still gates is the SDK's method **audit** event, and only while the
|
||||
server is auditing — which no configuration in this repo enables. Closed as
|
||||
accepted.
|
||||
- Method **audit** events (`AuditConditionAcknowledgeEventType`, …) are still
|
||||
the SDK's, delivered only while the server is auditing over an encrypted
|
||||
channel.
|
||||
|
||||
+12
-3
@@ -105,9 +105,18 @@ mirror **in place** (its MonitoredItems survive); a re-point or effective-name c
|
||||
removes and recreates **that one mirror node**. See [`Uns.md`](Uns.md) and
|
||||
[`plans/2026-08-15-surgical-deploys.md`](plans/2026-08-15-surgical-deploys.md).
|
||||
|
||||
⚠️ A surgically added raw container/tag is materialised but **not** announced —
|
||||
`AnnounceAddedNodes` is UNS-only, so model-aware clients see new raw nodes on their next
|
||||
browse. This mattered less when every add forced a rebuild (and therefore a re-browse).
|
||||
A surgically added raw container/tag is **announced** (Gitea #599): `AnnounceAddedNodes`
|
||||
raises a `GeneralModelChangeEvent(NodeAdded)` in the **Raw** realm for each distinct
|
||||
existing parent of the added nodes (a parent that is itself new in the same deploy is
|
||||
skipped — its own parent is announced, and a re-browse there finds the whole new subtree;
|
||||
a new top-level folder announces itself). UNS-side adds announce in the Uns realm as
|
||||
before. Until #599 raw adds were materialised but never announced, so model-aware clients
|
||||
only saw them on their next browse — invisible while every add still forced a rebuild.
|
||||
|
||||
Removing an equipment that holds UNS references is also surgical: the references are
|
||||
removed (terminal Bad, then `RemoveVariableNode`, which severs the backing raw node's
|
||||
inverse `Organizes` edge) **before** the equipment subtree, and the subtree removal severs
|
||||
the same edge for anything it reaches itself (archreview 03/P5 + 03/S20).
|
||||
|
||||
## The `Calculation` driver
|
||||
|
||||
|
||||
@@ -358,7 +358,7 @@ On allow, the handler publishes a `Commons.OpcUa.AlarmCommand` (containing comma
|
||||
|
||||
### 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. a re-ack — reports nothing). Native (driver-fed) conditions support only Acknowledge and AddComment; their Confirm, shelve and Enable/Disable methods return `BadNotSupported`. 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). 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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user