812 lines
50 KiB
Markdown
812 lines
50 KiB
Markdown
# Perf Follow-ups Implementation Plan
|
||
|
||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development
|
||
> to implement this plan task-by-task (per the standing preference: Opus implementers,
|
||
> classification-driven review chains).
|
||
|
||
**Goal:** Close the actionable items in the perf-roadmap-remainder follow-up register
|
||
(`docs/plans/2026-08-15-perf-roadmap-remainder.md` §"Follow-up register") and run the two
|
||
release-condition live gates, on branch `perf/followups` off master merge `e40a0385`.
|
||
|
||
**Architecture:** No new subsystems. Each task is a surgical close of a recorded follow-up:
|
||
a metrics ride-along on the existing shared `OtOpcUaTelemetry.Meter`, four defect fixes in
|
||
already-reviewed code (drain cancellation, ACL eviction race, revalidate memo key,
|
||
`TrackBackgroundTask` ordering), two SQL-Server-backed test gaps, one parser streaming
|
||
rewrite, an S7 multi-var read extension over the existing `IS7Plc` seam, and two live gates
|
||
(s7_1500 fixture + local docker-dev rig).
|
||
|
||
**Tech Stack:** .NET 10 / C#, xUnit + Shouldly, System.Diagnostics.Metrics, EF Core
|
||
(SqlServer + InMemory), S7netplus 0.20.0, FasterLog, docker-dev rig (OrbStack).
|
||
|
||
**Out of scope (recorded, deliberately not here):**
|
||
- **HistorianGateway multi-tag batch `WriteLiveValues` RPC** — cross-repo
|
||
(`~/Desktop/HistorianGateway` proto + client-package release + pin bump here). Stays in
|
||
`docs/Historian.md` as the recorded ceiling on `DrainConcurrency`.
|
||
- **S7 `SByte`** — a plan error, already corrected; not a gap.
|
||
- `UpsertNodeDeploymentState` / `TryRecoverFromStale` synchronous writes — commented in
|
||
`DriverHostActor.cs:2930-2932` / `:2979-2982` as deliberate; not on the hot path.
|
||
- Host-side mux interest gating — recorded as not-worth-it after the batch envelope.
|
||
- Historian end-to-end live gate against the real AVEVA Historian — needs the
|
||
`wonder-sql-vd03` VPN (user-driven); recipe stays
|
||
`docs/plans/2026-08-01-continuous-historization-live-gate.md`.
|
||
- Pushing master — user decision, unchanged.
|
||
|
||
**Execution rules (unchanged from the remainder plan):** shared checkout — explicit-pathspec
|
||
`git add`/`git commit -- <files>` only (never a bare commit); never checkout/switch/reset/
|
||
stash/clean; retry on `index.lock` and CS2012/MSB3030 build races; reviewers judge commits
|
||
via `git show`, never the working tree; falsification mutations reverted via Edit; TDD
|
||
red-first with honest "pin, not red-first" reporting where the behaviour predates the test.
|
||
|
||
---
|
||
|
||
### Task 1: Per-driver poll-overrun meter + PollGroupEngine doc notes
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** Tasks 2, 3, 4, 5, 6, 7, 8, 10, 11
|
||
|
||
**Files:**
|
||
- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Observability/OtOpcUaTelemetry.cs` (new counter)
|
||
- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/PollGroupEngine.cs` (doc notes only)
|
||
- Modify (one-line each, at the `new PollGroupEngine(` call sites): `ModbusDriver.cs:157`,
|
||
`FocasDriver.cs:117`, `TwinCATDriver.cs:76`, `AbLegacyDriver.cs:74`, `S7Driver.cs:65`,
|
||
`SqlDriver.cs:226`, `AbCipDriver.cs:157`
|
||
- Test: `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/` (PollGroupEngine tests file) +
|
||
one per-driver wiring pin is NOT required (see below)
|
||
|
||
**Spec.** The seven poll drivers' pacing change currently ships with no production consumer
|
||
of `PollGroupEngine.OverrunCount` (the accepted-not-fixed item). Close it the way the
|
||
engine's own doc note (`PollGroupEngine.cs:47-50`) prescribes: the driver wires its own
|
||
meter to the existing `onOverrun` callback (ctor param, line 105).
|
||
|
||
1. Add to `OtOpcUaTelemetry` (following the existing `CreateCounter` field pattern, e.g.
|
||
`otopcua.driver.lifecycle` at line 42):
|
||
`public static readonly Counter<long> PollOverruns = Meter.CreateCounter<long>("otopcua.driver.poll.overrun", unit: "{overrun}", description: "Poll cycles that ran longer than the poll interval (slow success and slow failure both count — see PollGroupEngine.NoteOverrun).");`
|
||
2. In each of the seven drivers, pass
|
||
`onOverrun: _ => OtOpcUaTelemetry.PollOverruns.Add(1, new KeyValuePair<string, object?>("driver.type", "<type>"))`
|
||
where `<type>` is the driver's `DriverTypeNames` constant (use the constant, not a
|
||
literal — the `TwinCat`/`Focas` drift was a real bug once). If the constructor scope has
|
||
a cheap device/instance identifier already in hand, add a second `device` tag; do NOT
|
||
plumb new state to get one.
|
||
Note: Commons is referenced from driver projects already via the messages namespace — if
|
||
any of the seven driver csproj lacks the `Commons` reference, add the ProjectReference;
|
||
report it rather than inventing a per-driver Meter (Galaxy's own-meter pattern is the
|
||
outlier, not the template).
|
||
3. Doc notes in `PollGroupEngine.cs` (comment-only):
|
||
- Update lines 47-50: the follow-up is now delivered driver-side via
|
||
`OtOpcUaTelemetry.PollOverruns`; the engine still deliberately owns no Meter.
|
||
- `NoteOverrun` (lines 315-322): add the caveat that it conflates a slow success with a
|
||
slow failure — both increment; disambiguation would need the loop's outcome, and no
|
||
consumer needs it yet.
|
||
- `backoffCap` ctor param (line 104): doc its units (a `TimeSpan` cap on the exponential
|
||
failure backoff) and its interaction with `minInterval` (backoff replaces, not adds to,
|
||
the interval on the failure path — state whichever the loop at 261-263 actually does;
|
||
read it, don't guess).
|
||
|
||
**Tests.** One unit test on the engine pinning that an overrun invokes the `onOverrun`
|
||
callback exactly as many times as `OverrunCount` increments (may already exist from Task 11
|
||
of the remainder — if so, extend or cite it; do not duplicate). The seven driver wirings are
|
||
one-line closures over a static counter — no per-driver test; a wiring test would just
|
||
restate the line.
|
||
|
||
**Step order:** test (or cite existing) → counter → 7 wirings → doc notes → build the
|
||
solution → commit.
|
||
|
||
**Commit:** `feat(drivers): per-driver poll-overrun meter on the shared OtOpcUa Meter; NoteOverrun + backoffCap doc notes`
|
||
|
||
---
|
||
|
||
### Task 2: S7 span-option validation guard
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~3 min
|
||
**Parallelizable with:** Tasks 1, 3, 4, 5, 6, 7, 8, 10, 11
|
||
|
||
**Files:**
|
||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverFactoryExtensions.cs` (`ParseOptions`, line 46 area)
|
||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7DriverOptions.cs` (doc remark only, if needed)
|
||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/` (the factory/options parse test file)
|
||
|
||
**Spec.** `SpanReadMaxSpanBytes` (`S7DriverOptions.cs:128`, default 220) has no validation:
|
||
a nonsensical value is accepted at config time and only fails (or silently misbehaves) at
|
||
read time. There is no `IValidateOptions` machinery for S7 (confirmed) — `ParseOptions` in
|
||
`S7DriverFactoryExtensions.cs` is the single choke point, so validate there:
|
||
|
||
- `SpanReadMaxSpanBytes < 1` → throw `ArgumentException`/config-error consistent with how
|
||
`ParseOptions` reports other bad config (read the method; match its existing error style).
|
||
- `SpanReadMaxGapBytes < -1` → same (−1 = disabled is legal, per `S7Driver.cs:571`'s
|
||
`>= 0` enable check; 0 is legal "no gap tolerated").
|
||
- Values **above** the negotiated PDU size cannot be checked here — the negotiated size is
|
||
opaque inside S7.Net (confirmed: no API exposes it) and S7.Net transparently chunks
|
||
oversized reads. Do NOT invent a fake upper bound; instead add one doc line to the
|
||
`SpanReadMaxSpanBytes` remark in `S7DriverOptions.cs` stating that over-PDU values are
|
||
legal but degrade to multi-request chunking inside S7.Net.
|
||
|
||
**Tests.** Red-first: `SpanReadMaxSpanBytes = 0` rejected; `SpanReadMaxGapBytes = -2`
|
||
rejected; `-1`/`0`/defaults accepted.
|
||
|
||
**Commit:** `fix(s7): validate span-read options at parse time; document the over-PDU degradation`
|
||
|
||
---
|
||
|
||
### Task 3: Historian drain — shutdown cancellation is not a per-tag failure
|
||
|
||
**Classification:** high-risk (concurrency, #538 quarantine semantics)
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** Tasks 1, 2, 4, 5, 6, 7, 8, 10, 11
|
||
|
||
**Files:**
|
||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationRecorder.cs:703-712`
|
||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationRecorderTests.cs`
|
||
|
||
**Spec.** In `DrainOnceAsync`, the per-tag parallel lambda wraps
|
||
`_writer.WriteLiveValuesAsync(g.Key, g.Values, ct)` in a **bare `catch`** (`:707-712`) that
|
||
converts a shutdown `OperationCanceledException` into `ok = false`. When sibling tags in the
|
||
same pass already succeeded, the #538 logic (`:775-784`) then counts the cancelled tag as a
|
||
consecutive per-tag failure — a shutdown can push a healthy tag toward abandonment. The
|
||
outer `catch (OperationCanceledException) when (ct.IsCancellationRequested)` (`:812-816`)
|
||
already treats whole-pass cancellation as non-fault but is never reached because the inner
|
||
catch eats it first.
|
||
|
||
Fix: in the per-tag lambda, rethrow genuine token cancellation ahead of the bare catch:
|
||
|
||
```csharp
|
||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||
{
|
||
throw;
|
||
}
|
||
catch
|
||
{
|
||
ok = false;
|
||
}
|
||
```
|
||
|
||
The rethrow propagates through the awaited per-tag task to the pass's `await` and lands in
|
||
the existing outer catch → `DrainResult(true, 0, 0)`, no ack, no failure counts. Verify the
|
||
`SemaphoreSlim window` release still runs on this path (it is in a `finally` — confirm, and
|
||
keep it that way). Do NOT touch the #538 discriminator ("did another tag write this pass"),
|
||
the all-or-nothing ack, or the falsification control's `succeeded == 0` keying.
|
||
|
||
**Tests.** Red-first: a writer fake where tag A completes successfully, then the token is
|
||
cancelled and tag B's write throws `OperationCanceledException(ct)`. Assert: the pass does
|
||
not ack (outbox not truncated), and — the load-bearing assertion — tag B accumulates **no**
|
||
failure count (observe via the same seam the existing #538 tests
|
||
`A_permanently_failing_tag_is_abandoned...` (`:408`) and
|
||
`A_backend_that_fails_every_tag_abandons_nothing` (`:456`) use: run `MaxWriteAttempts`
|
||
shutdown-cancelled passes interleaved with sibling successes, then show the tag is NOT
|
||
abandoned). Also keep a control: a non-cancellation throw from B with A succeeded still
|
||
counts (pins that the fix is scoped to genuine `ct` cancellation — an OCE thrown while
|
||
`ct.IsCancellationRequested == false` must still count as a failure, which the `when` filter
|
||
gives for free; test that too).
|
||
|
||
**#491 hazard note:** this file is the `ConfigureAwait(false)`-destroys-`Self` site. The fix
|
||
touches only the catch clauses inside an existing async lambda — do not add `ConfigureAwait`
|
||
changes, do not touch `Self` usage.
|
||
|
||
**Commit:** `fix(historian): a shutdown cancellation mid-drain is a pass abort, not a per-tag failure`
|
||
|
||
---
|
||
|
||
### Task 4: Direct-apply artifact read — explicit command timeout
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~4 min
|
||
**Parallelizable with:** Tasks 1, 2, 3, 5, 6, 7, 8, 10, 11
|
||
|
||
**Files:**
|
||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs:2082-2107` (`LoadArtifactBlob`)
|
||
- Test: the DriverHostActor direct-apply test file (locate the Task 26 tests around `BeginDirectApply`/`ArtifactForApply` in `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/`)
|
||
|
||
**Spec.** The Task 26 Important: `LoadArtifactBlob` (EF read of `Deployment.ArtifactBlob`,
|
||
runs in `Task.Run` off the mailbox) carries no timeout or cancellation. The code relies on
|
||
an implicit bound and does not assert it. Confirmed by exploration: nothing sets a command
|
||
timeout anywhere on the ConfigDb path (`ServiceCollectionExtensions.cs:30` has no
|
||
`CommandTimeout`, no connection string in the repo carries `Command Timeout=`), so the only
|
||
bound is SqlClient's default 30 s command timeout.
|
||
|
||
Fix — make the bound explicit and local (do NOT change the global pooled-factory options;
|
||
that would silently re-time every ConfigDb consumer, including migrations and long admin
|
||
queries):
|
||
|
||
1. In `LoadArtifactBlob`, after `CreateDbContext()`:
|
||
`db.Database.SetCommandTimeout(TimeSpan.FromSeconds(30));`
|
||
with a comment: this read runs off the mailbox on a pool thread with no CancellationToken
|
||
(the actor cannot be wedged, but a hung SQL read would pin a pool thread); the explicit
|
||
timeout replaces reliance on SqlClient's default so a future connection-string or
|
||
provider-default change cannot unbound it. A timeout surfaces as the existing
|
||
catch-to-null path (`:2093-2106`) → the apply fails, revision does not advance — the
|
||
#485-family "no answer" semantics, already correct.
|
||
2. Do not thread a `CancellationToken` — `FirstOrDefault` would need to become async and the
|
||
only caller-side token is actor lifetime; the timeout gives the same bound without
|
||
touching the actor seam. State this in the comment.
|
||
|
||
**Tests.** The behaviour delta is not black-box observable without a hung SQL server, so:
|
||
pin with a source-level test only if the repo already has the source-scan pin pattern for
|
||
this file; otherwise a unit test that `LoadArtifactBlob`'s failure path (throwing
|
||
DbContext) still returns null and fails the apply (may already exist — cite it), plus the
|
||
comment. Say honestly in the report which it was.
|
||
|
||
**Commit:** `fix(runtime): explicit 30s command timeout on the Direct-apply artifact read`
|
||
|
||
---
|
||
|
||
### Task 5: SessionAclDecisionCache — eviction race on `_globalCount` + stress test
|
||
|
||
**Classification:** high-risk (lock-free concurrency)
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** Tasks 1, 2, 3, 4, 6, 7, 8, 10, 11
|
||
|
||
**Files:**
|
||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/SessionAclDecisionCache.cs:178-182`
|
||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SessionAclDecisionCacheTests.cs`
|
||
|
||
**Spec.** The per-shard eviction branch (`Set()`, lines 178-182) runs
|
||
`shard.Entries.Clear()` then `Interlocked.Add(ref _globalCount, -Interlocked.Exchange(ref shard.ApproxCount, 0))`
|
||
as two non-atomic steps. A concurrent `Set` on the same shard can insert into the
|
||
just-cleared dictionary and increment both counts between them; the evictor's `Exchange`
|
||
then subtracts a count that includes the racer's fresh insert → `_globalCount` under-counts
|
||
(negative-only drift; trims early, never late — correctness of decisions is unaffected,
|
||
which is why this was deferred).
|
||
|
||
Fix by making the eviction atomic with respect to concurrent inserts: **replace the shard
|
||
object** rather than clearing the shared dictionary — the same pattern `ClearAll()` already
|
||
uses (it drops shard objects wholesale). Read the shard-storage shape first (how shards are
|
||
held per session key) and swap a fresh `Shard` in with a CAS/indexer replace, then subtract
|
||
the *displaced* shard's `ApproxCount` from `_globalCount`. A racer that inserted into the
|
||
displaced shard loses its entry (acceptable — it re-caches on next miss) but its global
|
||
increment is subtracted with the shard it landed in, so the count cannot over-subtract.
|
||
Preserve: the outer-bound gate at `:171`, generation-rollover `ClearAll()` at `:238`/`:250`,
|
||
and the "increment only on genuine `TryAdd`" invariant at `:186-189`.
|
||
|
||
**Tests.** Red-first is impractical for a race; do it as: (1) a deterministic interleaving
|
||
test if the seams allow (inject at the eviction boundary), else honestly a stress test —
|
||
N writer threads hammering one session's shard past `MaxEntriesPerSession` while others
|
||
insert, barrier-started, several rounds; assert at quiescence `_globalCount` equals the
|
||
actual live entry total (expose via an internal count-audit helper or reflection over
|
||
shards) and is never negative. Run the stress test with the fix reverted (Edit-revert, then
|
||
restore) and report whether it goes red — if the race won't reproduce under test load, say
|
||
so honestly; the deterministic invariant audit is then the pin.
|
||
|
||
**Commit:** `fix(acl): shard eviction swaps the shard object so _globalCount cannot over-subtract; concurrency stress test`
|
||
|
||
---
|
||
|
||
### Task 6: Revalidate memo — reject empty node identifiers
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~3 min
|
||
**Parallelizable with:** Tasks 1, 2, 3, 4, 5, 7, 8, 10, 11
|
||
|
||
**Files:**
|
||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs:3319-3321` (`IsSubscriptionStillPermitted`)
|
||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerStage6EnforcementTests.cs`
|
||
|
||
**Spec.** The pass-local revalidation memo key (`:3327`) tolerates an empty string
|
||
identifier: the guard at `:3319-3321` rejects only a *non-string* identifier, so all items
|
||
on empty-`s=` nodes for one (session, realm) collapse onto one memoized verdict.
|
||
`DataPlaneAuthorizer.Evaluate` already rejects exactly this at
|
||
`DataPlaneAuthorizer.cs:253` (`identifier.Length == 0`). Mirror it: extend the guard with
|
||
`|| identifier.Length == 0` so empty identifiers fall through to the un-memoized
|
||
`IsAllowedCached` call at `:3323-3324` (fail-safe: un-memoized means each item gets its own
|
||
authorizer verdict, and the authorizer's own guard then handles the empty id).
|
||
|
||
**Tests.** Red-first in `NodeManagerStage6EnforcementTests` (it already drives
|
||
`RevalidateMonitoredItems` directly at `:484`/`:515`): two monitored items on nodes with
|
||
empty string identifiers, different underlying permit outcomes by whatever seam the tests
|
||
use — assert they do NOT share one memoized verdict after the fix. If distinct outcomes for
|
||
two empty-id nodes aren't constructible through the fake, pin instead that empty-id items
|
||
bypass the memo (e.g. authorizer-call count equals item count for empty ids, vs. 1 for a
|
||
memoized duplicate pair) — state which shape was used.
|
||
|
||
**Commit:** `fix(acl): revalidation memo rejects empty node identifiers, mirroring the authorizer's guard`
|
||
|
||
---
|
||
|
||
### Task 7: SQL-Server-backed tests for the drift probe + rename script scan
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** Tasks 1, 2, 3, 4, 5, 6, 8, 10, 11
|
||
|
||
**Files:**
|
||
- Create: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AdminUiSqlServerFixture.cs`
|
||
- Create: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Deployments/DeploymentDriftProbeSqlServerTests.cs`
|
||
- Create: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTreeServiceRenameWarningSqlServerTests.cs`
|
||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs:1451` (one comment line)
|
||
- Modify (if needed): `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj` (`Microsoft.Data.SqlClient` if not already transitive)
|
||
|
||
**Spec.** Two register items say the same thing: EF InMemory does not exercise the SQL
|
||
Server provider's translation, so (a) the Task 17 `GroupBy(_ => 1)` watermark aggregate
|
||
(`DeploymentDriftProbe.ReadWatermarkAsync`, `DeploymentDriftProbe.cs:122-135`, over
|
||
**`ConfigEdits`** — not deployments; the folder name misleads) and (b) the Task 21
|
||
expression-built `SourceCode.Contains` OR-chain (`RawTreeService.AnyPathContained`,
|
||
`:1488-1513`) are unverified where they matter.
|
||
|
||
1. **Fixture**: copy `SchemaComplianceFixture`'s shape
|
||
(`tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceFixture.cs`): env
|
||
`OTOPCUA_CONFIG_TEST_SERVER` (default `10.100.0.35,14330`) +
|
||
`OTOPCUA_CONFIG_TEST_SA_PASSWORD` (default `OtOpcUaDev_2026!`), unique per-run DB name,
|
||
`Database.Migrate()`, drop-on-dispose, collection fixture. **One deliberate deviation**:
|
||
probe reachability first (short TCP/`SqlConnection` attempt) and expose a `SkipReason`;
|
||
tests call `Assert.Skip` when unreachable (the `Snap7ServerFixture.cs:41-92` pattern) —
|
||
AdminUI.Tests must stay green offline, unlike Configuration.Tests which fails hard.
|
||
Document the deviation in the fixture doc comment.
|
||
2. **Drift probe test**: seed `ConfigEdits` rows with known timestamps, run the real probe
|
||
against the SQL-backed context factory, assert `LastEditUtc`/`EditCount` — proving the
|
||
`GroupBy(_ => 1)` aggregate translates and returns the right watermark (a translation
|
||
failure throws; wrong translation returns wrong values — both caught).
|
||
3. **Rename scan test**: seed a `Script` whose `SourceCode` contains a tag path in
|
||
**different case** than the rename target. On SQL Server's default case-insensitive
|
||
collation the scan must find it (the "collation widening" the InMemory provider cannot
|
||
show — InMemory `string.Contains` is case-sensitive). Assert the warning names the
|
||
script. Also one same-case control. Exercise through the real
|
||
`BuildScriptReferenceWarningAsync` (`RawTreeService.cs:1437-1470`) with > 1 batch if
|
||
cheap (201 paths) to also cross the chunk boundary against the real provider.
|
||
4. **Comment**: one line at the batching loop (`RawTreeService.cs:1451`): worst case is
|
||
~250 sequential round-trips for a rename touching the 50k-path scale (200-term chunks);
|
||
deliberate — bounded memory beats one giant OR-chain.
|
||
|
||
**Commit:** `test(adminui): SQL-Server-backed pins for the drift watermark aggregate and the rename scan's collation widening`
|
||
|
||
---
|
||
|
||
### Task 8: ScriptedAlarmEngine — TrackBackgroundTask ordering + volatile `_disposed`
|
||
|
||
**Classification:** high-risk (concurrency, disposal)
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** Tasks 1, 2, 3, 4, 5, 6, 7, 10, 11
|
||
|
||
**Files:**
|
||
- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs` (`TrackBackgroundTask` `:996-1006`, `_disposed` `:172`, call sites `:559`, `:989`)
|
||
- Test: `tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmEngineTests.cs`
|
||
|
||
**Spec.** Two recorded gaps:
|
||
|
||
1. **Registration-ordering**: callers evaluate the async-method argument (which starts the
|
||
work) *before* `TrackBackgroundTask` adds it to `_inFlight`
|
||
(`TrackBackgroundTask(DrainDirtyAsync(...))` at `:559`). `Dispose` can snapshot
|
||
`_inFlight` (`:1090-1094`) in that window and drain past a genuinely in-flight task.
|
||
Fix by removing the window, not shrinking it: change the call shape to register-first —
|
||
e.g. give `TrackBackgroundTask` a `Func<Task>` overload that (under `_inFlightLock`)
|
||
installs a placeholder/`TaskCompletionSource` (or checks `_disposed` and declines to
|
||
start), then invokes the factory and links completion — so a task that runs is always
|
||
visible to `Dispose`'s snapshot, and after `_disposed` no new background work starts.
|
||
Preserve exactly: the drain-latch handover semantics
|
||
(`Sustained_synchronous_drain_failures_hand_over_without_deepening_the_stack`, `:1627` —
|
||
the handover MUST keep spawning via `Task.Run` on a fresh stack, commit `c8a70143`), the
|
||
`ExecuteSynchronously` self-removal, and `Dispose`'s synchronous `WhenAll` drain. If the
|
||
handover call site (`TrackBackgroundTask(Task.Run(() => DrainDirtyAsync(ct)))`) needs
|
||
reshaping to the factory form, keep the `Task.Run` inside.
|
||
2. **`_disposed`**: make it `volatile` (`:172`) — it is written in `Dispose` (`:1078`) and
|
||
read unsynchronized on other threads (`:235, 544, 603, 662, 682, 987, 1027`).
|
||
|
||
**Tests.** Red-first for the ordering fix where constructible: a factory whose task
|
||
completes synchronously/immediately while `Dispose` races — deterministically, pin the new
|
||
invariant instead: (a) after `Dispose`, `TrackBackgroundTask`'s factory form does not invoke
|
||
the factory (no new work post-disposal); (b) a task started via the factory form is awaited
|
||
by `Dispose` even when it completes before the continuation attaches. Every existing engine
|
||
test (`:817`, `:1458`, `:1521`, `:1563`, `:1627`) must stay green — they are the spec for
|
||
what must not change.
|
||
|
||
**Commit:** `fix(alarms): background tasks register before they start; volatile _disposed`
|
||
|
||
---
|
||
|
||
### Task 9: Test-reliability hardening (durability second-reader, #491 controls)
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~4 min
|
||
**Parallelizable with:** Tasks 4, 5, 6, 7, 8, 10, 11 (NOT Task 3 — same recorder test file)
|
||
**Blocked by:** Task 3
|
||
|
||
**Files:**
|
||
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Recorder/FasterLogHistorizationOutboxTests.cs:172-197` (+ helper `ReadCommitted` `:203+`)
|
||
- Modify: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationRecorderTests.cs:359-387`, `:797-831` (doc comments only)
|
||
|
||
**Spec.**
|
||
1. `AppendBatch_group_commit_is_durable_before_dispose` depends on filesystem flush
|
||
visibility to a second `FasterLog` reader, which macOS does not guarantee on the
|
||
Windows/Linux timescale. Prefer a robust wait: retry the second-reader scan (re-open or
|
||
re-read up to `CommittedUntilAddress`) with a bounded poll (e.g. up to 5 s) before
|
||
asserting — the property under test is "durable before dispose", which a bounded wait
|
||
still proves (the writer is not disposed during the poll). Only if FasterLog's reader
|
||
genuinely cannot observe later flushes on re-scan, fall back to an OS-conditional
|
||
`Assert.Skip` on macOS with a comment naming the platform limitation. Report which.
|
||
2. The two #491 yielding-fake tests: ensure each carries a comment stating the
|
||
falsification control is nondeterministic under load (ThreadStatic actor cell) — a single
|
||
green run of the intended-red mutation is not proof the regression is caught. The `:790-793`
|
||
comment covers part of this; make both tests carry the load-nondeterminism warning.
|
||
|
||
**Commit:** `test(historian): bounded-wait durability read; honest nondeterminism notes on the #491 controls`
|
||
|
||
---
|
||
|
||
### Task 10: MTConnect `Build` streams through XmlReader
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** Tasks 1, 2, 3, 4, 5, 6, 7, 8, 11
|
||
|
||
**Files:**
|
||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectStreamsParser.cs` (`Build` `:221`, the three `Parse` overloads `:102/:138/:155`)
|
||
- Test: the existing MTConnect parser test file(s) under `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/`
|
||
|
||
**Spec.** Task 27 removed the per-chunk string copies; `Build(XDocument)` still materialises
|
||
the whole document tree per sample chunk. Rewrite the parse path to stream via `XmlReader`
|
||
(`XmlReader.Create(stream, settings)` with DTD prohibited, async off — match the sync call
|
||
shape) extracting exactly what `Build` extracts today, and route all three `Parse` overloads
|
||
through it (the string overload via a `StringReader`). Requirements:
|
||
|
||
- **Byte-for-byte behavioural parity** with the current `Build` on every existing test —
|
||
the current suite is the spec. Namespace handling: MTConnect documents version their
|
||
namespace URI; whatever name/namespace matching `Build` does today (local-name-based or
|
||
namespace-qualified) must be preserved exactly — read `Build` first and mirror it.
|
||
- Keep the `AsStream` zero-copy path (`:216-219`) feeding the reader.
|
||
- If, mid-implementation, `Build`'s extraction turns out to lean on document-order queries
|
||
that make a single-pass reader genuinely intractable (e.g. backward references), STOP and
|
||
report rather than shipping a half-streamed hybrid — that finding would itself close the
|
||
register item as "not worth it".
|
||
|
||
**Tests.** The existing suite must pass unchanged (parity). Add one test only if a gap is
|
||
found in existing coverage of odd shapes (empty streams element, condition entries, unknown
|
||
elements skipped) — cite, don't duplicate.
|
||
|
||
**Commit:** `perf(mtconnect): stream sample-chunk parsing through XmlReader; no per-chunk document tree`
|
||
|
||
---
|
||
|
||
### Task 11: IS7Plc multi-variable read seam
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~3 min
|
||
**Parallelizable with:** Tasks 1, 2, 3, 4, 5, 6, 7, 8, 10
|
||
|
||
**Files:**
|
||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/IS7Plc.cs` (interface `:18-66`, `S7PlcAdapter` `:125-149`)
|
||
|
||
**Spec.** S7.Net 0.20.0 ships `Plc.ReadMultipleVarsAsync(List<DataItem>, CancellationToken)`
|
||
(confirmed in the shipped DLL); `IS7Plc` does not expose it. Add
|
||
`Task<IReadOnlyList<DataItem>> ReadMultipleVarsAsync(List<DataItem> items, CancellationToken ct)`
|
||
to the interface and implement in `S7PlcAdapter` delegating to the library, wrapped in
|
||
`S7OperationDeadline.RunAsync` exactly like the existing members (`:126-149` pattern —
|
||
mirror it, including the deadline/exception shape). Return the same `DataItem` list S7.Net
|
||
populates. No driver-side use yet (Task 12). Update any test fakes implementing `IS7Plc`
|
||
(they will fail to compile otherwise — implement as `throw new NotSupportedException` in
|
||
fakes that don't care, or a delegate hook in the shared fake if one exists).
|
||
|
||
**Tests.** Compile-level only for the adapter (it is a one-line delegation under the same
|
||
deadline wrapper the other members already have pinned); the behaviour tests come with Task 12's fake-driven driver tests. State this honestly.
|
||
|
||
**Commit:** `feat(s7): expose ReadMultipleVarsAsync through the IS7Plc seam`
|
||
|
||
---
|
||
|
||
### Task 12: S7 multi-var reads for span leftovers
|
||
|
||
**Classification:** high-risk (wire behaviour, partial-failure semantics)
|
||
**Estimated implement time:** ~6 min (accepted over budget — do not split further; the fallback semantics need one head)
|
||
**Blocked by:** Tasks 1, 11 (both touch `S7Driver.cs` / `IS7Plc.cs`)
|
||
|
||
**Files:**
|
||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs` (the `perTag` leftover loop, `:602-614` area)
|
||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/` (extend `S7SpanReadParityTests.cs` or sibling)
|
||
|
||
**Spec.** The Task 13 planner reads one PDU per span; leftovers (`plan.LeftoverIndexes`:
|
||
Timer/Counter tags, unparseable addresses, `PrefersPerTagRead` singles) still go one
|
||
round-trip each. Batch the *batchable* leftovers through the new
|
||
`IS7Plc.ReadMultipleVarsAsync`:
|
||
|
||
- **Partition** leftovers into DataItem-expressible vs not. Timer/Counter: S7.Net's
|
||
`DataItem`/`VarType` does support Timer/Counter — verify against the library types and
|
||
include them only if the driver's existing per-tag path for them maps cleanly onto a
|
||
`DataItem`; if the mapping is not obviously identical, leave Timer/Counter on the per-tag
|
||
path and say so (correctness beats the round-trip). Unparseable addresses stay per-tag by
|
||
definition.
|
||
- **Chunk** conservatively: a multi-var request's item headers consume PDU space
|
||
(~12 bytes/item request-side against the common 240-byte PDU) and S7.Net does NOT chunk
|
||
multi-var reads for you. Use a `const int MultiVarReadMaxItems = 12` with a comment
|
||
deriving the number; no new config key (YAGNI — the planner options weren't config either
|
||
until they were needed).
|
||
- **Fallback, per chunk**: if `ReadMultipleVarsAsync` throws for a chunk, fall back to the
|
||
existing per-tag `ReadOneAsync` path for that chunk's tags — a multi-var failure must
|
||
never make tags Bad that a per-tag read would have served. Only per-tag failure produces
|
||
the Bad snapshot (reuse `RecordReadFailure` semantics). Per-item errors inside a
|
||
successful multi-var response (S7.Net surfaces item error codes) likewise fall back
|
||
per-tag for the failed items only, if the library exposes them; read the library's
|
||
`DataItem` result shape and handle what it actually gives you.
|
||
- Results must sort back into batch order exactly as today (`:613`).
|
||
- Health semantics unchanged: the same success/failure accounting the per-tag loop feeds.
|
||
|
||
**Tests.** Fake `IS7Plc` driven, red-first: (1) N batchable leftovers issue
|
||
`⌈N/12⌉` multi-var calls, zero per-tag calls; (2) parity — values decoded from multi-var
|
||
equal the per-tag path's (mirror `S7SpanReadParityTests` structure); (3) a throwing
|
||
multi-var chunk falls back to per-tag and serves Good; (4) per-tag-only tags (Timer/Counter
|
||
if excluded, unparseable) untouched; (5) falsification: break the chunk size (mutate to
|
||
1000) or the fallback and show the corresponding test goes red, then revert via Edit.
|
||
|
||
**Commit:** `perf(s7): span leftovers batch through multi-var read PDUs with per-chunk fallback`
|
||
|
||
---
|
||
|
||
### Task 13: S7 live gate — span planner + multi-var against the s7_1500 fixture
|
||
|
||
**Classification:** standard (integration test + live run)
|
||
**Estimated implement time:** ~6 min including the live run
|
||
**Blocked by:** Tasks 2, 12
|
||
|
||
**Files:**
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500SpanReadTests.cs`
|
||
|
||
**Spec.** The register's ⚠️: the 16/220 span defaults are analytically chosen and never
|
||
live-gated. Add fixture-backed tests in the established pattern
|
||
(`[Collection(Snap7ServerCollection.Name)]`, `[Trait("Category","Integration")]`,
|
||
`[Trait("Device","S7_1500")]`, `Assert.Skip(sim.SkipReason)` when unreachable — see
|
||
`S7_1500SmokeTests.cs:16-24`, fixture `Snap7ServerFixture.cs:40-92`, endpoint env
|
||
`S7_SIM_ENDPOINT` default `10.100.0.35:1102`):
|
||
|
||
1. Seed (via whatever helper the existing smoke tests use to seed DB bytes) a tag layout
|
||
that genuinely exercises the planner: several tags in one DB within the 16-byte gap
|
||
(one span), a tag beyond the gap (second span or leftover), and ≥ 2 leftover-class tags
|
||
(to exercise the Task 12 multi-var path against the real wire).
|
||
2. Read the batch with span reads enabled (defaults) and again with spans disabled
|
||
(`SpanReadMaxGapBytes = -1`); assert value-for-value parity and all-Good quality.
|
||
3. **Run it live** against the fixture: probe `10.100.0.35:1102` first; if down, bring the
|
||
stack up over SSH (`ssh dohertj2@10.100.0.35 'cd /opt/otopcua-s7 && docker compose --profile s7_1500 up -d'`
|
||
— the Mac path per CLAUDE.md; `lmxopcua-fix` does not exist here). Then
|
||
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests --filter "FullyQualifiedName~S7_1500SpanRead"`.
|
||
The gate PASSES only on a real run (skipped-because-unreachable is not a pass — report
|
||
honestly). Record the result in the test file header comment and the task report.
|
||
|
||
**Commit:** `test(s7): live-gate span reads + multi-var leftovers against the s7_1500 fixture`
|
||
|
||
---
|
||
|
||
### Task 14: docker-dev live gate (regression of the merged remainder)
|
||
|
||
**Classification:** standard (no code — live verification)
|
||
**Estimated implement time:** ~10 min wall (mostly rig startup)
|
||
**Blocked by:** Tasks 1-12 (gates the branch's code state)
|
||
|
||
**Files:** none (evidence appended to this plan's Outcomes)
|
||
|
||
**Spec.** The remainder branch merged with **no live gate** (accepted at the time; recorded
|
||
as a release condition). Run the bounded regression gate on the local docker-dev rig
|
||
(OrbStack — the rig runs locally on this Mac; only fixtures live on 10.100.0.35):
|
||
|
||
1. `docker compose -f docker-dev/docker-compose.yml up -d --build` (builds the current
|
||
branch image); wait for `cluster-seed` "done." (`docker-dev/README.md:71-77`).
|
||
2. Verify all nodes healthy; AdminUI at `http://localhost:9200` (login disabled — drive it
|
||
directly).
|
||
3. Deploy: headless `POST http://localhost:9200/api/deployments` with
|
||
`X-Api-Key: docker-dev-deploy-key`; confirm the deployment seals green (all nodes acked).
|
||
4. Data plane: Client.CLI `browse` + `subscribe` against `opc.tcp://localhost:4840`;
|
||
confirm values stream Good. Make a config edit that produces a surgical apply, deploy,
|
||
and confirm the open subscription survives (the 2026-08-15 gate's step 3, reduced).
|
||
5. `/alerts` and `/hosts` pages load and stream (exercises the Fleet/Hosts poll-guard and
|
||
telemetry changes).
|
||
6. Tear down: `docker compose -f docker-dev/docker-compose.yml down -v`.
|
||
|
||
Out of gate scope (say so in the evidence): historian end-to-end (needs VPN/real gateway),
|
||
FetchAndCache flip, load-scale throughput. Any failure: STOP, report, do not "fix the rig"
|
||
around a product defect.
|
||
|
||
**Commit:** none (evidence lands in Task 15's docs commit).
|
||
|
||
---
|
||
|
||
### Task 15: Close-out — full sweep + Outcomes
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~5 min plus sweep wall time
|
||
**Blocked by:** all prior tasks
|
||
|
||
**Files:**
|
||
- Modify: this plan (append `## Outcomes`: per-task commit SHAs, deviations, the two live-gate evidence blocks)
|
||
- Modify: `docs/plans/2026-08-17-perf-followups.md.tasks.json` (final ledger)
|
||
- Modify: `docs/plans/2026-08-15-perf-roadmap-remainder.md` (tick the closed register items — a one-line "closed by <sha>, 2026-08-17" annotation per item; do NOT rewrite history)
|
||
- Modify (if any behaviour docs drifted): `docs/Historian.md`, `docs/ScriptedAlarms.md`, `CLAUDE.md` (only the lines these tasks invalidate — e.g. the "no production consumer of the overrun counter" caveat)
|
||
|
||
**Spec.** (1) `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — 0 errors. (2) Full unit sweep across
|
||
all test projects (the remainder plan's §Task 28 procedure; DB suites against
|
||
`10.100.0.35,14330` if reachable, recorded either way; the two empty Wonderware test dirs
|
||
are glob noise, not failures). (3) Outcomes + register annotations + ledger. (4) Memory
|
||
update is the orchestrator's job, not this task's.
|
||
|
||
**Commit:** `docs(plan): perf follow-ups close-out — outcomes, register annotations, sweep record`
|
||
|
||
---
|
||
|
||
## Outcomes
|
||
|
||
*Written at close-out, 2026-08-17, on branch `perf/followups`. All 15 tasks complete; both
|
||
release-condition live gates PASSED.*
|
||
|
||
### Sweep record
|
||
|
||
`dotnet build ZB.MOM.WW.OtOpcUa.slnx` → **0 errors**, 896 warnings (all pre-existing:
|
||
`xUnit1051` cancellation-token advice and the repo's own `OTOPCUA0001` CapabilityInvoker
|
||
analyzer firing on test call sites).
|
||
|
||
Full unit sweep — every `*.Tests` project in the solution, run per-project with `--no-build`
|
||
against the tree at the close-out commit. `*.IntegrationTests` are build-only by convention
|
||
and were not run in the sweep (the S7 suite ran separately as Task 13's live gate; the two
|
||
empty `tests/Drivers/…Driver.Historian.Wonderware*.Tests` directories carry no csproj and are
|
||
glob noise).
|
||
|
||
**43 / 43 test projects green (exit code 0 on every one).**
|
||
|
||
| | |
|
||
|---|---|
|
||
| Passed | **10 358** |
|
||
| Failed | **0** |
|
||
| Skipped | 19 |
|
||
| **Total** | **10 377** |
|
||
|
||
No test failed, so no isolation re-run was needed. All 19 skips are **pre-existing, env-gated**
|
||
suites, spread across five projects — `Core.Tests` (1), `Driver.Galaxy.Tests` (8),
|
||
`Driver.Historian.Gateway.Tests` (6, the `LiveIntegration` category that needs the
|
||
`wonder-sql-vd03` VPN), `OpcUaServer.Tests` (1), `Runtime.Tests` (3). **None of the skips are
|
||
the new SQL-Server-backed tests**: `AdminUI.Tests` ran **1537 / 0 skipped** and
|
||
`Configuration.Tests` **190 / 0 skipped**, which is the evidence that Task 7's pins actually
|
||
executed against a real SQL Server rather than skipping past themselves.
|
||
|
||
The DB-backed suites (`Configuration.Tests`, `AdminUI.Tests` including Task 7's new
|
||
SQL-Server-backed pins) ran **against a reachable** `10.100.0.35,14330` — they were executed,
|
||
not skipped. None of the known load-flake candidates flaked
|
||
(`DriverInstanceActorCommsLossWatchdogTests`, `RawTreeServiceRenameWarningTests`,
|
||
`RouteNativeAlarmAck_on_non_primary_is_dropped`, plus this branch's new
|
||
`SessionAclDecisionCache` concurrency stress test and the `ContinuousHistorizationRecorder`
|
||
timing tests).
|
||
|
||
### Per-task outcomes
|
||
|
||
| # | Task | Commit(s) | Outcome + deviation |
|
||
|---|---|---|---|
|
||
| 1 | Per-driver poll-overrun meter + `PollGroupEngine` doc notes | `e22ecea0`, `97902d68` | Done. Review deviation: the tag keys were literals; moved to snake_case consts (`driver_type` / `driver_instance`) so they match the rest of the Meter and a dashboard's group-by cannot split across two spellings. |
|
||
| 2 | `SpanReadMaxSpanBytes` validation guard | `acb37724` | Done, as planned — validated at parse time, with the over-PDU degradation documented. |
|
||
| 3 | Historian drain rethrows a shutdown `OperationCanceledException` | `7870c91f` | Done. Deviation in *severity*, not shape: the red run showed the bug did not merely inflate `_tagWriteFailures` — a shutdown mid-drain **discarded healthy tags' samples**. A pass abort, not a per-tag failure. |
|
||
| 4 | Bound the Direct-mode `LoadArtifactBlob` read | `dba5f806` | Done, with one addition the plan did not anticipate: an `IsRelational()` guard, because the extension throws on the InMemory provider the actor tests use. EF 10.0.7 verified **empirically** to reset the timeout on return to the pool, so the local `SetCommandTimeout` cannot pollute the pooled factory. ⚠️ The relational branch is consequently untested in-suite. |
|
||
| 5 | ACL `_globalCount` drift + concurrency stress test | `d8849345` | Done — **beyond the planned scope, and provably so.** The planned counter swap alone was insufficient; a seal sentinel *and* a `TryBookInsert` compare-exchange were both required. Falsification: removing either went 6/6 red. |
|
||
| 6 | Revalidation memo rejects empty node identifiers | `c4d8e4a2` | Done as a pattern-form guard mirroring the authorizer's; the bypass is pinned by a clock-call count rather than by observing the memo directly. |
|
||
| 7 | SQL-Server-backed pins for the drift watermark + rename collation | `c149410b` | Done. Deviation: `AdminUI.Tests` is pinned to **xunit v2** by `Akka.TestKit.Xunit2`, so `Assert.Skip` is unavailable — an overriding `SqlServerFactAttribute` gives a genuine skip instead. Both the `GroupBy` watermark aggregate and the `CHARINDEX` collation widening confirmed **live** against SQL Server. |
|
||
| 8 | `TrackBackgroundTask` registration ordering + volatile `_disposed` | `5ae43023` | Done via a TCS-placeholder **register-first** factory (registration precedes the work starting, closing the gap at its root rather than narrowing it); `_disposed` made volatile. |
|
||
| 9 | Durability-test robustness + honest #491 nondeterminism notes | `6b46c485` | Done. Deviation: the planned macOS skip proved **unnecessary** — verified empirically that a bounded wait is sufficient, so the test runs on macOS rather than skipping. The #491 falsification control's nondeterminism is inherent and is now documented, not fixed. |
|
||
| 10 | MTConnect sample-chunk parsing through `XmlReader` | `cda82c07` | Done. 566/566 parity against the pre-change parser, ~19% allocation win. One **disclosed** behaviour change: on a doubly-broken document the *ordering* of the reported errors differs. |
|
||
| 11 | Expose `ReadMultipleVarsAsync` through the `IS7Plc` seam | `6ee553cf` | Done, as planned. |
|
||
| 12 | S7 span leftovers batch through multi-var read PDUs | `20022083` | Done, with two forced deviations: S7.Net throws for the **whole** multi-var request rather than per item, so a bad item is handled by a **per-chunk fallback**; and Timer/Counter areas are **excluded** because S7.Net boxes them as `int` where the single-read path yields `ushort`, and box parity is load-bearing downstream. |
|
||
| 13 | S7 live gate (span reads + multi-var leftovers) | `57c66416` | **LIVE GATE PASSED** vs `10.100.0.35:1102`. Evidence below. |
|
||
| 14 | docker-dev live gate (regression of the merged remainder) | *(no commit — evidence below)* | **LIVE GATE PASSED.** Evidence below. |
|
||
| 15 | Close-out — full sweep + Outcomes | `7c5a843a`, *this commit* | Done. `7c5a843a` carries the review round's comment-only fixes; this commit carries the Outcomes, the live-gate evidence, the register annotations, and the ledger. |
|
||
|
||
### Live gate — S7 span reads (Task 13)
|
||
|
||
**2026-08-17, against the `s7_1500` fixture at `10.100.0.35:1102`. PASSED.**
|
||
|
||
- **Results:** 3/3 span-read tests green; 7 passed / 1 skipped across the full
|
||
`Device=S7_1500` suite. The single skip is the env-gated SYN-blackhole test, which needs a
|
||
recipe the fixture does not provide — not a silent skip of the gated behaviour.
|
||
- **Wire shape (the actual claim under test):** with spans **on**, 10 tags produced
|
||
**3 requests** — 2 block reads + 1 multi-var read carrying 3 items. With spans **off**, the
|
||
same 10 tags produced **10 requests**. Multi-variable and cross-area batching both confirmed
|
||
on the wire, and a falsification bit was flipped to prove the counter was measuring the real
|
||
traffic.
|
||
- **Reconnect suite** ran **live** (not simulated) via `S7_RECONNECT_BOUNCE_CMD` and passed.
|
||
- **Not live-pinned:** the Timer/Counter exclusion — the fixture has no TM/CT area, so the
|
||
box-parity reasoning behind excluding them rests on unit tests only.
|
||
- **Ops notes for whoever re-runs this** (both cost time on this run):
|
||
- The fixture **profile is baked into the image**, so `--build` is mandatory after editing a
|
||
profile. Confirm the seed actually took by looking for
|
||
`seeded DB1 … seeds=<n>` in `docker logs`; a stale image seeds the old profile silently.
|
||
- `/opt/otopcua-s7` on the docker host is **root-owned**, so a plain `rsync` fails with
|
||
status 23 **while looking successful** in its summary output. Use
|
||
`--rsync-path="sudo rsync"`.
|
||
|
||
### Live gate — docker-dev regression (Task 14)
|
||
|
||
**2026-08-17, local OrbStack rig, branch at `6b46c485`, ~14.5 min wall. PASSED.**
|
||
|
||
- **Boot:** clean-slate build + boot; 8 containers healthy, `RestartCount=0` across the board.
|
||
- **Deploy:** `c50678b2` sealed in **306 ms** with **6/6 Applied**.
|
||
- **Data plane:** browse / read / write end-to-end against the pymodbus fixture; a **180 s**
|
||
subscription streamed with **0 Bad** values.
|
||
- **Surgical apply (the headline regression target):** a **real** UNS-reference surgical apply
|
||
— deploy `3df4b321`, logging
|
||
`applied plan kind=PureAdd added=2 changed=1 surgicalRawTags=1 rebuild=False`. The open
|
||
subscription streamed **Good across the apply**, and the newly-added UNS mirror served live
|
||
immediately. This is the behaviour the 2026-08-15 surgical-deploys work exists to protect,
|
||
exercised as a genuine surgical path rather than a rebuild in disguise.
|
||
- **Deploy gate:** correctly rejected a malformed first attempt with **422**
|
||
(`UnsSegmentInvalid`, `EquipmentIdNotDerived`) — the gate is live, not bypassed.
|
||
- **AdminUI:** `/fleet`, `/alerts`, `/hosts`, `/uns`, `/raw`, `/deployments` all 200 with live
|
||
data.
|
||
- **Logs:** 0 ERR/FTL, excluding the by-design standby 503s.
|
||
- **Teardown:** clean.
|
||
- **Anomalies (non-blocking, all disclosed):**
|
||
- A transient **404 on the first `POST /api/deployments`** ~40 s after boot; the retry
|
||
succeeded. Scripted deploys should **poll** rather than fire once (see the follow-up
|
||
register below).
|
||
- Boot-time telemetry-dial **reconnect warnings**, settling within one cycle.
|
||
- `ScriptLogEntry` **dead-letters on site nodes**, one per deploy — **pre-existing**, not
|
||
introduced by this branch.
|
||
- **NOT exercised (say so plainly, do not read the pass as wider than it is):** historian
|
||
end-to-end (needs the VPN), the `FetchAndCache` flip, the ACL-**enabled** enforcement path,
|
||
the alarm-bearing surgical **ratchet** to rebuild, load-scale throughput, and real-browser
|
||
Blazor circuits.
|
||
|
||
### Follow-up register (opened by this round)
|
||
|
||
Open items this branch surfaced but deliberately did not close. **None blocks the branch.**
|
||
|
||
**ACL**
|
||
|
||
- ⚠️ **Pre-existing generation-rollover window in `SessionAclDecisionCache.InvalidateIfStale`.**
|
||
While the CAS winner is running `ClearAll()`, a racing caller can still serve a
|
||
stale-generation verdict out of a shard the winner has not purged yet. The window is
|
||
microseconds and **key revocation/expiry are unaffected** (both are re-checked *above* the
|
||
cache on every call), so this is a stale *grant walk*, not a stale key. Fix shape if it ever
|
||
matters: stamp the generation on `Shard` itself, or make the racer wait on the winner.
|
||
- **Closed by `1979cf41` + `98d6a190` (r2).** Took the first fix shape — the generation is
|
||
stamped on `Shard`, `TryGet` is generation-gated, and `Set` displaces a mismatched shard;
|
||
`98d6a190` added the monotonic `<=` guard that stops a stale-generation straggler rolling
|
||
the stamp backward. One benign residual remains (see r2's register).
|
||
- **A fresh `Shard` could take an initial-capacity hint** for a session known to be crawling,
|
||
to avoid the dictionary's growth rehashes on exactly the workload the per-key ceiling exists
|
||
for.
|
||
- **Closed by `1979cf41` (r2).** The eviction swap seeds the replacement shard with a
|
||
capacity hint, gated on the generation matching.
|
||
|
||
**S7**
|
||
|
||
- **No cancellation-branch test on any read path.** Pre-existing, but this branch added a third
|
||
path (multi-var), so the gap now spans three.
|
||
- **Closed by `4f860733` (r2).** The span and multi-var cancellation reopen contracts are
|
||
pinned (hang knobs + entered-TCS + `CreatedPlcCount`), and `120e885b` added the assertion
|
||
that the stale handle is *disposed*, not merely replaced.
|
||
- **No integration case for one bad decode inside an otherwise-good chunk** — the per-chunk
|
||
fallback is unit-tested only.
|
||
- **Closed by `4f860733` (r2).** One-bad-decode-in-chunk pinned (only that tag goes Bad, no
|
||
fallback), alongside the short-answer shape mismatch (whole-chunk fallback).
|
||
- **The Timer/Counter exclusion needs a live pin**, which needs a fixture with a TM/CT area.
|
||
- **Closed by `fd202d81` (r2).** TM/CT areas added to the `s7_1500` fixture and the exclusion
|
||
live-pinned against `10.100.0.35:1102` **at device 0**, the fixed point of snap7's
|
||
BIT-address resolution and a real CPU's device-number addressing; the device-number leg
|
||
(T5/C7) is env-gated on `S7_TIMER_NUMBER_ADDRESSING=1` as a ready-made real-CPU gate.
|
||
|
||
**Historian**
|
||
|
||
- **Task 3's `Task.Delay(500)` is still a hope-wait** and could become a bounded poll, the same
|
||
treatment Task 9 gave the durability test.
|
||
- **Closed by `45d67092` + `68461fbf` (r2).** All three fixed sleeps became bounded watches on
|
||
real observables (`RemoveCallCount`, queue depth, `PeekBatchCallCount`), with the negative
|
||
watch's cap anchored to a measured ~150 ms falsification trip.
|
||
|
||
**AdminUI tests**
|
||
|
||
- **A mid-run SQL connectivity blip surfaces as `SqlException` failures, not skips** — the
|
||
reachability probe is one-shot, taken before the run.
|
||
- **Documented wontfix (r2).** The one-shot probe is deliberate — a per-test probe would turn
|
||
a genuine mid-run outage into silent skips; verified against the comment at the probe site
|
||
and left as-is.
|
||
- **Orphaned test databases have no reaper.** A killed test process leaves its uniquely-named
|
||
database behind on `10.100.0.35,14330`.
|
||
- **Closed by `8946644b` + `580b77ce` (r2).** A one-shot startup reaper drops >24 h-orphaned
|
||
databases per fixture prefix; `580b77ce` fixed a review-found Critical (unvalidated name
|
||
into interpolated `sa` DDL) with a GUID-suffix shape guard plus parameterized
|
||
`QUOTENAME`/`sp_executesql`.
|
||
|
||
**MTConnect**
|
||
|
||
- **`AsStream` wants a buffer-ownership comment** so a future caller does not have to infer the
|
||
lifetime from the call site.
|
||
- **Closed by `1edfc263` (r2).** The doc comment now states the array-backed aliasing, the
|
||
synchronous call-duration lifetime, and that the result holds no reference into the buffer.
|
||
|
||
**docker-dev**
|
||
|
||
- **The first `POST /api/deployments` after boot can 404.** Worth a readiness gate on the
|
||
deployments endpoint, so scripted deploys do not have to encode a retry.
|
||
- **Closed by `a61b3c98` + `9f09a62a` (r2)** — as a *gate plus instrumentation*, not a
|
||
diagnosis: `deploy.sh` polls `/health/ready` (2 s/120 s) then POSTs once un-retried, and
|
||
Traefik now runs `--accesslog` so the next occurrence names the matching router. **The 404's
|
||
mechanism remains unconfirmed** until that log catches one.
|