docs: perf roadmap remainder — outcomes + follow-up register
This commit is contained in:
@@ -748,15 +748,18 @@ Two things to know before relying on any of this:
|
||||
*current* generation rather than the one the session activated under, and re-checks the
|
||||
authenticating key — so revoking a key or a grant stops an open session immediately instead of at
|
||||
the client's next reconnect. On a driver-only node the artifact is the only revocation channel.
|
||||
- `UserAuthorizationState` is built **per call, not per session**, and its LDAP-shaped
|
||||
- `UserAuthorizationState` is built **per decision walk, not per session** — and since the hot-path
|
||||
change it is allocated only on a cache **miss**, so a cache hit never constructs one. Its LDAP-shaped
|
||||
freshness/staleness predicates are switched off EXPLICITLY (`AuthCacheMaxStaleness = MaxValue`).
|
||||
⚠️ Relying on "the stamp is always fresh" was not safe: `MembershipResolvedUtc` comes from
|
||||
`DataPlaneAuthorizer`'s `TimeProvider` while `IsStale` is evaluated against
|
||||
`TriePermissionEvaluator`'s, so **any divergence between the two clocks denies every request** with a
|
||||
verdict indistinguishable from "no grant matched". Found by a test clock two hours ahead.
|
||||
- **Stage 6's hot seams (read/browse/subscribe) go through a per-`(session, node, operation, realm)`
|
||||
decision cache**, flushed when the generation advances. ⚠️ It caches the **grant walk only** — key
|
||||
revocation and expiry are re-checked every call, because expiry moves with the clock rather than
|
||||
decision cache**, cleared wholesale when the generation advances. It is **sharded per session**, so a
|
||||
single client crawling a large tree past the per-session ceiling evicts only its own shard and not
|
||||
every other session's decisions. ⚠️ It caches the **grant walk only** — key revocation and expiry are
|
||||
re-checked **before** the cache lookup on every call, because expiry moves with the clock rather than
|
||||
with a deployment.
|
||||
- **Browse needs ancestor visibility, and it must NOT be a synthesized grant on the ancestors.** A
|
||||
grant deep in the tree is reached by browsing the nodes above it, and the trie inherits downward
|
||||
|
||||
@@ -280,6 +280,36 @@ re-entered this path; the watchdog makes it automatic and unbounded, so a device
|
||||
have leaked ~360 transports at the 10 s retry interval. The shutdown is best-effort: a driver that throws
|
||||
on it still gets its initialise attempt.
|
||||
|
||||
#### The fan-out that watchdog triggers: indexed per driver, and one batch
|
||||
|
||||
`DisconnectObserved` raises `ConnectivityChanged`, and `DriverHostActor` answers it by pushing
|
||||
`BadNoCommunication` at every ref the flapping driver owns — the value fan to the dependency mux, and the
|
||||
alarm fan to the publish actor's condition quality. Both used to answer "which refs are this driver's?"
|
||||
by **scanning the node's entire ref map and filtering** — the maps are keyed by the composite
|
||||
`(DriverInstanceId, RawPath)`, so one driver's flap walked all ~50k keys on a large node to find its own
|
||||
few thousand. Each now has a secondary index, `DriverInstanceId → [(RawPath, node set)]`
|
||||
(`_valueRefsByDriver`, `_alarmRefsByDriver`), so the fan touches only that driver's refs.
|
||||
|
||||
The index cannot drift from its primary, because it is never maintained incrementally: both are cleared
|
||||
and rebuilt whole inside `ApplyDesiredSubscriptions`, in the same pass and the same clear as the maps
|
||||
they index. Two details are load-bearing if you touch that loop — an entry is added **only on set
|
||||
creation** (so a repeated `(DriverInstanceId, RawPath)` contributes exactly one), and the node
|
||||
`HashSet` is held **by reference, not copied**, so nodes registered against the same raw tag later in the
|
||||
same pass (a UNS reference variable, say) are visible through the index with no second bookkeeping step.
|
||||
|
||||
⚠️ **The value fan is now a single message.** It sends one
|
||||
`DriverInstanceActor.AttributeValueBatchPublished` carrying one item per RawPath, where it previously sent
|
||||
one `AttributeValuePublished` per ref — 5 000 messages onto the mux that also carries the node's steady
|
||||
value ingress, every time one driver flapped. Delivery is unchanged: the mux filters the batch per item
|
||||
against the same registered-interest map the per-value receive consulted, so only the framing moved. The
|
||||
**alarm** fan is indexed but deliberately not batched — one driver ref can back several condition NodeIds,
|
||||
so it still emits one `AlarmQualityUpdate` per condition.
|
||||
|
||||
**Consequence worth knowing: `AttributeValuePublished` now has no production sender at all.** This fan was
|
||||
its last one — everything on the subscription path was already wrapped in `AttributeValueBatchPublished`.
|
||||
`DependencyMuxActor` still registers the per-value receive, but in production it is dead code; it stays
|
||||
for tests and direct senders.
|
||||
|
||||
### The connect path feeds the circuit breaker (R2-10 T10 closure)
|
||||
|
||||
`IDriver.InitializeAsync` is routed through the capability invoker as
|
||||
@@ -392,6 +422,52 @@ deployment prerequisites are in [Historian.md](Historian.md).
|
||||
|
||||
---
|
||||
|
||||
## PollGroupEngine — poll pacing and teardown
|
||||
|
||||
[`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/PollGroupEngine.cs`](../src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/PollGroupEngine.cs)
|
||||
|
||||
The shared poll loop behind the seven polling drivers (Modbus, S7, AbCip, AbLegacy, FOCAS, Sql, TwinCAT).
|
||||
Note it lives in `Core.Abstractions`, not under `src/Drivers/`.
|
||||
|
||||
**Pacing is fixed-rate, not fixed-delay.** The loop used to `await Task.Delay(interval)` *after* each
|
||||
poll, so the real cadence was `interval + read time` and drifted with device latency — an 80 ms read on a
|
||||
100 ms interval produced a 180 ms cycle, and a subscription's configured publishing interval was a floor
|
||||
nobody actually got. Each poll is now timed (in a `finally`, so a *failed* poll is measured too) and the
|
||||
next wait is `max(interval − elapsed, 0)`.
|
||||
|
||||
- **An overrun does not skip a cycle and does not burst to catch up.** When the remaining time is zero or
|
||||
negative the loop does not call `Task.Delay` at all — it does a `Task.Yield()`, re-checks cancellation,
|
||||
and starts the next poll immediately. The rate simply degrades to whatever the device sustains. The
|
||||
`Yield` is load-bearing: without it a synchronously-completing reader turns a zero delay into a
|
||||
thread-pool-starving tight loop that never observes cancellation.
|
||||
- **The failure path is exempt.** When `backoffCap` is set *and* there have been consecutive failures, the
|
||||
capped exponential `ConnectionBackoff.ComputeDelay` wins outright — a dead device must not be hammered
|
||||
at fixed rate. (Previously the backoff formula was consulted whenever a cap was configured, including at
|
||||
zero failures where it just returns the base interval.)
|
||||
|
||||
**`OverrunCount` is the signal that a device cannot keep up.** It counts poll cycles whose measured
|
||||
duration exceeded that subscription's publishing interval, engine-wide across every subscription,
|
||||
monotonic and never reset. A non-zero and *climbing* value means the loop is already running flat out.
|
||||
|
||||
- ⚠️ **It conflates a slow success with a slow failure** — a poll that overran because the device was slow
|
||||
and one that overran because it timed out increment the same counter. Read it alongside the health
|
||||
snapshot's error count, not on its own.
|
||||
- **There is no meter and no log**, because `Core.Abstractions` deliberately carries no metrics
|
||||
dependency. The surface is the `OverrunCount` property plus an optional `onOverrun` constructor
|
||||
callback. **No driver wires either today** — a driver-level meter is a recorded follow-up, so treat the
|
||||
counter as latent instrumentation rather than something you can read off a dashboard.
|
||||
|
||||
**Unsubscribe is awaited, not blocked on.** `Unsubscribe` drained the in-flight poll with a synchronous
|
||||
`task.Wait(5s)` — and every driver's `UnsubscribeAsync` called it and returned `Task.CompletedTask`, so
|
||||
that block landed *inside a `DriverInstanceActor` mailbox turn*, stalling the actor for up to five
|
||||
seconds. `UnsubscribeAsync` now awaits `task.WaitAsync(DrainTimeout)` instead. The synchronous
|
||||
`Unsubscribe` is retained for compatibility and has no production caller left; prefer the async form on
|
||||
any path inside an actor turn or a request pipeline. Semantics are identical either way — the loop is
|
||||
cancelled and awaited *before* the CTS is disposed, so no change callback fires after teardown and the
|
||||
CTS is never disposed while a `Task.Delay` still holds its token.
|
||||
|
||||
---
|
||||
|
||||
## Commons — shared cross-cutting primitives
|
||||
|
||||
[`src/Core/ZB.MOM.WW.OtOpcUa.Commons/`](../src/Core/ZB.MOM.WW.OtOpcUa.Commons/)
|
||||
|
||||
+48
-2
@@ -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:
|
||||
|
||||
@@ -1264,3 +1264,152 @@ Wave 4: 28 (←all)
|
||||
apply-path changes (run the existing live-gate recipes afterwards, as items 1–2 did);
|
||||
the HistorianGateway contract change; any change to `redundancy-state`, the deploy
|
||||
classifier, or the ACL trie itself.
|
||||
|
||||
---
|
||||
|
||||
## Outcomes
|
||||
|
||||
All 28 tasks landed on `perf/roadmap-remainder`. Close-out sweep (2026-08-16):
|
||||
`dotnet build ZB.MOM.WW.OtOpcUa.slnx` — **0 errors**, 896 warnings (all pre-existing:
|
||||
`OTOPCUA0001` on deliberate unwrapped driver calls in tests, `xUnit1051`, two `CS8602`);
|
||||
full unit sweep across all 43 `*.Tests` projects — **10 310 passed, 0 failed, 19 skipped**.
|
||||
`*.IntegrationTests` are build-only by repo convention, and the DB-backed suites
|
||||
(`*.Configuration.Tests`, `*.Admin.Tests`, `*.Server.Tests`) ran for real against the
|
||||
reachable central SQL Server at `10.100.0.35,14330` — nothing was skipped for want of a
|
||||
database. The three known load-related flakes
|
||||
(`DriverInstanceActorCommsLossWatchdogTests`, `RawTreeServiceRenameWarningTests`,
|
||||
`RouteNativeAlarmAck_on_non_primary_is_dropped`) all passed on the first run; no project
|
||||
needed an isolated re-run.
|
||||
|
||||
Note for anyone re-running the sweep by globbing `tests/*/*.Tests`: two directories under
|
||||
`tests/Drivers/` — `…Driver.Historian.Wonderware.Tests` and
|
||||
`…Driver.Historian.Wonderware.Client.Tests` — are **empty leftovers** of the retired
|
||||
Wonderware backend, holding only stale `bin`/`obj` and no `.csproj`. They are not in the
|
||||
solution. A glob-driven sweep reports `MSB1003` for them; that is the glob, not a failure.
|
||||
|
||||
Every task's spec review and code review was run and closed before the task was marked
|
||||
done; what follows records only the outcome and any deviation from the plan as written.
|
||||
|
||||
| # | Task | Commit(s) | Outcome / deviation |
|
||||
|---|---|---|---|
|
||||
| 1 | Outbox batch append with group commit | `6ff64ca0` + `73205865` | Done. The follow-up commit made the batch bookkeeping incremental and replaced the durability test with a genuine group-commit pin (a second reader). Deviation: the second-reader durability test is fragile on macOS — see the register. |
|
||||
| 2 | Batched mux→subscriber delivery, single-eval virtual tags | **inside `140bf0c1`** | Done. ⚠️ Its 7 Runtime files were swept into Task 22's commit — see the mixed-commit note below. Both code review Importants (a yielding batch-test fake, mid-batch exception policy) were folded into Task 4's brief and closed there. |
|
||||
| 3 | Parallel drain with bounded concurrency | `a76b4e07` | Done, approved as specified (`DrainConcurrency`, default 8). One inherited pre-existing defect recorded in the register (shutdown cancellation counted as a per-tag failure). |
|
||||
| 4 | Recorder batched ingest — one fsync per burst | `384568b4` + `451273c8` | Done. The follow-up pinned the append-failure branch via three independent mutations and corrected the loss-accounting docs; the recorder's `64` literals were aligned to `1024` there. |
|
||||
| 5 | Historian defaults, config drift, docs | `0298a0b9` | Done. Added the `Historian.md` key table and recorded the cross-repo batch-RPC ceiling. Minor: the pinning test's doc comment overclaims appsettings-drift coverage. |
|
||||
| 6 | Per-session decision-cache shards | `debd44c2` | Done. Replaces the flat cache + wholesale `Clear` with per-session shards. Register: `_globalCount` drift under races; no concurrency stress test. |
|
||||
| 7 | Authorizer hot path — lookup first, lazy deny reasons | `d8a17f2e` | Done. Cache lookup moved ahead of scope construction; deny reasons are built only on the deny path; revocation/expiry stay ahead of the cache. |
|
||||
| 8 | Browse and TranslateBrowsePath compaction | `541a223b` + `aad7583b` | Done — O(n) in-place compaction. The follow-up test pins survivor **order** through the compaction (falsified red first). Minor: the `FanOutTargetIdList` fake couples to the SDK's single-`Add` contract. |
|
||||
| 9 | RevalidateMonitoredItems dedup | `fbe8aaad` | Done. Deviation from spec, deliberately: the dedup key is a **realm-qualified 3-tuple** `(session, node, realm)` rather than the spec's 2-tuple — the realm is carried explicitly at every sink seam and dropping it could collapse two distinct nodes. Register: the empty-identifier pattern is worth tightening. |
|
||||
| 10 | Modbus `InitializeAsync` transport leak | `c3970a2e` + `409a58b6` | Done. The follow-up added the lifecycle gate and replaced a vacuous reprobe test with a non-vacuous one. |
|
||||
| 11 | PollGroupEngine fixed-rate pacing, overrun counter, async unsubscribe | `9cf400f1` + `485f4ea1` | Done. The follow-up widened the fast-reader control's tolerance to one scheduler blip (200 ms interval, ≤1). Register: `NoteOverrun` conflates slow-success with slow-failure; `backoffCap` needs a constructor doc note. |
|
||||
| 12 | S7 narrow-scalar span codec | `8d91833f` | Done. **Plan error corrected:** the plan's spec mentioned `SByte`; S7 has no such scalar in this codec's domain, so it was not implemented. The omission is intentional, not a gap. |
|
||||
| 13 | S7 span planner + grouped `ReadAsync` | `39f67a0f` | Done. Spec review accepted the demotion rule and the 6th (factory-binding) file beyond the plan's file list. ⚠️ The `16` / `220` span defaults are **not live-gated** — see the register. |
|
||||
| 14 | AbCip bounded-parallel reads | `d6a4fbd6` + `d5f573b0` | Done (`MaxConcurrentReads`, default 8). The follow-up added a per-device `ConnectGate` single-flight so cold-connect backoff survives the parallel read window. Minor: a stale `EvictRuntime`-race comment (~L1161); test timing margin is tight on slow CI. |
|
||||
| 15 | Dirty-set coalescing in `ScriptedAlarmEngine` | `5df793e7` | Done. Write-behind persistence was **considered and rejected** — see `ScriptedAlarms.md`. Register: `TrackBackgroundTask` registration-ordering gap (pre-existing), non-volatile `_disposed`. |
|
||||
| 16 | Deployments list projection | `dbc9a132` | Done — `/deployments` no longer materialises artifact blobs to render a list. |
|
||||
| 17 | Drift watermark probe | `684e59d8` | Done. **Design deviation, accepted:** the probe is a `GroupBy(_ => 1)` single-round-trip aggregate rather than the plan's separate count+max queries. Register: that aggregate is verified only against EF InMemory, so the perf claim is unverified against real SQL Server. |
|
||||
| 18 | ScriptTagCatalog TTL snapshot cache | `3795899d` | Done — 5 s snapshot cache. |
|
||||
| 19 | EffectiveNameGuard batch check + bulk add-references | `fa36d64e` | Done. Behaviour deviation recorded: for a **mixed-failure** batch the reported error is now the batch's first failure rather than the per-row one, a minor error-precedence shift. |
|
||||
| 20 | Raw tree tag-name projection | `ba977dbe` | Done — tag rows render from a projection that omits `TagConfig`. |
|
||||
| 21 | Rename script-scan filters in SQL | `fe8d132c` + `ca87329a` | Done. The follow-up added `Distinct` + 200-term chunking with `ScriptId`-keyed cross-batch dedup. Register: the `CHARINDEX`/collation widening needs a SQL-Server-backed test; the worst-case 250 sequential round-trips are undocumented. |
|
||||
| 22 | Pooled context factory + RoleGrants off the circuit-scoped context | `140bf0c1` | Done. ⚠️ This commit **also carries Task 2's files** — see the note below. |
|
||||
| 23 | Fleet/Hosts poll overlap guards | `4b6a93bb` + `63081d52` | Done. The follow-up gave the Fleet page the same async timer disposal the Hosts page has. |
|
||||
| 24 | MQTT unchanged-value suppression | `2c708f55` + `50035b36` | Done. The follow-up pinned the status-code half of the suppression (with falsification) and documented that Sparkplug bypasses it. |
|
||||
| 25 | `DriverHostActor` per-driver index + batched comms-loss fan | `b6275f43` | Done. Consequence for readers: after this, **no production code sends the per-value `AttributeValuePublished`** — the mux's per-value receive is test-only. Minor: cross-sender `RegisterInterest` ordering in one test is not structurally guaranteed; one empty-bucket guard is unreachable. |
|
||||
| 26 | Direct-apply artifact read off the mailbox | `5b92eb0f` | Done. A `BusLogging` comment dispute in review was resolved in the implementer's favour. Register: the Direct-mode SQL read carries no timeout/cancellation; `Bootstrap`'s orphan replay exits `PreStart` in `Applying`. |
|
||||
| 27 | MTConnect sample chunks parse from bytes | `34f836e0` | Done — no per-chunk string copies. |
|
||||
| 28 | Full sweep + plan close-out docs | this commit + the comment-only follow-up | Done. Sweep green; docs updated (`CLAUDE.md`, `security.md`, `DriverLifecycle.md`, `ScriptedAlarms.md`). |
|
||||
|
||||
### The mixed commit `140bf0c1`
|
||||
|
||||
Task 22's implementer ran a bare `git commit` (no pathspec) while Task 2's 7 Runtime files
|
||||
were already staged in the shared index. Both tasks' content therefore lives in
|
||||
**`140bf0c1`**, under Task 22's message
|
||||
(`perf(adminui): pooled DbContext factory; role-grants reads per-call, not per-circuit`),
|
||||
which names only Task 22's half.
|
||||
|
||||
Both halves were fully spec- and code-reviewed at that commit — nothing is unreviewed, and
|
||||
nothing is missing from the branch. **Decision: the history stays as it is.** No empty
|
||||
marker commit was created; an empty commit would add a second misleading object rather than
|
||||
correct the first. This note is the record. If you are archaeologising Task 2 (batched
|
||||
mux→subscriber delivery, single-eval virtual tags), look in `140bf0c1`, not for a commit
|
||||
whose message mentions it.
|
||||
|
||||
### Follow-up register
|
||||
|
||||
Open items the work surfaced but deliberately did not close. None blocks the branch.
|
||||
|
||||
**Cross-repo**
|
||||
|
||||
- **HistorianGateway needs a multi-tag batch `WriteLiveValues` RPC.** Client `0.3.0` exposes
|
||||
only a single-tag call, so bounded parallelism (Task 3's `DrainConcurrency`) is the
|
||||
ceiling — the drain cannot become one round-trip per batch until the gateway contract
|
||||
grows the RPC. Already recorded in [`docs/Historian.md`](../Historian.md).
|
||||
|
||||
**Drivers**
|
||||
|
||||
- **S7: multi-variable PDUs for span leftovers.** The Task 13 planner groups contiguous
|
||||
reads into one PDU per span; tags that do not fall in a span are still one PDU each. A
|
||||
multi-var PDU would absorb them.
|
||||
- ⚠️ **S7 span planner has no live gate.** The `16` (max gap) / `220` (max span bytes)
|
||||
defaults were chosen analytically and pinned only by unit tests. Run the planner against
|
||||
the `s7_1500` fixture (`lmxopcua-fix up s7 s7_1500`, `S7_SIM_ENDPOINT=10.100.0.35:1102`)
|
||||
before trusting them on real hardware.
|
||||
- **`SpanReadMaxSpanBytes` has no validation guard.** A value above the negotiated PDU size
|
||||
is accepted at config time and fails at read time.
|
||||
- **MTConnect `Build` could stream through `XmlReader`.** Task 27 removed the per-chunk
|
||||
string copies; the document build still materialises.
|
||||
- **No driver-level meter for the PollGroupEngine overrun counter.** The counter exists and
|
||||
is observable in-process but is not exported per driver.
|
||||
- **`NoteOverrun` conflates a slow success with a slow failure** — a poll that overran
|
||||
because the device was slow and one that overran because it timed out increment the same
|
||||
counter. Worth a doc caveat rather than a code change.
|
||||
- **`backoffCap` wants a constructor doc note** so a future caller does not have to read the
|
||||
loop to learn its units and its interaction with the poll interval.
|
||||
|
||||
**Runtime / deploy path**
|
||||
|
||||
- **`UpsertNodeDeploymentState` and `TryRecoverFromStale` still write synchronously** on the
|
||||
actor thread. Both are commented as such in `DriverHostActor`; neither is on the hot path
|
||||
that Task 26 moved off the mailbox.
|
||||
- ⚠️ **Direct-mode `LoadArtifactBlob` carries no timeout or cancellation token** (Task 26
|
||||
review, Important). The read now runs off the mailbox so it cannot wedge the actor, but a
|
||||
hung SQL read still pins a thread-pool thread indefinitely unless the `ConfigDb`
|
||||
connection string's command timeout bounds it. **Confirm that it does** — the code relies
|
||||
on it and does not assert it.
|
||||
- **The historian drain's `catch` swallows a shutdown `OperationCanceledException` as a
|
||||
per-tag failure** when other tags in the same pass succeeded, spuriously incrementing
|
||||
`_tagWriteFailures` (Task 3 review; pre-existing, inherited). Rethrowing a genuine
|
||||
`ct` cancellation is the fix.
|
||||
- **Host-side mux interest gating was skipped** — the batch envelope (Task 2) made the
|
||||
saving it would have bought immaterial. Recorded so it is not re-proposed as an oversight.
|
||||
|
||||
**ACL**
|
||||
|
||||
- **Task 6: `_globalCount` can drift under concurrent shard eviction**, and there is no
|
||||
concurrency stress test. Drift is bounded and only affects when trimming triggers, not
|
||||
correctness of any decision.
|
||||
- **Task 9: the empty-identifier pattern should be tightened** — the dedup key tolerates an
|
||||
empty node identifier rather than rejecting it.
|
||||
- **Task 17: the `GroupBy(_ => 1)` aggregate is unverified against real SQL Server.** It is
|
||||
covered only by EF InMemory, which does not exercise the provider's translation, so the
|
||||
single-round-trip claim is untested where it matters.
|
||||
- **Task 21: the `CHARINDEX`/collation widening needs a SQL-Server-backed test**, and the
|
||||
worst-case **250 sequential round-trips** (a rename touching the chunking limit) should
|
||||
carry a one-line comment at the call site.
|
||||
|
||||
**Alarms**
|
||||
|
||||
- **Task 15: `TrackBackgroundTask` has a registration-ordering gap** (pre-existing) — a task
|
||||
can complete before it is registered. Also `_disposed` is non-volatile.
|
||||
|
||||
**Test-suite reliability** (affects how reliably a regression is *caught*, not shipped behaviour)
|
||||
|
||||
- **Task 1's second-reader durability test is fragile on macOS** — it depends on filesystem
|
||||
flush visibility to a second reader, which the platform does not guarantee on the same
|
||||
timescale as Windows/Linux.
|
||||
- **The #491 falsification control is nondeterministic under load.** It relies on a
|
||||
`ThreadStatic` actor cell, so under a loaded sweep the intended-red mutation can
|
||||
occasionally pass. The test is still worth keeping; do not read a single green run of it
|
||||
as proof the regression is caught.
|
||||
|
||||
+56
-1
@@ -438,13 +438,68 @@ Behaviours worth knowing before enabling it:
|
||||
|
||||
Read, Browse and subscription creation ask the same question many times per request — one Browse of a
|
||||
device folder asks once per child. Those seams go through a per-`(session, node, operation, realm)`
|
||||
decision cache, flushed wholesale whenever the authority generation advances.
|
||||
decision cache (`SessionAclDecisionCache`), cleared wholesale whenever the authority generation
|
||||
advances.
|
||||
|
||||
⚠️ **The cache holds the grant walk, not the decision.** Grants, principals and the trie are all fixed
|
||||
for the life of a generation, so memoizing the walk is sound. A key's `ExpiresUtc` passes with the
|
||||
*clock*, not with a deployment — so revocation and expiry are re-checked on every call, outside the
|
||||
cache. Caching them would have let an expired key keep working until the next deploy.
|
||||
|
||||
**The cache is sharded per session, because a flat one let any client evict every other client's
|
||||
decisions.** It used to be a single dictionary with the session identity folded *into* the composite key
|
||||
and one process-wide bound of `MaxEntries = 100_000`, whose only eviction was a wholesale `Clear()` at
|
||||
the cap. So one machine consumer crawling a large address space past 100k entries wiped the memoized
|
||||
walks of every other session on the server, and each store paid a `ConcurrentDictionary.Count` — an
|
||||
all-locks snapshot — to test the cap. It is now a dictionary of per-session shards keyed by the session's
|
||||
`ApiKeyId ?? Principal`, each holding its own `(operation, realm, nodeIdentifier)` entries, with two
|
||||
bounds:
|
||||
|
||||
| Bound | Value | What it clears |
|
||||
|---|---|---|
|
||||
| `MaxEntriesPerSession` | 20 000 | that one session's shard |
|
||||
| `MaxEntries` | 100 000 | every shard (the shard *objects* are dropped, not emptied) |
|
||||
|
||||
Both are compile-time constants, not appsettings keys. Five sessions simultaneously at their per-session
|
||||
ceiling reach the global cap; **the number of shards is not itself bounded** — the bound is entries.
|
||||
|
||||
Things to know before changing it:
|
||||
|
||||
- **There is no session-close eviction hook**, deliberately. A closed session's shard is shed the next
|
||||
time a wholesale clear happens — a generation bump, or the global cap tripping. Dropping the shard
|
||||
objects rather than emptying them is what makes that sweep work.
|
||||
- **The counters are approximate under concurrency, by design.** The cap test is a check-then-act over
|
||||
`Volatile.Read` of `Interlocked` counters rather than a lock, so concurrent stores can overshoot a cap
|
||||
by roughly one shard's worth. Every clear resets the counters, so drift cannot ratchet. Inserts use
|
||||
`TryAdd` (not an indexer assignment) so that only a genuine insert moves a counter — otherwise two
|
||||
threads racing the same key would inflate them and permanently pull the ceilings in.
|
||||
- **Only the CAS winner clears on a generation bump.** `InvalidateIfStale` compare-exchanges the
|
||||
generation, so a post-deploy burst of requests repopulates the cache once instead of each thread
|
||||
clearing it out from under the others.
|
||||
|
||||
**The hot path looks the entry up before it builds anything.** The gate used to construct the node scope
|
||||
— splitting the `s=` identifier and allocating a `NodeScope` — and then allocate a five-capture closure
|
||||
for the walk, on *every* call including cache hits. Now the identifier is extracted once, the cache is
|
||||
consulted immediately, and a hit returns having built no scope, no closure and no
|
||||
`UserAuthorizationState`; the scope and the walk happen only on a miss. `GetOrAdd` was split into an
|
||||
allocation-free `TryGet` plus a `Set` to make that possible, and `TryGet` deliberately does not
|
||||
create a shard, so a hit-only session never mints one and a just-cleared shard is not resurrected until
|
||||
something is actually stored.
|
||||
|
||||
⚠️ **Revocation is checked ahead of the cache, not behind it** — the key-usability lookup runs *above*
|
||||
the `TryGet`, so a cache hit cannot serve an allow for a key that has since been disabled or expired.
|
||||
Reversing those two lines is the subtle way to reintroduce the bug the paragraph above warns about: the
|
||||
generation never moves when a key merely expires, so nothing would ever drop the stale allow. Two tests
|
||||
pin the ordering (`Key_revocation_is_rechecked_on_a_cache_hit` and
|
||||
`An_expired_key_is_denied_even_though_its_allow_is_cached`).
|
||||
|
||||
**Deny reasons are composed lazily.** A denial on the cached path logs at `Debug`, and the interpolated
|
||||
reason strings (which generation, which key, which operation) used to be built at every call site before
|
||||
the log level was consulted — so a Browse denied across a thousand children formatted a thousand strings
|
||||
with `Debug` off. The interpolating denials now guard on `_log.IsEnabled(level)` first, and the
|
||||
`BadUserAccessDenied` `ServiceResult` is a shared static instance rather than one allocation per denied
|
||||
node.
|
||||
|
||||
#### Everything denies when something is missing
|
||||
|
||||
No identity, no principal on the identity, **no cluster identity on the node**, a NodeId with no string
|
||||
|
||||
Reference in New Issue
Block a user