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
+6 -3
View File
@@ -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
+76
View File
@@ -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
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:
@@ -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
View File
@@ -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