fix(opcuaclient): #676 re-review — trailing read skips refs delivered to while in flight

1. A per-ref delivery counter (bumped under the delivery lock by every publish path) is
   captured before the trailing read is issued; a ref delivered to since is skipped.
   Without source timestamps the read's sample could not be ordered against a notification
   that landed mid-read, and publishing it regressed the node. Test injects the
   notification inside the mocked read, with and without a read source time (red before).
2. Delivery-lock comment corrected (it serialises across subscriptions, which the SDK
   dispatches on separate workers); PublishCommsLoss now publishes under the lock too.
3. CancelSessionWork cancels without disposing, so a keep-alive thread reading the old
   token cannot hit ObjectDisposedException.
This commit is contained in:
Joseph Doherty
2026-10-11 11:44:12 -04:00
parent 30a9d5f094
commit 27bf4826d6
3 changed files with 94 additions and 23 deletions
+8 -6
View File
@@ -113,13 +113,15 @@ The session keep-alive (`KeepAliveInterval`, default 5 s) is the liveness signal
source timestamp (a server that does not send them) is the server's current state and counts as newer. An **equal**
source timestamp with different content IS published — it is the server's current state, not a replay, and it repairs
the equal-timestamp status-only change the replay guard drops by design. An identical (value, status) is never
republished. An Information line names how many refs it had to repair. The trailing read and the replay-guard disarm
republished. A ref that received ANY delivery while the read was in flight is skipped (a per-ref delivery counter is
captured before the read is issued): without source timestamps the read's sample cannot be ordered against that
notification, and publishing it would regress the node. An Information line names how many refs it had to repair. The trailing read and the replay-guard disarm
timer run on the driver clock and are cancelled when the session is released (re-initialise / shutdown / dispose).
- **No older value is ever delivered after a newer one.** The three paths that publish upstream data — live
notifications, the recovery read and the trailing read — make their "may I publish this?" decision and the publish
inside one driver-wide critical section. A single lock rather than one per ref: a session's notifications arrive on
its publish thread one message at a time, so the only contention is a recovery/trailing read overlapping a
notification, and an uncontended lock costs tens of nanoseconds per notification. Subscribers run inside it — the
- **No older value is ever delivered after a newer one.** The paths that publish upstream data — live notifications,
the recovery read, the trailing read and the comms-loss Bad fan — make their "may I publish this?" decision and the
publish inside one driver-wide critical section. A single lock rather than one per ref: it also serialises delivery
across subscriptions (the SDK dispatches each subscription's messages on its own worker), which is cheap because each
section is a dictionary update plus a subscriber `Tell` and nothing awaits under it. Subscribers run inside it — the
host's handler only `Tell`s an actor — so a subscriber must never block on, or call back into, the driver.
- **The session does not come back** (an upstream restart: the server forgot the session) ⇒ the driver stays
`Degraded` with its read stamp frozen, which is the #527 comms-loss watchdog's trip condition; `DriverInstanceActor`