Commit Graph

4 Commits

Author SHA1 Message Date
Joseph Doherty d9b23e0b95 feat(opcua): per-item delivery for folder event items; folder ConditionRefresh gets the #854 cut (#855)
Condition events reaching one of our notifiers no longer go through the SDK
MonitoredNode2 channel: the walk serves the attached items itself, per item and
per event (audit guard, uncached INodeManager3 verdict, item gate/cut,
QueueEvent), as the Server-object path does. No notifier of ours defers any
more; the #838 deferral queue and the #854 cut stay as the safety net and
folder items share the cut (RefreshDirectItems, flag on and off). An event
whose walk suspends is marked deferred before it suspends.

Tests produce deferral with a notifier-stall probe instead of holding a folder
item's SDK lock (which would now block the reporter). New
FolderConditionRefreshWireTests; design note in docs/plans.

Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
2026-11-11 12:18:11 -05:00
Joseph Doherty 284358a2e2 test(opcua): ConditionRefresh stress runs until deferral was actually exercised (#838 follow-on)
Under load 40 refreshes could finish before any stall overlapped a report, failing the 'stress must have
run with deferred events' precondition. Keep refreshing (>=40, ceiling 90 s) until deferral is seen.

Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
2026-11-11 10:36:49 -05:00
Joseph Doherty fe48458a41 fix(opcua): Server-object ConditionRefresh off Lock unless deferring; cut path O(conditions) with replay queued off Lock (#854 review)
Review of 1622d07f:
1. Perf: every i=2253 refresh ran the whole notifier walk + permission checks +
   QueueEvent under Lock. Now, with nothing deferred (the common case), the refresh is
   the SDK-shaped walk off Lock (Lock only to copy the root notifiers), kept only if no
   deferral episode existed at any moment of it (count 0 before and after, episode
   unchanged); otherwise the cut path also runs and its later replay supersedes. The
   cut path takes ACL/role verdicts off Lock, then one short Lock hold: watermark +
   frozen snapshots of Retain conditions from _alarmConditions (no tree walk) + per-item
   cut and "replay pending" marker; the replay is queued OFF Lock, one event per hold of
   the item gate. While pending, a condition event newer than the cut marks its
   condition so the older frozen entry is dropped rather than following it. A
   concurrent fast-path refresh cancels a pending cut replay of the same item.
   Measured, 3000 retained conditions, longest Lock wait seen by another thread during
   one refresh: 1622d07f 95-106 ms idle / 102-127 ms deferring; now 0-3 ms idle /
   23-32 ms deferring.
2. ACL: a retained condition omitted from the replay for want of a grant is never
   withheld (RefreshCut.RetainedAtCut); only Retain=true events of a condition omitted
   because it was NOT retained are.
3. One gate/cut state per item for its lifetime (ConditionalWeakTable keyed by the
   item): an unsubscribe + resubscribe between refresh and drain keeps the cut.
4. Cuts carry their deferral episode; ClearStaleRefreshCuts runs when the count
   returns to zero, and a stale-episode cut is inert.
5. Dispose guard for the custom refresh (ObjectDisposedException once disposing;
   Dispose waits for refreshes in flight).
6. Docs: a refresh waits at most for one in-progress QueueEvent on the item.

Tests: resubscribe variants, ACL-grant-lands test, pending-replay probe test,
dispose test, 3000-condition Lock-hold measurement with a probe pinning that neither
path queues its replay under Lock; stress invariant tightened (never backwards outside a
refresh; each refresh ends on the newest delivered state) and tolerant of the SDK's own
BadRefreshInProgress overlap refusal. Each new test fails against its targeted mutation.
2026-11-11 07:28:08 -05:00
Joseph Doherty 1622d07f2e fix(opcua): ConditionRefresh on a Server-object item is not overtaken by older deferred condition events (#854)
While a folder subscriber's SDK channel is full, a condition's later events wait in
its #838 deferral queue and reach i=2253 items only when the drain gets to them, off
Lock. A ConditionRefresh in that window replayed the current state, then the older
deferred events followed RefreshEnd (stale state, or a condition the refresh had just
dropped re-added until the newest drained).

- Condition events are reported as ConditionEventSnapshot (an InstanceStateSnapshot
  carrying a report sequence, condition NodeId and Retain); events queued behind a
  deferred report are marked Deferred.
- Server-object refresh (ConditionRefresh and ConditionRefresh2, flag on AND off) now
  runs in RefreshServerObjectItems: watermark, "anything deferred?", the replay and its
  queueing in ONE Lock hold, so the replay is exactly the state at the cut. Per item it
  queues the replay and installs a RefreshCut under the item's gate.
- OnConditionReportEvent holds that gate across "superseded? -> QueueEvent" for
  deferred events only. Withheld from that item alone: events at/below the cut whose
  condition the replay re-sent, or Retain=true events of a condition it omitted; an
  older Retain=false event of an omitted condition is still delivered. Queues, other
  Server-object items and folder items are untouched; refresh never waits for a drain.
- Lock order Lock -> item gate -> SDK item lock, same as a synchronous report.
- Meter otopcua.opcua.condition_events.superseded.
- Folder items keep a documented residual (SDK channel backlog + memoised per-item
  verdict leave no per-event hook).

Tests: ConditionRefreshDeferralWireTests (deterministic overload by holding the folder
item's SDK lock; 6 variants fail before the fix; probe-driven gate test fails with the
gate removed; concurrent reports/refreshes/stall stress test fails before the fix).
2026-11-11 06:37:22 -05:00