24 KiB
Perf Follow-ups Round 2 Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to implement this plan task-by-task. Opus implementers; classification-driven review chains.
Goal: Close the follow-up register opened by the 2026-08-17 perf follow-ups round
(docs/plans/2026-08-17-perf-followups.md §"Follow-up register") — the ACL
generation-rollover window, the S7 coverage gaps (cancellation branches, one-bad-decode,
Timer/Counter live pin), the historian test hope-waits, the orphaned-test-DB reaper, the
MTConnect ownership comment, and the docker-dev deploy readiness gate.
Architecture: No new subsystems. One production concurrency fix (generation stamp on the
ACL cache's Shard), one production doc comment (MTConnect), one rig hardening
(docker-dev/deploy.sh + Traefik access log); everything else is test/fixture work. Branch
perf/followups-r2 off master (7bb888be).
Tech Stack: .NET 10, xUnit + Shouldly, Akka.NET TestKit, python-snap7 fixture on
10.100.0.35, SQL Server at 10.100.0.35,14330, Traefik v3.1 on the local OrbStack rig.
Out of scope (say so once, here): the cross-repo HistorianGateway batch WriteLiveValues
RPC (sister repo); the VPN-gated historian E2E; the FetchAndCache / ACL-enabled / alarm-ratchet
/ load-scale live exercises (separate live-gate effort, on request).
SHARED-CHECKOUT RULES (every implementer, verbatim from round 1)
All implementers share ONE checkout on branch perf/followups-r2. Therefore:
- Stage and commit with explicit pathspecs only:
git add -- <files>thengit commit -m "<msg>" -- <files>. NEVER baregit commit,git add .,git add -A. - NEVER run
git checkout,git switch,git reset,git stash,git clean, or touch files outside your task'sFiles:contract. - On
index.lockcontention or transient build races (CS2012, MSB3030): wait 5–15 s and retry. - Falsification mutations (temporarily reverting a fix to prove a test bites) are made with Edit and reverted with Edit — never with git.
- TDD red-first where the plan says so; when a test pins existing behaviour instead, label it honestly ("pin, not red-first") in your report.
- Build/test only your own projects (
dotnet build <csproj>/dotnet test <csproj> [--filter ...]), not the whole solution.
Task 1: Close the ACL generation-rollover window; capacity hint on eviction
Classification: high-risk Estimated implement time: ~5 min Parallelizable with: Task 2, Task 3, Task 4, Task 5, Task 6, Task 7
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/SessionAclDecisionCache.cs - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SessionAclDecisionCacheTests.cs
The defect (pre-existing, recorded by round 1). InvalidateIfStale (lines 342–350)
publishes the new _generation in the CAS at line 349 before the winner's ClearAll()
(365–370) has purged the shards. A racer entering TryGet (167–183) in that window reads
seen == generation, skips clearing, and serves an entry written under the previous
generation from a shard the winner has not removed yet. Key revocation/expiry are unaffected
(re-checked above the cache), so this is a stale grant walk only — but it is real, and the fix
shape was already chosen in round 1's register: stamp the generation on Shard itself.
Spec:
Shard(lines 378–391) gains a constructor takinglong generation(stored in apublic readonly long Generationfield) and an optional initial-capacity hint forEntries(see step 6). All four construction sites inSet(lines 208, 221, 232, 236) pass the current query generation.TryGetserves a hit only whenshard.Generation == generation. A generation-mismatched shard is a miss (do not evict from the read path —TryGetmust stay allocation-free and non-mutating; the winner'sClearAllor the nextSetreclaims it).Set: after acquiring the shard, ifshard.Generation != generation, evict-swap exactly like the existing per-shard bound path (TryUpdate(sessionKey, new Shard(generation), shard)Interlocked.Add(ref _globalCount, -Seal(shard)), then re-acquire) before inserting._globalCountaccounting must stay exact — the seal/booking protocol fromd8849345is load-bearing; do not weakenTryBookInsert/Seal.
- Deterministic pin of the window (this is the red-first test): add an internal
test-only seam
internal void AdvanceGenerationForTest(long generation)that CAS-moves_generationWITHOUT callingClearAll— exactly the racer's view of the window. Test:Setunder gen 1,AdvanceGenerationForTest(2), thenTryGet(gen 2, …)for the same key must miss. Run it before the fix: it goes red (the stale entry is served). After the fix: green. Falsification after fixing: temporarily remove theshard.Generationcheck via Edit, confirm red, restore. - Extend the existing stress test (lines 186–264) or add a sibling: interleave generation
bumps with readers/writers and assert no hit ever returns a value stored under an older
generation (encode the generation in the stored bool — e.g. store
gen % 2 == 0— and assert every hit equals the current generation's encoding). Also keep assertingTrackedCount/LiveShardCountexactness at quiescence. - Capacity hint (second register item, same ctor): only the eviction-swap site (line 232
path) passes a capacity hint — the evicted shard's sealed count, clamped to a sane bound
(e.g.
Math.Clamp(captured, 0, MaxEntriesPerShard)or the existing per-key ceiling const) — because a shard evicted at the ceiling is the crawler signal. PlainGetOrAddshards stay default-capacity. One test: after an eviction swap, inserts proceed correctly (a behaviour pin; capacity itself is not observable — say so in the test comment). - Update the class-level comments where they describe the invalidation story; do not restate history, state the invariant ("a shard can only serve the generation it was built under").
Steps: red test (4) → fix (1–3) → green → falsification → stress (5) → capacity (6) →
dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter "FullyQualifiedName~SessionAclDecisionCache"
→ commit with pathspecs: git commit -m "fix(acl): stamp the generation on Shard so a rollover racer cannot serve a stale-generation verdict; eviction-swap capacity hint" -- <the two files>.
Task 2: S7 read-path coverage — cancellation branches + one bad decode in a chunk
Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 1, Task 3, Task 4, Task 5, Task 6, Task 7
Files:
- Test (only):
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7SpanReadParityTests.cs
Context. Three read paths carry the STAB-15a cancellation contract (_plcDead = true +
rethrow): per-tag (S7Driver.cs:649-660, covered by
S7DriverReconnectTests.Cancelled_read_marks_handle_dead_and_next_read_reopens), span block
read (S7Driver.cs:920-926, uncovered), multi-var leftovers (S7Driver.cs:803-809,
uncovered). The per-chunk fallback (:810-817) has one whole-request-fault test; the
per-ITEM decode guard (:819-836, null value → InvalidDataException → that tag Bad) and the
shape-mismatch arm (:796-801) have none. ImageS7PlcFactory
(S7SpanReadParityTests.cs:453, ImageS7Plc at :480) exposes only ReadFault and
MultiVarFault knobs today.
Spec — all tests are pins of existing behaviour unless marked red-first:
- Extend
ImageS7PlcFactory/ImageS7Plcwith three knobs, mirroring the existing fault knobs' style:HangReadBytes(aTaskCompletionSourcegate):ReadBytesAsyncawaitsTask.Delay(Timeout.Infinite, ct)so cancellation is the only exit — theS7DriverReconnectTestsfake at:557-560is the pattern.HangMultiVar: same forReadMultipleVarsAsync.NullItemIndex(int?):ReadMultipleVarsAsyncreturns the normal answered list but with item N'sValueleft null — the decode guard's trigger at:824-826. Track aCreatedPlcCounton the factory if not already present (reopen observable).
- Cancelled span block read: plan spans on, hang
ReadBytesAsync, cancel the read's CTS, assertOperationCanceledExceptionpropagates out ofReadAsync, and the NEXT read constructs a fresh plc (CreatedPlcCountincrements — the_plcDeadreopen contract), using the same assertion shape as the reconnect test atS7DriverReconnectTests.cs:338-355. - Cancelled multi-var read: spans on with a leftover batch (reuse the existing
multi-var-eligible tag set from
:257/:281), hangReadMultipleVarsAsync, cancel, same two assertions. - One bad decode inside an otherwise-good chunk:
NullItemIndex = 1on a 3-item multi-var chunk → exactly that tag lands Bad (RecordReadFailurestatus), its chunk-mates Good with correct values,MultiVarCalls == 1, and no per-tag fallback reads occur for the good items (per-item failure is not chunk failure). Assert driver health reflects the partial failure the same way the existing fallback test does. - Shape mismatch: a knob (or reuse
NullItemIndex-style) makingReadMultipleVarsAsyncreturnchunk.Count - 1items → the whole chunk falls back per-tag (:796-801→InvalidDataException→:810-817), all values still served Good via fallback. - Falsification for (2) and (3): via Edit, temporarily change the production OCE arm to swallow (mimic the pre-#527-style bug) and confirm the reopen assertion goes red; restore. Note in the report which line was mutated.
Steps: knobs → tests (each run individually) → falsification → full file run
(dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests --filter "FullyQualifiedName~S7SpanReadParityTests")
→ commit test(s7): pin the span/multi-var cancellation reopen contract, one-bad-decode-in-chunk, and multi-var shape mismatch -- tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7SpanReadParityTests.cs.
Task 3: Timer/Counter live pin — TM/CT area in the s7_1500 fixture
Classification: standard (live-gated) Estimated implement time: ~5 min authoring + fixture redeploy wall-time Parallelizable with: Task 1, Task 2, Task 4, Task 5, Task 6, Task 7 (disjoint files; the live leg runs against the shared fixture host, not the checkout)
Files:
- Modify:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/profiles/s7_1500.json - Modify:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500SpanReadTests.cs - Modify (if needed for seeding TM/CT):
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/server.py - Modify:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/README.md
Context. The round-1 gate could not live-pin the Timer/Counter multi-var exclusion because
the fixture profile declares only DB1 + MK (s7_1500.json), though server.py:34-41's
AREA_MAP already supports TM/CT (snap7.type.SrvArea). The exclusion itself:
S7ReadSpanPlanner.cs:94-103 (planner → leftovers) and S7Driver.cs:1129-1132
(IsBufferType → never multi-var). Timer/Counter entries are 2-byte device-number-indexed
cells; a "device number N" seed lands at byte offset 2*N big-endian in the area buffer.
Spec:
- Add
TMindex 0 andCTindex 0 areas (size 64 bytes each = 32 devices) tos7_1500.jsonwith 2–3 distinct non-zero seeds each (the #536 rule: a zero-valued source proves nothing) at known device numbers,_desc'd. Keep every existing seed byte-identical — the round-1 tests depend on them. - New
[SkippableFact]-style test (follow the file's existing gating convention) inS7_1500SpanReadTests.cs: author a tag set mixing multi-var-eligible scalars with one Timer and one Counter tag; spans on. Assert:- the Timer/Counter values read back as the seeded non-zero constants (correct decode on a real wire), and
- via
CountingPlc, no multi-var request ever contains a Timer/Counter item and the TM/CT reads arrive as their own block/per-tag requests — the live pin of the exclusion.
- Accepted alternate outcome, stated in advance: S7.Net's TM/CT read is a distinct wire shape the python-snap7 server has never served. If it genuinely cannot (live evidence: error/exception captured, not a guess), do NOT force it: env-gate the test with a skip reason naming the fixture limitation, record the evidence in the Outcomes, and the exclusion stays unit-pinned. A red herring here must not burn the task.
- Fixture redeploy recipe (round-1 ops notes, both bit last time):
rsync -av --rsync-path="sudo rsync" tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/ dohertj2@10.100.0.35:/opt/otopcua-s7/thenssh dohertj2@10.100.0.35 'cd /opt/otopcua-s7 && docker compose build && docker compose up -d --force-recreate'—--buildis mandatory (profile is baked into the image) and confirmseeded DB1 … seeds=<n>(and the new TM/CT lines) indocker logs. - Run:
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests --filter "FullyQualifiedName~S7_1500SpanReadTests"with the fixture live. All pre-existing tests must stay green (the seed-compat rule in step 1).
Commit: test(s7): live-pin the Timer/Counter multi-var exclusion against a TM/CT-seeded s7_1500 fixture with pathspecs.
Task 4: Historian recorder tests — bounded watches instead of hope-waits
Classification: standard Estimated implement time: ~4 min Parallelizable with: Task 1, Task 2, Task 3, Task 5, Task 6, Task 7
Files:
- Test (only):
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationRecorderTests.cs
Context. Three fixed sleeps synchronize negative assertions ("X did not happen"):
line 602 (FromMilliseconds(500) before asserting no truncation after a shutdown-cancelled
write), line 452 (300 ms before asserting an abandoned tag stopped being rewritten), line 857
(300 ms before asserting sequential-drain concurrency). A negative can't be polled for
directly; each needs a positive completion observable, then the assert. The Task-9
treatment (FasterLogHistorizationOutboxTests.cs:203-231) is the pattern for the poll shape
(Stopwatch + 25 ms + cap, return last observation). InMemoryOutbox has
AppendCallCount/AppendBatchCallCount/PeekBatchCallCount/DroppedCount but no
RemoveCallCount.
Spec:
- Add
RemoveCallCounttoInMemoryOutbox(increment inRemoveAsync), same style as the existing counters. - Line 602 test: replace the sleep with a bounded watch: after
writer.Cancelled, poll (25 ms / 2 s cap) for the bug's positive signal —RemoveCallCount > 0orCountAsync != 2— failing fast red if either fires, green after the full window if neither does. Comment: "bounded negative watch — red is immediate when the fold truncates, green costs the window once". Keep the existing preconditions (594–595) untouched. - Line 452 test: replace wall-clock with the drain's own clock: snapshot
PeekBatchCallCount, poll until it has advanced by ≥ 3 (more passes have demonstrably run), then assert theGood.Tagwrite count is unchanged. Cap 5 s; on cap-out without 3 passes, fail with a message naming the observed pass count. - Line 857 test: same pass-count clock (≥ 2 further passes) before the concurrency
asserts; keep the existing
AwaitAssertAsyncprecondition. - Do NOT touch the deliberate mechanics delays (1476, 1640, 1656, 1673 — documented pool-thread forcing; leave them and their comments alone).
- Each reworked test: run 5× locally (
--filter) to check stability; note the timings in the report.
Commit: test(historian): replace the recorder tests' fixed sleeps with bounded watches on drain-pass and truncation observables with pathspecs.
Task 5: Orphaned test-database reaper (AdminUI + Configuration fixtures)
Classification: standard Estimated implement time: ~4 min Parallelizable with: Task 1, Task 2, Task 3, Task 4, Task 6, Task 7
Files:
- Modify:
tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AdminUiSqlServerFixture.cs - Modify:
tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceFixture.cs - Test: a new
[SqlServerFact](AdminUI side) proving the reaper drops an old-named DB
Context. A killed test process leaks its uniquely-named database forever:
OtOpcUaAdminUi_Test_<yyyyMMddHHmmss>_<GuidN> (AdminUiSqlServerFixture.cs:77) and
OtOpcUaConfig_Test_… (SchemaComplianceFixture.cs:30). The fixture comment at 127–128 even
says the timestamp exists "so a leaked one is identifiable" — the reaper was never written.
Natural insertion point: fixture ctor, after the probe early-return (:75), before the name
assignment (:77). The drop SQL to reuse is :118-121; TryDropDatabase must be extracted
to a static taking a name.
Spec — safety first, this runs DROP DATABASE against a shared server:
- Extract the drop body into a static
TryDropDatabase(string databaseName)(best-effort,DbException-swallowing, exactly the current semantics); the instance method delegates. - Add a static reaper, run once per process behind a
LazyinAdminUiSqlServer(mirroring the probe):SELECT name FROM sys.databases WHERE name LIKE 'OtOpcUaAdminUi\_Test\_%' ESCAPE '\', parse the embeddedyyyyMMddHHmmss(UTC), and drop ONLY rows older than 24 hours; a name whose timestamp does not parse is left alone. Never throw — a reaper failure must not fail a green run. Log nothing louder thanConsole.WriteLine(test infra convention in this file). - Same shape in
SchemaComplianceFixturefor theOtOpcUaConfig_Test_prefix, one-shot per process. (It has no probe/skip machinery — keep the reapercatch-all so an unreachable server behaves exactly as today.) - Test (red-first is impossible for a time-gated reaper without waiting a day, so pin it
deterministically): a
[SqlServerFact]that creates an empty DB named with a >24 h-old timestamp (e.g.OtOpcUaAdminUi_Test_20250101000000_<guid>), invokes the reaper's core (make the core internal-testable, taking the cutoff as a parameter), and asserts the DB is gone — and a second DB named with a fresh timestamp survives the same call. Clean up the survivor in the test. - The mid-run-blip semantics (one-shot probe → mid-run
SqlException= failures, not skips) stay as-is — they are already documented at:34-38; verify the comment survives your edit, extend it with one line noting the deliberate non-choice if it doesn't already say so.
Commit: test(sql): reap >24h-old orphaned test databases at fixture startup (AdminUI + Configuration prefixes) with pathspecs.
Task 6: MTConnect AsStream buffer-ownership comment
Classification: trivial Estimated implement time: ~1 min Parallelizable with: all
Files:
- Modify:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectStreamsParser.cs(theAsStreamdoc comment at lines 241–249)
Spec: extend the existing <summary> with the ownership facts (verified this round):
the array-backed branch aliases the caller's array without copying; the buffer is not
pooled or rented anywhere on this path; parsing is fully synchronous, so the buffer must stay
valid and unmutated only for the duration of the call; the returned
MTConnectStreamsResult holds no reference into the buffer (XmlReader materialises its own
strings). Comment-only — no code change, no test.
Commit: docs(mtconnect): state AsStream's buffer aliasing and lifetime contract with pathspec.
Task 7: docker-dev readiness-gated deploy helper + Traefik access log
Classification: small Estimated implement time: ~4 min Parallelizable with: all
Files:
- Create:
docker-dev/deploy.sh - Modify:
docker-dev/docker-compose.yml(Traefik command block, lines ~912-916) - Modify:
docker-dev/README.md(the bare POST snippet at lines ~88-91)
Context (root-cause facts, record them in the README): the transient first-POST 404 can NOT
come from the AdminUI (all routes map synchronously before RunAsync
(Program.cs:740 vs :803); the endpoint's own failures are 503/401/422, never 404) and can
NOT be Traefik's backend-down answer (that is 503 no available server). A 404 from :9200
means no Traefik router matched at that instant — mechanism unconfirmed; the access log
below is how the next occurrence gets diagnosed.
Spec:
deploy.sh(bash,set -euo pipefail): pollshttp://localhost:9200/health/readyuntil HTTP 200 (2 s interval, 120 s cap — a 200 through Traefik proves both the router table and a ready backend), then POSTs the artifact exactly as the README snippet does (X-Api-Key: docker-dev-deploy-key, JSON body from$1or stdin), prints the response, and exits non-zero on any non-2xx. No retry on the POST itself — the gate is the fix, and a post-gate 404 should fail loudly (it would be new evidence).- Add
--accesslog=true(and--accesslog.fields.defaultmode=keep) to the Traefik command list so a recurrence shows which router (or none) matched. - README: replace the bare POST snippet with
./deploy.sh, and add a short "first-POST 404" note carrying the root-cause facts above. - Verification is docker-dev-rig-dependent;
bash -n deploy.sh+ adocker compose configparse check are the in-checkout gate. The rig run happens in Task 8's close-out if the rig is brought up; do not block this task on it.
Commit: chore(docker-dev): readiness-gated deploy.sh + Traefik access log for the first-POST 404 with pathspecs.
Task 8: Close-out — sweep + Outcomes + register annotations
Classification: standard Estimated implement time: ~10 min (mostly sweep wall-time) Parallelizable with: none (blockedBy all)
Files:
- Modify:
docs/plans/2026-08-17-perf-followups-r2.md(append## Outcomes) - Modify:
docs/plans/2026-08-17-perf-followups.md(append-only "closed by " annotations on the round-1 register items this round closes; the two live-exercise items that stay open stay open) - Modify:
docs/plans/2026-08-17-perf-followups-r2.md.tasks.json(ledger)
Steps:
dotnet build ZB.MOM.WW.OtOpcUa.slnx→ 0 errors (pre-existing warnings tolerated).- Full per-project unit sweep of every
*.Testsproject (--no-buildafter the solution build), same protocol as round 1. DB-backed suites must RUN (not skip) against10.100.0.35,14330. Expected: 0 failed; new-test count above round 1's 10,358 baseline. - S7 integration suite against the live fixture (Task 3's evidence re-confirmed at the final tree).
- Append
## Outcomes: per-task table (commit, outcome, deviation), the TM/CT live evidence (or the documented fixture limitation), the reaper's first live reap count, any new follow-up register (only genuinely new items). - Annotate the round-1 register (append-only, never rewrite): ACL rollover + capacity hint, S7 cancellation + one-bad-decode + TM/CT, historian hope-wait, AdminUI reaper (blip semantics = documented wontfix), MTConnect comment, docker-dev readiness gate — each with its closing SHA.
- Commit docs with pathspecs; update the ledger.
Execution notes for the controller
- Wave 1: Tasks 1–7 dispatched concurrently (all
Files:contracts disjoint). Task 8 after all verdicts land. - Review chains per classification: T1 high-risk (spec serial → code); T2/T3/T4/T5/T8 standard (spec ∥ code); T7 small (code only); T6 trivial (none).
- Implementers: Opus (user preference), including the trivial task for uniformity.
- Reviewers judge via
git show <sha>; file-disjoint scheduling makes on-disk fallback acceptable as in round 1.