Files
lmxopcua/docs/plans/2026-08-17-perf-followups.md
T

50 KiB
Raw Blame History

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:

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 , 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.