docs: perf roadmap remainder — outcomes + follow-up register

This commit is contained in:
Joseph Doherty
2026-08-16 05:08:53 -04:00
parent 451273c860
commit e75bb38734
5 changed files with 335 additions and 6 deletions
+48 -2
View File
@@ -57,12 +57,58 @@ Alarm predicates reuse the same Roslyn sandbox as virtual tags — `ScriptEvalua
Evaluation cadence:
- On every upstream tag change that any alarm's input set references (`OnUpstreamChange` → `ReevaluateAsync`). The engine maintains an inverse index `tag path → alarm ids` (`_alarmsReferencing`); only affected alarms re-run.
- On a 5-second shelving-check timer (`_shelvingTimer`) for timed-shelve expiry.
- On every upstream tag change that any alarm's input set references (`OnUpstreamChange` → the dirty set → `DrainDirtyAsync` → `ReevaluateAsync`). The engine maintains an inverse index `tag path → alarm ids` (`_alarmsReferencing`); only affected alarms re-run.
- On a 5-second shelving-check timer (`_shelvingTimer`) for timed-shelve expiry. This is **not** routed through the dirty set — it remains an independent producer contending for the same evaluation gate.
- At `LoadAsync` for every alarm, to re-derive `ActiveState` per plan decision #14 (startup recovery).
If a predicate throws or times out, the engine logs the failure and leaves the prior `ActiveState` intact — it does not synthesise a clear. Operators investigating a broken predicate should never see a phantom clear preceding the error.
### Dirty-set coalescing
Every upstream push used to spawn its **own** fire-and-forget re-evaluation, each allocating a fresh
array of affected alarm ids and each queueing on the single non-reentrant evaluation gate. N pushes
arriving during one slow evaluation meant **N serialized re-evaluations**, every one of them re-reading
the same latest values and every one paying the per-predicate timeout machinery again.
A push now unions the affected alarm ids into a `HashSet<string> _dirtyAlarmIds` under a short leaf lock
and, if no drain is already scheduled, starts one. The drain loops: take the set, swap in an empty one,
release the lock, evaluate that batch, repeat until the set is empty. Exactly one drainer runs at a time.
- **There is no timer and no wall-clock window.** A push landing on an idle engine is evaluated
immediately; the coalescing window is exactly "whatever queued while the gate was busy".
- **"Latest values win" is structural, not a heuristic.** No per-push value snapshot is carried into the
drain. Every push writes the newest value into `_valueCache` synchronously, and the surviving
evaluation refills its scratch read-cache from `_valueCache` *at drain time, inside the gate* — so it
reads whatever the newest push wrote. The collapse is loss-free by construction.
- **Transition semantics are unchanged**, because evaluation was already level-triggered: the old code
never read the pushed value either, it always re-read the cache. All N of those serialized passes
already saw the same latest values, so the dirty set removes redundant work, not information.
- **An alarm is evaluated at most once per pass** even when ten of its input tags changed — the fan-in
across inputs dedups, not just repeat pushes of one tag.
- ⚠️ **What can still be collapsed away** (and always could be) is an intermediate state the engine would
have transited had it evaluated between pushes. With a slow predicate or a blocked store, a value that
crosses a threshold and crosses back before the drain reaches it produces **no transition and no
event**; the alarm settles on the latest value. The #478 quality-bucket tracking sees only the
coalesced-latest bucket for the same reason.
- **Batch order is unordered** — the batch comes out of a `HashSet`, so alarms within a pass are no longer
evaluated in push order. Emissions still fire strictly after the gate is released.
#### Why the store write was NOT moved behind a write-behind queue
The review that prompted this work suggested a write-behind queue for `IAlarmStateStore.SaveAsync` —
deferring the Part 9 condition-state persist (`LocalDbAlarmConditionStateStore`, the replicated
`alarm_condition_state` table) out of the evaluation gate. It was **deliberately rejected**, and the
rationale is recorded in the engine's class doc so it is not re-proposed:
1. **It trades away a real crash-consistency property.** The store write stays *inside* the gate and
*before* the in-memory update, so a store failure leaves the in-memory condition and the persisted one
both at the prior state rather than diverging. That ordering is pinned by
`Store_save_failure_leaves_in_memory_state_unchanged`.
2. **The throughput it would buy is no longer needed.** After coalescing, a `SaveAsync` happens only on a
genuine Part 9 transition, which is rare relative to the push rate — so the gate-held store call is not
on the hot path any more. Write-behind would trade a correctness property for latency the dirty set
already removed.
## Part 9 state machine
`Part9StateMachine` (`Part9StateMachine.cs`) is a pure `static` function set. Every transition takes the current `AlarmConditionState` plus the event, returns a new record and an `EmissionKind`. No I/O, no mutation, trivially unit-testable. Transitions map to OPC UA Part 9: