50 KiB
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
WriteLiveValuesRPC — cross-repo (~/Desktop/HistorianGatewayproto + client-package release + pin bump here). Stays indocs/Historian.mdas the recorded ceiling onDrainConcurrency. - S7
SByte— a plan error, already corrected; not a gap. UpsertNodeDeploymentState/TryRecoverFromStalesynchronous writes — commented inDriverHostActor.cs:2930-2932/:2979-2982as 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-vd03VPN (user-driven); recipe staysdocs/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).
- Add to
OtOpcUaTelemetry(following the existingCreateCounterfield pattern, e.g.otopcua.driver.lifecycleat 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)."); - In each of the seven drivers, pass
onOverrun: _ => OtOpcUaTelemetry.PollOverruns.Add(1, new KeyValuePair<string, object?>("driver.type", "<type>"))where<type>is the driver'sDriverTypeNamesconstant (use the constant, not a literal — theTwinCat/Focasdrift was a real bug once). If the constructor scope has a cheap device/instance identifier already in hand, add a seconddevicetag; 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 theCommonsreference, add the ProjectReference; report it rather than inventing a per-driver Meter (Galaxy's own-meter pattern is the outlier, not the template). - 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.backoffCapctor param (line 104): doc its units (aTimeSpancap on the exponential failure backoff) and its interaction withminInterval(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).
- Update lines 47-50: the follow-up is now delivered driver-side via
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→ throwArgumentException/config-error consistent with howParseOptionsreports other bad config (read the method; match its existing error style).SpanReadMaxGapBytes < -1→ same (−1 = disabled is legal, perS7Driver.cs:571's>= 0enable 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
SpanReadMaxSpanBytesremark inS7DriverOptions.csstating 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/ArtifactForApplyintests/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):
- In
LoadArtifactBlob, afterCreateDbContext():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. - Do not thread a
CancellationToken—FirstOrDefaultwould 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.SqlClientif 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.
- Fixture: copy
SchemaComplianceFixture's shape (tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceFixture.cs): envOTOPCUA_CONFIG_TEST_SERVER(default10.100.0.35,14330) +OTOPCUA_CONFIG_TEST_SA_PASSWORD(defaultOtOpcUaDev_2026!), unique per-run DB name,Database.Migrate(), drop-on-dispose, collection fixture. One deliberate deviation: probe reachability first (short TCP/SqlConnectionattempt) and expose aSkipReason; tests callAssert.Skipwhen unreachable (theSnap7ServerFixture.cs:41-92pattern) — AdminUI.Tests must stay green offline, unlike Configuration.Tests which fails hard. Document the deviation in the fixture doc comment. - Drift probe test: seed
ConfigEditsrows with known timestamps, run the real probe against the SQL-backed context factory, assertLastEditUtc/EditCount— proving theGroupBy(_ => 1)aggregate translates and returns the right watermark (a translation failure throws; wrong translation returns wrong values — both caught). - Rename scan test: seed a
ScriptwhoseSourceCodecontains 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 — InMemorystring.Containsis case-sensitive). Assert the warning names the script. Also one same-case control. Exercise through the realBuildScriptReferenceWarningAsync(RawTreeService.cs:1437-1470) with > 1 batch if cheap (201 paths) to also cross the chunk boundary against the real provider. - 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:
- Registration-ordering: callers evaluate the async-method argument (which starts the
work) before
TrackBackgroundTaskadds it to_inFlight(TrackBackgroundTask(DrainDirtyAsync(...))at:559).Disposecan 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. giveTrackBackgroundTaskaFunc<Task>overload that (under_inFlightLock) installs a placeholder/TaskCompletionSource(or checks_disposedand declines to start), then invokes the factory and links completion — so a task that runs is always visible toDispose's snapshot, and after_disposedno 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 viaTask.Runon a fresh stack, commitc8a70143), theExecuteSynchronouslyself-removal, andDispose's synchronousWhenAlldrain. If the handover call site (TrackBackgroundTask(Task.Run(() => DrainDirtyAsync(ct)))) needs reshaping to the factory form, keep theTask.Runinside. _disposed: make itvolatile(:172) — it is written inDispose(: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(+ helperReadCommitted:203+) - Modify:
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationRecorderTests.cs:359-387,:797-831(doc comments only)
Spec.
AppendBatch_group_commit_is_durable_before_disposedepends on filesystem flush visibility to a secondFasterLogreader, 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 toCommittedUntilAddress) 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-conditionalAssert.Skipon macOS with a comment naming the platform limitation. Report which.- 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-793comment 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 threeParseoverloads: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
Buildon every existing test — the current suite is the spec. Namespace handling: MTConnect documents version their namespace URI; whatever name/namespace matchingBuilddoes today (local-name-based or namespace-qualified) must be preserved exactly — readBuildfirst and mirror it. - Keep the
AsStreamzero-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(theperTagleftover loop,:602-614area) - Test:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/(extendS7SpanReadParityTests.csor 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/VarTypedoes 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 aDataItem; 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 = 12with 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
ReadMultipleVarsAsyncthrows for a chunk, fall back to the existing per-tagReadOneAsyncpath 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 (reuseRecordReadFailuresemantics). 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'sDataItemresult 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):
- 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).
- Read the batch with span reads enabled (defaults) and again with spans disabled
(
SpanReadMaxGapBytes = -1); assert value-for-value parity and all-Good quality. - Run it live against the fixture: probe
10.100.0.35:1102first; 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-fixdoes not exist here). Thendotnet 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):
docker compose -f docker-dev/docker-compose.yml up -d --build(builds the current branch image); wait forcluster-seed"done." (docker-dev/README.md:71-77).- Verify all nodes healthy; AdminUI at
http://localhost:9200(login disabled — drive it directly). - Deploy: headless
POST http://localhost:9200/api/deploymentswithX-Api-Key: docker-dev-deploy-key; confirm the deployment seals green (all nodes acked). - Data plane: Client.CLI
browse+subscribeagainstopc.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). /alertsand/hostspages load and stream (exercises the Fleet/Hosts poll-guard and telemetry changes).- 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_1500suite. 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_CMDand 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
--buildis mandatory after editing a profile. Confirm the seed actually took by looking forseeded DB1 … seeds=<n>indocker logs; a stale image seeds the old profile silently. /opt/otopcua-s7on the docker host is root-owned, so a plainrsyncfails with status 23 while looking successful in its summary output. Use--rsync-path="sudo rsync".
- The fixture profile is baked into the image, so
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=0across the board. - Deploy:
c50678b2sealed 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, loggingapplied 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,/deploymentsall 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.
ScriptLogEntrydead-letters on site nodes, one per deploy — pre-existing, not introduced by this branch.
- A transient 404 on the first
- NOT exercised (say so plainly, do not read the pass as wider than it is): historian
end-to-end (needs the VPN), the
FetchAndCacheflip, 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 runningClearAll(), 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 onSharditself, or make the racer wait on the winner.- Closed by
1979cf41+98d6a190(r2). Took the first fix shape — the generation is stamped onShard,TryGetis generation-gated, andSetdisplaces a mismatched shard;98d6a190added the monotonic<=guard that stops a stale-generation straggler rolling the stamp backward. One benign residual remains (see r2's register).
- Closed by
- A fresh
Shardcould 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.
- Closed by
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), and120e885badded the assertion that the stale handle is disposed, not merely replaced.
- Closed by
- 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).
- Closed by
- 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 thes7_1500fixture and the exclusion live-pinned against10.100.0.35:1102at 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 onS7_TIMER_NUMBER_ADDRESSING=1as a ready-made real-CPU gate.
- Closed by
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.
- Closed by
AdminUI tests
- A mid-run SQL connectivity blip surfaces as
SqlExceptionfailures, 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;580b77cefixed a review-found Critical (unvalidated name into interpolatedsaDDL) with a GUID-suffix shape guard plus parameterizedQUOTENAME/sp_executesql.
- Closed by
MTConnect
AsStreamwants 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.
- Closed by
docker-dev
- The first
POST /api/deploymentsafter 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.shpolls/health/ready(2 s/120 s) then POSTs once un-retried, and Traefik now runs--accesslogso the next occurrence names the matching router. The 404's mechanism remains unconfirmed until that log catches one.
- Closed by