Compare commits

..

15 Commits

Author SHA1 Message Date
Joseph Doherty 468f13cb56 Merge remote-tracking branch 'origin/master' into fix/trx-skip-reporting
v2-ci / unit-tests (pull_request) Waiting to run
2026-10-13 11:55:13 -04:00
Joseph Doherty f71fe13826 docs: record successful bounded CI baseline 2026-10-13 11:55:13 -04:00
dohertj2 6f0674296b Merge pull request 'Clarify S7 counter evidence and fixture limits' (#715) from docs/s7-counter-evidence into master
v2-ci / unit-tests (push) Has been cancelled
2026-10-13 11:54:57 -04:00
dohertj2 19bc13bf6a Merge pull request 'Bound CI concurrency and stabilize test fixtures' (#711) from fix/bootstrap-and-boundary-test-fixtures into master
v2-ci / unit-tests (push) Waiting to run
2026-10-13 11:54:15 -04:00
Joseph Doherty cd50db136e docs: clarify S7 counter evidence and fixture limits 2026-10-13 11:34:59 -04:00
Joseph Doherty eecf301200 ci: count individual skipped TRX results 2026-10-13 11:05:49 -04:00
Joseph Doherty 76bdca90e9 ci: bound build and test concurrency
v2-ci / unit-tests (pull_request) Successful in 1h1m1s
2026-10-13 10:52:12 -04:00
Joseph Doherty 8ff9f38bbd Make bootstrap cancellation test deterministic and deduplicate boundary cases 2026-10-13 09:56:37 -04:00
dohertj2 495606b9fb Merge pull request 'Clarify Galaxy native acknowledgement outcome documentation' (#708) from docs/galaxy-ack-rationale into master
v2-ci / build (push) Successful in 7m24s
v2-ci / unit-tests (push) Failing after 31m46s
2026-10-13 09:10:19 -04:00
Joseph Doherty f794dc50b6 docs(galaxy): clarify native alarm acknowledgement outcomes
v2-ci / build (pull_request) Successful in 7m16s
v2-ci / unit-tests (pull_request) Failing after 3h13m50s
2026-10-13 09:07:13 -04:00
dohertj2 7b23875070 Merge pull request 'fix(historian): preserve alarm delivery identity across retries' (#706) from fix/alarm-delivery-identity into master
v2-ci / build (push) Successful in 11m27s
v2-ci / unit-tests (push) Failing after 1h18m15s
2026-10-13 07:11:09 -04:00
Joseph Doherty 109163621c fix(historian): preserve stable alarm delivery identities
v2-ci / build (pull_request) Successful in 5m52s
v2-ci / unit-tests (pull_request) Failing after 1h23m55s
2026-10-13 07:10:58 -04:00
dohertj2 0ccfbbee5c Merge pull request 'Accept supported definitive loss reasons in restart wire test' (#704) from fix/restart-loss-reason into master
v2-ci / build (push) Successful in 12m15s
v2-ci / unit-tests (push) Failing after 35m1s
2026-10-13 06:30:22 -04:00
Joseph Doherty dccab57d39 test: accept definitive session loss reasons during restart
v2-ci / build (pull_request) Successful in 12m16s
v2-ci / unit-tests (pull_request) Failing after 3h13m34s
2026-10-13 06:30:08 -04:00
dohertj2 a935e21bf8 Merge pull request 'Cover concurrent gateway tag cache lookups' (#703) from test/historian-cache-concurrency into master
v2-ci / build (push) Successful in 6m57s
v2-ci / unit-tests (push) Failing after 3h11m16s
2026-10-13 05:22:42 -04:00
28 changed files with 711 additions and 103 deletions
+19 -22
View File
@@ -1,7 +1,6 @@
# CI for the v2 branch — runs on every push + PR to the v2-akka-fuse / master
# branches. Layered into two jobs:
# build dotnet restore + build (fast feedback on compile errors)
# unit-tests the WHOLE solution minus the env-gated tiers (self-maintaining)
# branches. One job restores and builds once, then tests the WHOLE solution
# minus the env-gated tiers (self-maintaining).
#
# Skips E2E (Category=E2E) and LiveIntegration (needs a real gateway/PLC/VPN) — both
# are env-gated tiers with no hosted-runner fixtures. (The skip-gated *.IntegrationTests
@@ -20,38 +19,35 @@ on:
branches: [v2-akka-fuse, master]
workflow_dispatch: {}
# #707: one active run per repository across push/PR triggers. Gitea 1.26 supports
# workflow concurrency; newer pending runs can supersede an older pending run.
concurrency:
group: lmxopcua-v2-ci
cancel-in-progress: false
env:
DOTNET_NOLOGO: "1"
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
jobs:
build:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
# #606: code-reviews/README.md must be regenerated from the per-module findings.md
# files; --check exits non-zero on drift without writing (see REVIEW-PROCESS.md section 5).
# #606: regenerated review summary must remain in sync with module findings.
- name: code-reviews README drift check
run: python3 code-reviews/regen-readme.py --check
- name: TRX skip guard fixtures
run: python3 scripts/ci/test-assert-not-all-skipped.py
# #707: bound project workers and avoid concurrent NuGet restore work.
- name: dotnet restore
run: dotnet restore ZB.MOM.WW.OtOpcUa.slnx
run: dotnet restore ZB.MOM.WW.OtOpcUa.slnx --disable-parallel -m:2
- name: dotnet build
run: dotnet build ZB.MOM.WW.OtOpcUa.slnx --no-restore --configuration Release
unit-tests:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- name: dotnet restore
run: dotnet restore ZB.MOM.WW.OtOpcUa.slnx
run: dotnet build ZB.MOM.WW.OtOpcUa.slnx --no-restore --configuration Release -m:2
# #606: the runner's size is the context every timing-sensitive failure needs and the log never
# recorded it (load average included: the two Gitea runners may be running jobs side by side).
- name: runner capacity
@@ -69,14 +65,15 @@ jobs:
# are unreachable, so they run harmlessly here (and are counted by the skip-gate below).
- name: dotnet test (whole solution, minus E2E + LiveIntegration)
# #606: Akka TestKit time dilation for the Runtime + ControlPlane actor suites (read by their
# harnesses, tests/Server/*/Harness/TestTimeFactor.cs). This leg runs every test assembly at once
# on the runner, and their 2–8 s presence budgets — sized on an idle dev box — failed in a long
# harnesses, tests/Server/*/Harness/TestTimeFactor.cs). Their 2–8 s presence budgets
# — sized on an idle dev box — failed in a long
# tail, a different test per run. Dilation only stretches waits: a presence wait still fails if
# the message never comes, and absence windows get longer (stricter). Local runs stay at 1.
env:
AKKA_TEST_TIMEFACTOR: "3"
run: >
dotnet test ZB.MOM.WW.OtOpcUa.slnx --configuration Release --no-restore
dotnet test ZB.MOM.WW.OtOpcUa.slnx --configuration Release --no-build
-m:1 -p:TestTfmsInParallel=false --settings scripts/ci/ci.runsettings
--filter "Category!=E2E&Category!=LiveIntegration"
--logger "trx" --results-directory artifacts/trx
# 07/S-2 fail-on-skip: a whole-solution run that executed ZERO tests is a masked outage
+13
View File
@@ -153,6 +153,19 @@ Ordering follows from that: the drain reads in `enqueued_at_utc` order (with `id
as a tiebreak so the ordering is total), because a hashed key carries no
insertion sequence.
Gateway delivery carries a separate nonzero `DeliveryId` GUID. Direct event instances
create it once; retries and record copies preserve it. It is excluded from queue JSON
and business equality, so the existing payload fingerprint and pair deduplication stay
unchanged. The drain derives its delivery GUID solely from the persisted row `id`,
including migrated legacy rows. It never hashes a reconstructed payload or mints an ID
per send. The versioned encoding in `AlarmDeliveryIds` therefore survives retry,
restart, payload edits and replicated failover without a schema migration.
Different business payloads at the same timestamp remain distinct; completely identical
payloads deliberately converge. An explicitly empty direct delivery ID is rejected.
The Gateway mapper sends this GUID as the wire event ID; the live alarm readback test
requires that exact ID. SQL Server and QuestDB retain their existing event-ID schemes.
`EnqueueAsync` does a single `INSERT … ON CONFLICT DO NOTHING` on the hot path.
To avoid a `SELECT COUNT(*)` on every enqueue, the sink keeps an in-memory
non-dead-lettered row counter (seeded at startup, kept current by every mutation,
+9 -3
View File
@@ -456,9 +456,15 @@ from the scripted `AlarmCommandRouter`:
carrying the authored `FullName` as the `ConditionId` and the
authenticated `OperatorUser`. The driver forwards this to the Galaxy
gateway → AVEVA via `GatewayGalaxyAlarmAcknowledger`.
**Fire-and-forget** — a failed upstream ack is not surfaced back to the
OPC UA client (mirrors the Galaxy write-outcome limitation; the local
`AlarmConditionState` SDK update already committed at step 1).
The Galaxy adapter awaits the correlated `AcknowledgeAlarmAsync` RPC:
gateway protocol failures throw, while a non-zero native return code is
warning-logged. **The actor route back to OPC UA is fire-and-forget** —
`DriverInstanceActor` logs and drops failures without replying to the
OPC UA client, whose local `AlarmConditionState` SDK update already
committed at step 1. This is a native-ack routing limitation; Galaxy
value writes have a separate correlated write-outcome path. An awaited
ack RPC does not prove the ack reached AVEVA event history: the H6 live
verification remains [operator-gated](plans/2026-06-15-stillpending-phase-3-opcua-standards-design.md).
Only the **Acknowledge** is routed to the driver. **A native condition
supports Acknowledge and AddComment only** (Gitea #587 review): `Confirm`,
+4 -2
View File
@@ -325,8 +325,10 @@ routes through a separate `NativeAlarmAckRouter` seam (instead of the scripted
FullName)` inverse map, Primary-gated) → the owning driver's
`IAlarmSource.AcknowledgeAsync` → Galaxy gateway → AVEVA. The call carries the
authenticated operator's display name via the `AlarmAcknowledgeRequest.OperatorUser`
field. This is fire-and-forget — a failed upstream ack is not surfaced back to the
OPC UA client (mirrors the Galaxy write-outcome limitation). See [AlarmTracking.md
field. The actor route back to OPC UA is fire-and-forget — a failed upstream ack
is logged rather than surfaced back to the OPC UA client. The Galaxy adapter
awaits its correlated RPC; this does not establish delivery to AVEVA event history.
Galaxy value writes use a separate correlated write-outcome path. See [AlarmTracking.md
§Native alarm acknowledge → AVEVA](AlarmTracking.md#native-alarm-acknowledge--aveva)
for the full routing diagram.
+45 -7
View File
@@ -198,15 +198,53 @@ Timers are **read-only** this phase; a write attempt returns `BadNotWritable`.
### Counter read
**Address**: `C{n}` (e.g. `C0`, `C10`). **DataType must be `Int32`**. The OPC UA node is
a scalar `Int32` whose value is the current **count** (raw word from the PLC).
a scalar `Int32` containing the unsigned **raw big-endian word**, widened to `Int32`:
`(bytes[0] << 8) | bytes[1]`. `S7.Net`'s `Counter.FromByteArray` performs no BCD
reinterpretation; for example, bytes `01 23` surface as `291`. This describes the
current decoder, not verified agreement with a PLC's displayed count.
Counters are **read-only** this phase; a write attempt returns `BadNotWritable`.
> **Known limitation — Counter BCD encoding on S7-300/400**: `S7.Net`'s
> `Counter.FromByteArray` returns the raw big-endian word without BCD decode.
> On **classic S7-300/400** the C-area word is BCD-encoded (0–999), so the
> surfaced value can differ from the actual count on that hardware. S7-1200/1500
> use IEC/DB counters (plain integers) where the raw word is correct. BCD
> reinterpretation for legacy C-area is a live-hardware-gated follow-up.
The S7comm ReadVar **C-area response encoding remains unverified on hardware**.
PLC instruction representations do not by themselves establish network encoding:
Siemens documents a binary current value for [L: Load counter](https://docs.tia.siemens.cloud/r/en-us/v21/stl-s7-300-s7-400/stl-mnemonic-s7-300-s7-400/counter-operations-s7-300-s7-400/l-load-counter-s7-300-s7-400)
and separate `CV` and `CV_BCD` outputs for [S_CD](https://docs.tia.siemens.cloud/r/en-us/v21/lad-s7-300-s7-400/counter-operations-s7-300-s7-400/simatic-counters-s7-300-s7-400/s_cd-assign-parameters-and-count-down-s7-300-s7-400?contentId=reR_xapFX_Ike2opHIvfzg).
IEC counter values stored in DB memory use the **DB address and declared integer
type**, a different read path from `C{n}`. CPU family alone does not select the
counter kind or prove its wire representation. In particular, Siemens also lists
legacy SIMATIC counters for S7-1500 in its [instruction comparison (pages 23–24)](https://cache.industry.siemens.com/dl/files/777/109986777/att_1322640/v1/s7_1500_compare_table_en.pdf?download=true).
The driver has no counter encoding selector; hardware-faithful counter support
requires independent raw-response evidence before any interpretation change.
### Counter evidence follow-up (#616)
The existing `S7_1500SpanReadTests` exercises the python-snap7 profile's synthetic
raw words (`C0 = 4711`, `C7 = 31337`). These are outside the classic SIMATIC count
range `0..999` documented by S_CD. These expectations describe raw-word handling;
they do not establish classic counter count semantics. The recorded device-0 run cannot prove nonzero
numbering. The nonzero test is environment-gated, but its options remain
`CpuType = S71500`, rack `0`, slot `0`: setting `S7_TIMER_NUMBER_ADDRESSING=1`
does not configure a classic CPU or make the profile a classic counter gate.
Before claiming hardware validation or closing #616:
- Prepare a separate target-specific semantic gate with the actual CPU type,
rack, slot and TSAP/connection settings. Keep the synthetic raw-word profile
as its own evidence of the existing decoder contract.
- Record CPU model/order number, firmware, endpoint, connection settings and
driver/S7netplus plus simulator versions where applicable.
- Observe independent PLC-side current counts and binary/BCD reference outputs
for valid classic counts such as `123`, a second distinguishing count (`42`
or `999`), and zero. Record the instruction/counter kind and DB reference
addresses; do not derive reference values from the driver's output.
- Capture the actual two-byte S7comm C-area responses alongside those references.
Do not infer response bytes from a simulator seed or expected BCD value.
- Verify several distinct nonzero timer/counter numbers against their independent
PLC values and captured requests/responses, and verify the read-only contract.
The raw-byte/count and nonzero-numbering gates remain open. Password/protection
support inherited from #303/#632 is a separate follow-up, not evidence supplied
by these counter checks.
### Deferrals (not yet implemented)
@@ -120,7 +120,9 @@ OPC UA client Acknowledge on a NATIVE condition
under Lock, like `AlarmCommandRouter`).
- **Driver faulted / reconnecting** → `AcknowledgeAsync` fails or no-ops per the driver; the local condition
state still reflects the SDK-applied ack (the OPC UA client sees Good). Surfacing a *failed* upstream ack
back to the client is out of scope (mirrors the Galaxy write-outcome fire-and-forget limitation).
back to the client is out of scope for this native-ack actor route. **Current clarification
(2026-10-13):** the Galaxy adapter awaits a correlated ack RPC, but the actor route does not
return its outcome to OPC UA; Galaxy value writes have a separate correlated write-outcome path.
- **H4 native Enable/Disable** → `BadNotSupported` (decision #2).
- **H2-bit:** no runtime behavior change for existing grants except closing the HistoryRead⇒HistoryUpdate
authorization hole; the service itself stays rejected.
@@ -0,0 +1,9 @@
# Alarm delivery identity results — lmxopcua #705
Alarm events now carry a nonzero delivery GUID created once for direct delivery and preserved across retries and record copies. It is excluded from queue JSON and business-field equality/hash. Queued delivery overrides it with a frozen versioned UUID encoding derived only from the persisted queue row key, preserving existing payload JSON, fingerprints, schema, deduplication and AlarmId. The Gateway mapper sends that GUID and rejects explicitly empty identities. SQL Server and QuestDB identity logic is unchanged.
Coverage includes literal historical JSON/fingerprint/GUID vectors; ten-field equality and hash behavior; direct copies/retries; queue retry/reopen/payload edits/dead-letter revival; identical-input convergence; distinct same-time business events; real replicated queue convergence; and legacy migration. Reconstructed direct objects receive new identities; durable reconstruction uses the queue. Completely identical business payloads remain intentionally deduplicated.
TDD: direct wire regression failed on an empty ID; queued retry/reopen regression failed on changing IDs. Final affected runs: Core51/51, Host migration/convergence16/16, Gateway453passed/6live skipped/0failed. Full offline solution:14,635passed/611skipped/0failed across62suites, exit0. Independent final review and diffcheck clear. Fixture configuration variables were removed before all runs.
No live event writes, deployed sender rollout or historical row rewrite occurred. The live round-trip test now requires exact delivery GUID readback; actual persistence remains the open Gateway #28 gate, alongside its separate #39 timestamp-offset provider failure. Upstream History controls #29 and lmxopcua #694/#700 remain open.
@@ -0,0 +1,9 @@
# Restart loss reason test correction — #702
The full integration reproduction failed at the restart wire test reason assertion: it required BadServerHalted but correctly received BadSessionIdInvalid. Production already supports that definitive loss reason.
The test now accepts an explicit semantic set: four direct definitive session statuses and two sustained closed-channel statuses. All BadNoCommunication, retained-value, unhealthy-state, frozen-read-stamp, one-notification and explicit reinitialization/recovery assertions remain unchanged. Only test expectation/comments changed; production unchanged. Existing deterministic driver tests independently enforce direct-status ordering/count and the sustained-channel threshold.
Verification: isolated restart1/1, deterministic status routes30/30, full OPC UA integration122/122. Full offline solution: 14,613 passed / 611 skipped / 0 failed, exit0, 61 reported suite summaries. Host integration266 passed /14 skipped; unavailable environment/fixture tests remain skips. Independent review found no important defects; git diff --check passed. Actual prior red run121pass/1fail, detailed console/TRX confirms the exact reason mismatch.
Logs/TRX retained in the Codex thread workspace under work/issue702-*. No Gateway or deployment behavior changed by this test correction. Separate live gates remain open.
+53
View File
@@ -0,0 +1,53 @@
# Whole-solution CI
The `v2-ci` workflow restores, builds and tests in one job so the test step uses
that job's Release binaries. Restore disables parallel package work; restore and
build use at most two MSBuild workers. Testing uses one MSBuild worker, disables
parallel target-framework runs and loads `ci.runsettings`: one VSTest process and
two xUnit collection workers with conservative scheduling. Assemblies that disable
parallelization keep that setting.
The workflow retains the E2E/LiveIntegration exclusions, includes SlowUnit, keeps
Akka's test time factor at three, and requires at least 12,000 executed tests.
Runner capacity and TRX files remain available for diagnosing failures. These
limits reduce contention; they do not prove every timing failure has one cause.
There are no automatic test retries or changed assertion deadlines.
Gitea 1.26 supports the repository-wide `lmxopcua-v2-ci` concurrency group. Only
one run is active; an active run is not cancelled, but a newer pending run may
supersede an older pending run. The group does not serialize other repositories
using the same host.
To reproduce the bounded test leg after restore and Release build:
```bash
AKKA_TEST_TIMEFACTOR=3 dotnet test ZB.MOM.WW.OtOpcUa.slnx \
--configuration Release --no-build -m:1 -p:TestTfmsInParallel=false \
--settings scripts/ci/ci.runsettings \
--filter "Category!=E2E&Category!=LiveIntegration" \
--logger trx --results-directory artifacts/trx
MIN_EXECUTED=12000 bash scripts/ci/assert-not-all-skipped.sh artifacts/trx/*.trx
```
The guard uses the existing TRX `Counters/@executed` value for the aggregate
floor. Its `skipped` diagnostic counts individual `Results/UnitTestResult`
records whose outcome is `NotExecuted`, using the document's XML namespace.
Some VSTest reports set summary `notExecuted` to zero even when these records
are present. Skip records are counted per file, including repeated runs; they
are not a count of unique test methods and do not affect the executed floor.
For malformed XML, the diagnostic retains the old summary-counter fallback;
this guard does not add XML validation to its existing gate policy.
Run the deterministic script fixtures without building or running the solution:
```bash
python3 scripts/ci/test-assert-not-all-skipped.py
```
Verified hosted baseline: [Gitea run 975](https://gitea.dohertylan.com/dohertj2/lmxopcua/actions/runs/975)
completed successfully on 2026-10-13 for commit
`76bdca90e94e814e06ef10b78271e2b492b2ba32` (merged by PR #711).
Restore, Release build, README drift check, the serialized whole-solution test
step, the 12,000-test floor and TRX upload all passed. The floor counted
14,630 executed tests across 62 TRX files. This baseline predates the individual
skip-reporting correction; its printed zero skip diagnostic is not skip evidence.
+20 -1
View File
@@ -33,12 +33,31 @@ for f in "$@"; do
exit 2
fi
seen=$((seen + 1))
# The trx <Counters .../> element in <ResultSummary> carries executed + notExecuted counts.
# Keep the executed-counter floor unchanged; notExecuted is only a fallback
# for malformed XML that this guard historically accepted.
counters="$(grep -oE '<Counters[^>]*/?>' "$f" | head -1)"
ex="$(printf '%s' "$counters" | grep -oE 'executed="[0-9]+"' | grep -oE '[0-9]+' || true)"
ne="$(printf '%s' "$counters" | grep -oE 'notExecuted="[0-9]+"' | grep -oE '[0-9]+' || true)"
ex="${ex:-0}"
ne="${ne:-0}"
# VSTest can emit notExecuted="0" despite individual NotExecuted results.
# Count direct Results/UnitTestResult records in the document's namespace,
# excluding foreign elements, nested records and escaped diagnostic text.
ne="$(python3 - "$f" "$ne" <<'PY'
import sys
import xml.etree.ElementTree as ET
try:
root = ET.parse(sys.argv[1]).getroot()
except ET.ParseError:
# Skip reporting must not introduce a new malformed-XML gate failure.
print(sys.argv[2])
else:
namespace = root.tag.split("}", 1)[0] + "}" if root.tag.startswith("{") else ""
results = root.findall(f"{namespace}Results/{namespace}UnitTestResult")
print(sum(result.get("outcome") == "NotExecuted" for result in results))
PY
)"
total_executed=$((total_executed + ex))
total_skipped=$((total_skipped + ne))
echo " $(basename "$f"): executed=${ex} skipped=${ne}"
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<RunSettings>
<!-- #707: project scheduling is also bounded by dotnet test -m:1. -->
<RunConfiguration>
<MaxCpuCount>1</MaxCpuCount>
</RunConfiguration>
<xUnit>
<!-- Preserve each assembly's parallelization opt-out; bound enabled collections. -->
<MaxParallelThreads>2</MaxParallelThreads>
<ParallelAlgorithm>conservative</ParallelAlgorithm>
</xUnit>
</RunSettings>
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""Deterministic subprocess checks for the TRX executed-floor guard."""
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
SCRIPT = Path(__file__).with_name("assert-not-all-skipped.sh")
NS = "http://microsoft.com/schemas/VisualStudio/TeamTest/2010"
def trx(executed, outcomes, summary_skipped=0, namespace=NS, extra=""):
declaration = f' xmlns="{namespace}"' if namespace else ""
results = "".join(f'<UnitTestResult outcome="{outcome}"/>' for outcome in outcomes)
return (f'<TestRun{declaration}><Results>{results}{extra}</Results>'
f'<ResultSummary><Counters total="{len(outcomes)}" executed="{executed}" '
f'notExecuted="{summary_skipped}"/></ResultSummary></TestRun>')
class SkipGuardTests(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory(prefix="trx-skip-fixtures-")
self.addCleanup(self.directory.cleanup)
self.root = Path(self.directory.name)
def write(self, name, content):
path = self.root / name
path.write_text(content, encoding="utf-8")
return path
def run_guard(self, *paths, minimum=None):
env = os.environ.copy()
env.pop("MIN_EXECUTED", None)
if minimum is not None:
env["MIN_EXECUTED"] = str(minimum)
return subprocess.run(["bash", str(SCRIPT), *map(str, paths)], env=env,
text=True, capture_output=True, check=False)
def assert_report(self, result, status, executed, skipped, files=1):
self.assertEqual(status, result.returncode, result.stdout + result.stderr)
self.assertIn(f"files={files} executed={executed} skipped={skipped} ", result.stdout)
def test_mixed_results_ignore_zero_summary_skip_counter(self):
# A summary-only skip count misses the three actual NotExecuted records.
path = self.write("mixed.trx", trx(827, ["Passed"] * 827 + ["NotExecuted"] * 3))
result = self.run_guard(path)
self.assert_report(result, 0, 827, 3)
self.assertIn("mixed.trx: executed=827 skipped=3", result.stdout)
def test_all_skipped_fails_default_floor_but_report_only_passes(self):
path = self.write("all-skipped.trx", trx(0, ["NotExecuted"] * 188))
self.assert_report(self.run_guard(path), 1, 0, 188)
self.assert_report(self.run_guard(path, minimum=0), 0, 0, 188)
def test_zero_tests_fails_default_floor_but_report_only_passes(self):
path = self.write("zero.trx", trx(0, []))
self.assert_report(self.run_guard(path), 1, 0, 0)
self.assert_report(self.run_guard(path, minimum=0), 0, 0, 0)
def test_agreeing_summary_and_outcomes_are_not_double_counted(self):
path = self.write("agreeing.trx", trx(1, ["Passed", "NotExecuted"], 1))
self.assert_report(self.run_guard(path), 0, 1, 1)
def test_outcomes_are_authoritative_when_summary_overstates_skips(self):
path = self.write("overstated.trx", trx(1, ["Passed", "NotExecuted"], 9))
self.assert_report(self.run_guard(path), 0, 1, 1)
def test_namespace_path_and_exact_outcome_exclude_decoys(self):
extra = ('<Other><UnitTestResult outcome="NotExecuted"/></Other>'
'<foreign:UnitTestResult xmlns:foreign="urn:other" outcome="NotExecuted"/>'
'<Output><Message>&lt;UnitTestResult outcome="NotExecuted"/&gt;</Message></Output>')
content = trx(2, ["Passed", "Failed", "NotExecuted", "notexecuted"], extra=extra)
# Prefixes on Results/UnitTestResult must work just like the default namespace.
content = content.replace('<TestRun ', f'<TestRun xmlns:t="{NS}" ')
content = content.replace('<Results>', '<t:Results>').replace('</Results>', '</t:Results>')
content = content.replace('<UnitTestResult ', '<t:UnitTestResult ')
path = self.write("namespaced.trx", content)
self.assert_report(self.run_guard(path), 0, 2, 1)
def test_unnamespaced_results_remain_supported(self):
path = self.write("legacy.trx", trx(1, ["Passed", "NotExecuted"], namespace=""))
self.assert_report(self.run_guard(path), 0, 1, 1)
def test_executed_counter_controls_aggregate_floor_at_boundary(self):
# Counters retain authority even when result records disagree with executed.
mixed = self.write("floor.trx", trx(12000, ["Passed", "NotExecuted"]))
skipped = self.write("skipped.trx", trx(0, ["NotExecuted"]))
self.assert_report(self.run_guard(mixed, skipped, minimum=12000), 0, 12000, 2, 2)
self.assert_report(self.run_guard(mixed, skipped, minimum=12001), 1, 12000, 2, 2)
def test_missing_arguments_and_file_keep_exit_two(self):
self.assertEqual(2, self.run_guard().returncode)
self.assertEqual(2, self.run_guard(self.root / "missing.trx").returncode)
def test_missing_counters_keeps_existing_exit_one(self):
path = self.write("no-counters.trx", '<TestRun><Results/></TestRun>')
self.assertEqual(1, self.run_guard(path, minimum=0).returncode)
def test_malformed_xml_keeps_summary_fallback_and_gate_status(self):
path = self.write("malformed.trx", '<TestRun><Counters executed="2" notExecuted="3"/>')
self.assert_report(self.run_guard(path, minimum=2), 0, 2, 3)
self.assert_report(self.run_guard(path, minimum=3), 1, 2, 3)
if __name__ == "__main__":
unittest.main(verbosity=2)
@@ -0,0 +1,25 @@
using System.Security.Cryptography;
using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
/// <summary>Stable delivery GUIDs for persisted alarm queue rows.</summary>
public static class AlarmDeliveryIds
{
/// <summary>
/// Converts an existing durable row key into a nonzero version-8 GUID. Version 1 of this encoding hashes
/// UTF-8 "OtOpcUa.AlarmDelivery/v1\0" followed by the exact persisted key with SHA-256, takes the first
/// 16 bytes, sets the UUID version/variant bits, and interprets the bytes in network order.
/// This encoding must remain stable across retries, upgrades and replicated failover.
/// </summary>
/// <param name="queueId">The persisted row key, never a hash recomputed from the current payload.</param>
/// <returns>The row's stable delivery identity.</returns>
public static Guid FromQueueId(string queueId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(queueId);
var digest = SHA256.HashData(Encoding.UTF8.GetBytes("OtOpcUa.AlarmDelivery/v1\0" + queueId));
digest[6] = (byte)((digest[6] & 0x0F) | 0x80);
digest[8] = (byte)((digest[8] & 0x3F) | 0x80);
return new Guid(digest.AsSpan(0, 16), bigEndian: true);
}
}
@@ -1,3 +1,4 @@
using System.Text.Json.Serialization;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
@@ -34,4 +35,31 @@ public sealed record AlarmHistorianEvent(
string Message,
string User,
string? Comment,
DateTime TimestampUtc);
DateTime TimestampUtc)
{
/// <summary>
/// Delivery identity, minted once for direct sends and replaced by the durable queue's identity when drained.
/// Excluded from payload serialization and business equality so redundant enqueues still converge.
/// A record copy preserves this identity; an explicitly empty identity is invalid for delivery.
/// </summary>
[JsonIgnore]
public Guid DeliveryId { get; init; } = Guid.NewGuid();
/// <summary>Compares the ten business fields, excluding transport delivery identity.</summary>
/// <param name="other">The event to compare.</param>
/// <returns>Whether the business fields are equal.</returns>
public bool Equals(AlarmHistorianEvent? other) => ReferenceEquals(this, other) || other is not null
&& AlarmId == other.AlarmId && EquipmentPath == other.EquipmentPath && AlarmName == other.AlarmName
&& AlarmTypeName == other.AlarmTypeName && Severity == other.Severity && EventKind == other.EventKind
&& Message == other.Message && User == other.User && Comment == other.Comment && TimestampUtc == other.TimestampUtc;
/// <summary>Hashes the same business fields used by equality, excluding delivery identity.</summary>
/// <returns>The business-field hash code.</returns>
public override int GetHashCode()
{
var hash = new HashCode();
hash.Add(AlarmId); hash.Add(EquipmentPath); hash.Add(AlarmName); hash.Add(AlarmTypeName); hash.Add(Severity);
hash.Add(EventKind); hash.Add(Message); hash.Add(User); hash.Add(Comment); hash.Add(TimestampUtc);
return hash.ToHashCode();
}
}
@@ -603,6 +603,9 @@ public sealed class LocalDbStoreAndForwardSink : IAlarmHistorianSink, IDisposabl
try
{
evt = JsonSerializer.Deserialize<AlarmHistorianEvent>(payload);
// The durable key survives retry, payload edits, legacy migration and replicated failover.
// Never derive delivery identity from a reserialized payload or an individual RPC attempt.
if (evt is not null) evt = evt with { DeliveryId = AlarmDeliveryIds.FromQueueId(id) };
}
catch (JsonException)
{
@@ -35,16 +35,16 @@ internal static class AlarmEventMapper
/// <returns>The gateway wire event ready for <c>SendEvent</c>.</returns>
public static HistorianEvent ToHistorianEvent(AlarmHistorianEvent alarm)
{
if (alarm.DeliveryId == Guid.Empty)
throw new ArgumentException("Alarm delivery identity must be nonzero.", nameof(alarm));
// Timestamp.FromDateTime requires a Utc-kind DateTime; coerce defensively (TimestampUtc is
// already Utc by contract, but a caller could pass Unspecified).
var eventTime = Timestamp.FromDateTime(DateTime.SpecifyKind(alarm.TimestampUtc, DateTimeKind.Utc));
var historianEvent = new HistorianEvent
{
// Deliberately DO NOT set HistorianEvent.Id: the gateway's SendEvent path rejects a
// client-supplied event id (the server handler throws and the call fails permanently —
// confirmed live). The historian assigns event identity server-side; the alarm's own id
// is preserved below as a property for read-back correlation/traceability.
// Identity belongs to the event/durable queue, never to an individual RPC attempt.
Id = alarm.DeliveryId.ToString("D"),
SourceName = alarm.EquipmentPath,
Type = alarm.AlarmTypeName,
EventTime = eventTime,
@@ -1465,7 +1465,7 @@ public sealed class S7Driver
{
// AREA precedence first (before the DataType switch): a Timer (T{n}) decodes its 2-byte
// S5TIME word to a duration in SECONDS via S7.Net's Timer.FromByteArray (returns double);
// a Counter (C{n}) decodes its 2-byte word to a COUNT via Counter.FromByteArray (returns
// a Counter (C{n}) exposes its 2-byte RAW WORD via Counter.FromByteArray (returns
// ushort, surfaced as a non-negative int to match the tag's Int32 type). Routing on Area
// here is essential: a Timer tag is typed Float64, so letting it fall to the Float64 arm
// below would ReadDoubleBigEndian over a 2-byte block = garbage/throw. Boxed explicitly so
@@ -1474,11 +1474,12 @@ public sealed class S7Driver
return (object)global::S7.Net.Types.Timer.FromByteArray(block);
if (addr.Area is S7Area.Counter)
{
// NOTE: S7.Net Counter.FromByteArray returns the RAW big-endian word ((bytes[0]<<8)|bytes[1]),
// NOT a BCD decode. On classic S7-300/400 the C-area word is BCD (0-999), so on that hardware
// this raw value can differ from the displayed count; S7-1200/1500 use IEC/DB counters (plain
// ints), where it is correct. Surfacing S7.Net's value verbatim is the faithful choice; BCD
// reinterpretation for legacy C-area counters is a live-hardware-gated follow-up.
// S7.Net Counter.FromByteArray returns the raw big-endian word ((bytes[0]<<8)|bytes[1]),
// without BCD reinterpretation. This preserves the raw value, not a verified PLC count:
// S7comm C-area response encoding still needs independent raw bytes and PLC-side
// count/binary/BCD references (#616). CPU family alone does not prove that encoding.
// IEC counter integers in DB memory take the DB type codec, not this C-area path;
// S7-1500 also has legacy SIMATIC counters. See docs/drivers/S7.md for the evidence gate.
return (object)(int)global::S7.Net.Types.Counter.FromByteArray(block);
}
@@ -0,0 +1,78 @@
using System.Text.Json;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests;
public sealed class AlarmHistorianEventIdentityTests
{
internal const string Payload = """{"AlarmId":"A","EquipmentPath":"S","AlarmName":"N","AlarmTypeName":"LimitAlarm","Severity":2,"EventKind":"Activated","Message":"m","User":"system","Comment":null,"TimestampUtc":"2026-01-01T00:00:00Z"}""";
internal const string QueueId = "52C8292271C629CCC2BFE4EC8C16A5E70CA7456C7D88BC282ABCEDF636F05901";
private static AlarmHistorianEvent Event() => new("A", "S", "N", "LimitAlarm", AlarmSeverity.High,
"Activated", "m", "system", null, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
[Theory]
[InlineData(QueueId, "48bca092-b99d-8ea4-a2de-8b30d80a481a")]
[InlineData("persisted-row-v1", "39473040-002b-876c-a1e6-9a9ad4a60ded")]
public void Persisted_keys_have_frozen_versioned_delivery_GUID_vectors(string key, string expected)
{
Assert.Equal(Guid.Parse(expected), AlarmDeliveryIds.FromQueueId(key));
}
[Fact]
public void Delivery_identity_is_nonzero_and_a_copy_preserves_it()
{
var original = Event();
Assert.NotEqual(Guid.Empty, original.DeliveryId);
Assert.Equal(original.DeliveryId, (original with { }).DeliveryId);
Assert.Equal(original.DeliveryId, (original with { Comment = "copy" }).DeliveryId);
}
[Fact]
public void Business_equality_hash_and_historical_JSON_ignore_delivery_identity()
{
var first = Event();
var second = Event();
Assert.NotEqual(first.DeliveryId, second.DeliveryId);
Assert.Equal(first, second);
Assert.True(first == second);
Assert.Equal(first.GetHashCode(), second.GetHashCode());
Assert.Single(new HashSet<AlarmHistorianEvent> { first, second });
Assert.Equal(Payload, JsonSerializer.Serialize(first));
Assert.Equal(Payload, JsonSerializer.Serialize(second));
Assert.Equal(QueueId, AlarmSfSchema.DeriveId(Payload));
Assert.Equal(first, JsonSerializer.Deserialize<AlarmHistorianEvent>(Payload));
}
[Theory]
[InlineData("AlarmId")]
[InlineData("EquipmentPath")]
[InlineData("AlarmName")]
[InlineData("AlarmTypeName")]
[InlineData("Severity")]
[InlineData("EventKind")]
[InlineData("Message")]
[InlineData("User")]
[InlineData("Comment")]
[InlineData("TimestampUtc")]
public void Each_business_field_still_participates_in_equality(string field)
{
var first = Event();
var changed = field switch
{
"AlarmId" => first with { AlarmId = "B" },
"EquipmentPath" => first with { EquipmentPath = "T" },
"AlarmName" => first with { AlarmName = "Other" },
"AlarmTypeName" => first with { AlarmTypeName = "DiscreteAlarm" },
"Severity" => first with { Severity = AlarmSeverity.Low },
"EventKind" => first with { EventKind = "Cleared" },
"Message" => first with { Message = "other" },
"User" => first with { User = "operator" },
"Comment" => first with { Comment = "note" },
"TimestampUtc" => first with { TimestampUtc = first.TimestampUtc.AddTicks(1) },
_ => throw new ArgumentOutOfRangeException(nameof(field)),
};
Assert.NotEqual(first, changed);
Assert.False(first == changed);
}
}
@@ -123,6 +123,69 @@ public sealed class LocalDbStoreAndForwardSinkTests : IDisposable
Comment: null,
TimestampUtc: ts ?? DateTime.UtcNow);
[Fact]
public async Task Queued_delivery_identity_survives_retry_reopen_and_payload_changes()
{
// The persisted row key is authoritative: reserializing today's payload must never change a retry's identity.
var expected = Guid.Parse("48bca092-b99d-8ea4-a2de-8b30d80a481a");
var evt = System.Text.Json.JsonSerializer.Deserialize<AlarmHistorianEvent>(AlarmHistorianEventIdentityTests.Payload)!;
var writer = new FakeWriter { DefaultOutcome = HistorianWriteOutcome.RetryPlease };
using (var sink = new LocalDbStoreAndForwardSink(Db, writer, _log))
{
await sink.EnqueueAsync(evt, TestContext.Current.CancellationToken);
var row = (await Db.QueryAsync("SELECT id, payload_json FROM alarm_sf_events",
r => (Id: r.GetString(0), Payload: r.GetString(1)), null, TestContext.Current.CancellationToken)).Single();
row.Id.ShouldBe(AlarmHistorianEventIdentityTests.QueueId);
row.Payload.ShouldBe(AlarmHistorianEventIdentityTests.Payload);
await sink.DrainOnceAsync(TestContext.Current.CancellationToken);
await sink.DrainOnceAsync(TestContext.Current.CancellationToken);
writer.Batches.ShouldAllBe(batch => batch.Single().DeliveryId == expected);
await Db.ExecuteAsync("UPDATE alarm_sf_events SET payload_json = @Payload",
new { Payload = System.Text.Json.JsonSerializer.Serialize(evt with { Comment = "edited" }) },
TestContext.Current.CancellationToken);
}
_provider!.Dispose();
_provider = null;
_db = null;
var reopenedWriter = new FakeWriter();
using var reopened = new LocalDbStoreAndForwardSink(Db, reopenedWriter, _log);
await reopened.DrainOnceAsync(TestContext.Current.CancellationToken);
reopenedWriter.Batches.Single().Single().DeliveryId.ShouldBe(expected);
reopenedWriter.Batches.Single().Single().Comment.ShouldBe("edited");
reopened.GetStatus().QueueDepth.ShouldBe(0);
}
[Fact]
public async Task Independently_created_identical_events_keep_the_historical_queue_key()
{
var first = System.Text.Json.JsonSerializer.Deserialize<AlarmHistorianEvent>(AlarmHistorianEventIdentityTests.Payload)!;
var second = System.Text.Json.JsonSerializer.Deserialize<AlarmHistorianEvent>(AlarmHistorianEventIdentityTests.Payload)!;
first.DeliveryId.ShouldNotBe(second.DeliveryId);
using var sink = new LocalDbStoreAndForwardSink(Db, new FakeWriter(), _log);
await sink.EnqueueAsync(first, TestContext.Current.CancellationToken);
await sink.EnqueueAsync(second, TestContext.Current.CancellationToken);
sink.GetStatus().QueueDepth.ShouldBe(1);
(await Db.QueryAsync("SELECT id FROM alarm_sf_events", r => r.GetString(0), null, TestContext.Current.CancellationToken))
.ShouldBe([AlarmHistorianEventIdentityTests.QueueId]);
}
[Fact]
public async Task Distinct_business_events_at_the_same_time_have_distinct_queued_delivery_ids()
{
var first = Event("A", new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
var second = first with { EventKind = "Cleared", Comment = "different transition" };
var writer = new FakeWriter();
using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log);
await sink.EnqueueAsync(first, TestContext.Current.CancellationToken);
await sink.EnqueueAsync(second, TestContext.Current.CancellationToken);
sink.GetStatus().QueueDepth.ShouldBe(2);
await sink.DrainOnceAsync(TestContext.Current.CancellationToken);
var delivered = writer.Batches.Single();
delivered.Count.ShouldBe(2);
delivered.ShouldAllBe(evt => evt.DeliveryId != Guid.Empty);
delivered.Select(evt => evt.DeliveryId).Distinct().Count().ShouldBe(2);
}
/// <summary>Verifies that acknowledged events are removed from the queue.</summary>
[Fact]
public async Task EnqueueThenDrain_Ack_removes_row()
@@ -297,6 +360,11 @@ public sealed class LocalDbStoreAndForwardSinkTests : IDisposable
var status = sink.GetStatus();
status.QueueDepth.ShouldBe(1);
status.DeadLetterDepth.ShouldBe(0);
var originalDeliveryId = writer.Batches.Single().Single().DeliveryId;
await sink.DrainOnceAsync(TestContext.Current.CancellationToken);
writer.Batches.Count.ShouldBe(2);
writer.Batches.Last().Single().DeliveryId.ShouldBe(originalDeliveryId);
sink.GetStatus().QueueDepth.ShouldBe(0);
}
/// <summary>Verifies that the exponential backoff ladder caps at 60 seconds.</summary>
@@ -18,6 +18,33 @@ public sealed class GatewayAlarmHistorianWriterTests
private static GatewayAlarmHistorianWriter Writer(FakeHistorianGatewayClient fake) =>
new(fake, NullLogger<GatewayAlarmHistorianWriter>.Instance);
[Fact]
public async Task A_direct_retry_preserves_the_original_delivery_id()
{
var evt = Evt("A");
var fake = new FakeHistorianGatewayClient { SendEventThrows = new RpcException(new Status(StatusCode.Unavailable, "down")) };
using var writer = Writer(fake);
Assert.Equal(HistorianWriteOutcome.RetryPlease,
(await writer.WriteBatchAsync([evt], TestContext.Current.CancellationToken)).Single());
var first = fake.LastSendEvent!.Id;
fake.SendEventThrows = null;
fake.SendEventResult = new WriteAck { Success = true };
Assert.Equal(HistorianWriteOutcome.Ack,
(await writer.WriteBatchAsync([evt with { }], TestContext.Current.CancellationToken)).Single());
Assert.Equal(evt.DeliveryId.ToString("D"), first);
Assert.Equal(first, fake.LastSendEvent!.Id);
}
[Fact]
public async Task An_empty_delivery_id_is_permanent_failure_without_an_RPC()
{
var fake = new FakeHistorianGatewayClient();
using var writer = Writer(fake);
Assert.Equal(HistorianWriteOutcome.PermanentFail,
(await writer.WriteBatchAsync([Evt("A") with { DeliveryId = Guid.Empty }], TestContext.Current.CancellationToken)).Single());
Assert.Equal(0, fake.SendEventCallCount);
}
[Fact]
public async Task All_acked_when_SendEvent_succeeds()
{
@@ -197,8 +197,6 @@ public sealed class GatewayCeilingPagingTests : IDisposable
[InlineData(L - 1, 5000u)]
[InlineData(L, 5000u)]
[InlineData(L + 1, 5000u)]
[InlineData(Ceiling - 17, 5000u)]
[InlineData(Ceiling - 16, 5000u)]
[InlineData(Ceiling + 1, 5000u)]
[InlineData(L - 2, 0u)]
[InlineData(L - 1, 0u)]
@@ -149,9 +149,8 @@ public sealed class GatewayLiveIntegrationTests(GatewayLiveFixture fixture) : IC
/// <see cref="GatewayAlarmHistorianWriter"/>, then <c>ReadEvents</c> the recent window for that
/// source through the real <see cref="GatewayHistorianDataSource"/> and assert the event is
/// present. Requires the gateway running <c>RuntimeDb:EventReadsEnabled=true</c> (the SQL
/// alarm-history read path). Presence is asserted as "at least one event for the source surfaced
/// in the post-send window" (the data source filters by source); the exact AlarmId / message
/// match is surfaced as a diagnostic, since the SQL event store may re-key the row.
/// alarm-history read path). The exact delivery GUID must return; an unrelated event for the
/// same source cannot satisfy the assertion.
/// </summary>
[Fact]
[Trait("Category", "LiveIntegration")]
@@ -196,7 +195,7 @@ public sealed class GatewayLiveIntegrationTests(GatewayLiveFixture fixture) : IC
var read = await dataSource.ReadEventsAsync(
source, eventUtc - TimeSpan.FromMinutes(5), DateTime.UtcNow + TimeSpan.FromMinutes(1), maxEvents: 0, ct);
events = read.Events;
if (events.Count > 0) break;
if (events.Any(e => Guid.TryParse(e.EventId, out var id) && id == alarm.DeliveryId)) break;
await Task.Delay(TimeSpan.FromSeconds(2), ct);
}
while (DateTime.UtcNow < deadline);
@@ -211,18 +210,18 @@ public sealed class GatewayLiveIntegrationTests(GatewayLiveFixture fixture) : IC
$"a source-filtered ReadEvents must return the just-sent alarm for source '{source}': the gateway now " +
"populates Source_Object from the event SourceName (C4 fix), so OtOpcUa's own alarm sends round-trip.");
var exactMatch = events.Any(e => string.Equals(e.EventId, alarmId, StringComparison.Ordinal));
var exactMatch = events.Any(e => Guid.TryParse(e.EventId, out var id) && id == alarm.DeliveryId);
exactMatch.ShouldBeTrue("the just-sent event must retain its nonzero delivery GUID through persistence and readback.");
TestContext.Current.SendDiagnosticMessage(
$"alarm round-trip: SendEvent source='{source}' id={alarmId} → ReadEvents returned {events.Count} event(s); exact-id match={exactMatch}.");
$"alarm round-trip: SendEvent source='{source}' delivery-id={alarm.DeliveryId:D} → exact ID returned among {events.Count} event(s).");
}
/// <summary>
/// Alarm send contract — the OtOpcUa responsibility in isolation: an <see cref="AlarmHistorianEvent"/>
/// maps to a wire event the gateway accepts and <c>SendEvent</c> returns <see cref="HistorianWriteOutcome.Ack"/>.
/// This is the half that must always hold (the readback half is historian-gated; see
/// <see cref="Alarm_SendEvent_then_ReadEvents"/>). Regression guard for the event-id mapping —
/// a client-supplied wire <c>Id</c> makes the gateway's SendEvent handler throw, so the mapper
/// must leave it unset.
/// <see cref="Alarm_SendEvent_then_ReadEvents"/>). The mapper sends the stable nonzero delivery GUID;
/// the earlier assumption that the gateway rejected every client-supplied ID was incorrect (#705).
/// </summary>
[Fact]
[Trait("Category", "LiveIntegration")]
@@ -251,7 +250,7 @@ public sealed class GatewayLiveIntegrationTests(GatewayLiveFixture fixture) : IC
outcomes.ShouldHaveSingleItem().ShouldBe(
HistorianWriteOutcome.Ack,
"the alarm SendEvent must be acked (the AlarmEventMapper must NOT set the wire event Id — the gateway rejects a client-supplied id).");
"the alarm SendEvent carrying its nonzero delivery GUID must be acked.");
}
/// <summary>
@@ -7,6 +7,17 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.Mapping;
public sealed class AlarmEventMapperTests
{
[Fact]
public void A_direct_event_has_a_nonzero_wire_id_that_survives_repeated_mapping()
{
var alarm = new AlarmHistorianEvent("A", "S", "N", "LimitAlarm", AlarmSeverity.High,
"Activated", "m", "system", null, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
var first = AlarmEventMapper.ToHistorianEvent(alarm).Id;
Assert.True(Guid.TryParse(first, out var id), $"Expected a delivery GUID; received '{first}'.");
Assert.NotEqual(Guid.Empty, id);
Assert.Equal(first, AlarmEventMapper.ToHistorianEvent(alarm).Id);
}
[Fact]
public void Maps_source_time_type_and_rich_properties()
{
@@ -17,9 +28,7 @@ public sealed class AlarmEventMapperTests
Assert.Equal("Area/Line/Pump1", e.SourceName);
Assert.Equal("LimitAlarm", e.Type);
Assert.Equal(new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), e.EventTime.ToDateTime());
// The wire Id is deliberately left unset — the gateway's SendEvent rejects a client-supplied
// event id. The alarm's own id is carried as a property instead.
Assert.Equal(string.Empty, e.Id);
Assert.Equal(a.DeliveryId.ToString("D"), e.Id);
Assert.Equal("A1", e.Properties["AlarmId"]);
Assert.Equal("HiHi", e.Properties["AlarmName"]);
Assert.Equal("Activated", e.Properties["EventKind"]);
@@ -67,14 +76,21 @@ public sealed class AlarmEventMapperTests
}
[Fact]
public void Wire_id_is_never_set_and_blank_alarm_id_adds_no_property()
public void A_blank_condition_id_still_has_delivery_identity_and_adds_no_condition_property()
{
// A blank AlarmId must NOT fall back to a generated wire Id (the gateway rejects any id) and
// must not add an empty AlarmId property.
// Condition identity and event delivery identity serve different purposes.
var a = new AlarmHistorianEvent("", "S", "N", "DiscreteAlarm", AlarmSeverity.Low, "Cleared", "m", "system", null,
new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
var e = AlarmEventMapper.ToHistorianEvent(a);
Assert.Equal(string.Empty, e.Id);
Assert.Equal(a.DeliveryId.ToString("D"), e.Id);
Assert.False(e.Properties.ContainsKey("AlarmId"));
}
[Fact]
public void An_explicitly_empty_delivery_id_is_rejected_instead_of_reminted()
{
var alarm = new AlarmHistorianEvent("A", "S", "N", "LimitAlarm", AlarmSeverity.High,
"Activated", "m", "system", null, DateTime.UtcNow) { DeliveryId = Guid.Empty };
Assert.Throws<ArgumentException>(() => AlarmEventMapper.ToHistorianEvent(alarm));
}
}
@@ -27,7 +27,15 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
/// areas: <b>PASSED</b> — 4 passed, 0 failed, 1 skipped (the device number-addressing test,
/// see below). The Timer/Counter batch was served by 4 requests for 7 tags: one DB1 span,
/// one 3-item multi-var carrying only the scalars, and one 2-byte area read apiece for
/// <c>T0</c> (decoded 456.0 s from S5TIME <c>0x2456</c>) and <c>C0</c> (decoded count 4711).
/// <c>T0</c> (decoded 456.0 s from S5TIME <c>0x2456</c>) and <c>C0</c> (surfaced raw word 4711).
/// </para>
/// <para>
/// <b>Counter evidence limit.</b> The profile's C0=4711 and C7=31337 are synthetic raw
/// words, outside classic SIMATIC counts 0..999. The device-0 record proves the fixture's
/// raw-word decode and batching exclusion, not classic count semantics or nonzero
/// addressing. BuildOptions retains S71500/rack 0/slot 0; a target-specific semantic gate
/// still needs independent PLC counts and binary/BCD references paired with captured
/// C-area bytes, CPU model/firmware and connection settings (docs/drivers/S7.md, #616).
/// </para>
/// <para>
/// <b>What the layout buys.</b> The ten tags are positioned so the planner's rules produce a
@@ -73,11 +81,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
/// returns TM bytes 0..1, and it takes <c>start 80</c> (= 10 × 8) to reach the timer seeded
/// at TM byte 10. Device <b>0</b> is the single fixed point of the two conventions
/// (<c>0 &gt;&gt; 3 == 2 × 0 == 0</c>), so it is the only device this fixture can pin a
/// VALUE for, and that is what the mixed batch uses. The spec-correct <c>2 × N</c> cells
/// VALUE for, and that is what the mixed batch uses. The profile's <c>2 × N</c> cells
/// are seeded too and
/// <see cref="Timer_and_counter_decode_the_seeded_values_at_their_device_numbers"/> asserts
/// them — skipped unless <c>S7_TIMER_NUMBER_ADDRESSING=1</c> declares the endpoint a real
/// CPU. The device-number offset arithmetic itself stays unit-covered
/// CPU. This flag is an operator declaration, not hardware evidence.
/// The device-number offset arithmetic itself stays unit-covered
/// (<c>S7ScalarBlockTests</c>, <c>S7SpanReadParityTests</c>).
/// </para>
/// <para>
@@ -240,7 +249,7 @@ public sealed class S7_1500SpanReadTests(Snap7ServerFixture sim)
snapshot.StatusCode.ShouldBe(0u, $"'{name}' must be Good");
// (a) Correct decode off real wire. A Timer surfaces as the S5TIME word's duration in
// seconds (double) and a Counter as the raw count (int) — two different areas, two
// seconds (double) and a Counter as the raw word (int) — two different areas, two
// different values, two different CLR types, so a read that reached the wrong area or
// decoded through the wrong arm cannot pass.
byName["TC_Timer"].Value.ShouldBe(TimerCounterLayout.Device0TimerSeconds);
@@ -280,15 +289,19 @@ public sealed class S7_1500SpanReadTests(Snap7ServerFixture sim)
/// <summary>
/// Verifies a Timer and a Counter at NON-zero device numbers decode their seeded values —
/// the assertion the shared fixture cannot serve.
/// the raw-word assertion the shared fixture cannot serve.
/// </summary>
/// <remarks>
/// Skipped unless <c>S7_TIMER_NUMBER_ADDRESSING=1</c> declares the endpoint a device
/// number-addressing CPU, because python-snap7's server is not one: it resolves the TM/CT
/// request address as a bit address, so <c>T5</c> reads TM byte 0 rather than TM byte 10
/// (measured 2026-08-17 — see the type remarks). Set the variable alongside
/// <c>S7_SIM_ENDPOINT</c> when pointing this suite at a real S7 CPU seeded to match
/// <c>Docker/profiles/s7_1500.json</c>'s TM/CT areas.
/// (measured 2026-08-17 — see the type remarks). The flag only enables this test;
/// BuildOptions still selects S71500, rack 0, slot 0 and the profile's raw C7 word 31337.
/// It does not configure a classic CPU or prove classic 0..999 count semantics. A separate
/// target-specific gate needs valid counts, independent PLC binary/BCD references and
/// captured C-area bytes, plus CPU model/firmware and connection settings (#616).
/// See docs/drivers/S7.md for the evidence checklist. The skip message below retains the
/// historical fixture instructions; enabling the flag alone does not satisfy this gate.
/// </remarks>
[Fact]
public async Task Timer_and_counter_decode_the_seeded_values_at_their_device_numbers()
@@ -408,12 +421,13 @@ public sealed class S7_1500SpanReadTests(Snap7ServerFixture sim)
/// <summary>
/// The Timer/Counter layouts. Two sets: a mixed batch that provokes all three read shapes at
/// once (block span, multi-var, per-tag Timer/Counter), and a device-numbered pair only a
/// real CPU can serve. Addresses map 1:1 to the TM0 / CT0 seeds in
/// device number-addressing endpoint can serve. These synthetic raw-word expectations
/// do not prove classic counter semantics. Addresses map 1:1 to the TM0 / CT0 seeds in
/// <c>Docker/profiles/s7_1500.json</c>.
/// </summary>
private static class TimerCounterLayout
{
/// <summary>Declares the endpoint a real S7 CPU, which addresses TM/CT by device number.</summary>
/// <summary>Operator opt-in for nonzero TM/CT addressing; does not change CPU options or prove encoding.</summary>
public const string RealCpuEnvVar = "S7_TIMER_NUMBER_ADDRESSING";
/// <summary>
@@ -425,7 +439,7 @@ public sealed class S7_1500SpanReadTests(Snap7ServerFixture sim)
/// <summary>T0's seed 0x2456 — S5TIME base 2 (×1 s) over BCD 456.</summary>
public const double Device0TimerSeconds = 456.0d;
/// <summary>C0's seed — the raw big-endian count word.</summary>
/// <summary>C0's synthetic raw big-endian word 4711; not a classic 0..999 count reference.</summary>
public const int Device0CounterCount = 4711;
/// <summary>
@@ -441,7 +455,7 @@ public sealed class S7_1500SpanReadTests(Snap7ServerFixture sim)
new("TC_Lone_I16", "DB1.DBW600", S7DataType.Int16),
new("TC_Lone_Mk", "MW10", S7DataType.UInt16),
// Timer tags are typed Float64 (decoded to seconds) and Counter tags Int32 (decoded to
// a count) — the init guard rejects any other typing. Writable:false is mandatory, not
// a raw word) — the init guard rejects any other typing. Writable:false is mandatory, not
// stylistic: S7TagDefinition defaults Writable to TRUE and the same guard rejects a
// writable Timer/Counter outright (they are read-only this phase).
new("TC_Timer", "T0", S7DataType.Float64, Writable: false),
@@ -1,6 +1,9 @@
using System.Diagnostics.CodeAnalysis;
using Akka.Actor;
using Akka.Cluster.Tools.PublishSubscribe;
using Akka.Event;
using Akka.TestKit;
using Akka.TestKit.Xunit2;
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
@@ -680,7 +683,7 @@ public class CentralCommunicationActorTests : ControlPlaneActorTestBase
/// <summary>Counts contact loads and fails the first <c>failFirst</c> of them.</summary>
private sealed class CountingDbFactory(
IDbContextFactory<OtOpcUaConfigDbContext> inner, int failFirst = 0)
IDbContextFactory<OtOpcUaConfigDbContext> inner, int failFirst = 0, Action<int>? onLoad = null)
: IDbContextFactory<OtOpcUaConfigDbContext>
{
private int _calls;
@@ -689,7 +692,9 @@ public class CentralCommunicationActorTests : ControlPlaneActorTestBase
public OtOpcUaConfigDbContext CreateDbContext()
{
if (Interlocked.Increment(ref _calls) <= failFirst)
var call = Interlocked.Increment(ref _calls);
onLoad?.Invoke(call);
if (call <= failFirst)
throw new InvalidOperationException("ConfigDb unreachable at boot");
return inner.CreateDbContext();
}
@@ -747,31 +752,51 @@ public class CentralCommunicationActorTests : ControlPlaneActorTestBase
[Fact]
public void Bootstrap_retries_stop_once_the_gate_is_published()
{
// The provider throws on PreStart (call 1) and on the first load's resolve (call 2), which
// schedules a 1 s retry. A manual refresh then publishes while that retry is still PENDING;
// stop-on-publish must cancel it. Without the cancel the timer fires, loads again and
// republishes (loads == 3, publications == 2).
// Freeze time only for this fact: the cluster harness and every other retry test retain
// their real scheduler. gRPC mode needs neither cluster membership nor pub-sub here.
using var local = new TestKit(@"akka {
loglevel = INFO
scheduler.implementation = ""Akka.TestKit.TestScheduler, Akka.TestKit""
}");
var scheduler = (TestScheduler)local.Sys.Scheduler;
var loads = local.CreateTestProbe();
var barrier = local.CreateTestProbe();
local.Sys.EventStream.Subscribe(barrier.Ref, typeof(UnhandledMessage));
var inner = NewInMemoryDbFactory();
SeedGrpcCluster(inner, "C1", ("host-a:4053", "host-a", 4056, true, false));
var factory = new CountingDbFactory(inner);
var factory = new CountingDbFactory(inner, onLoad: n => loads.Ref.Tell(n));
var psk = new RecordingPskProvider();
var calls = 0;
// PreStart and the first contact load fail resolution. The latter registers a retry.
IMeshPskProvider? PskFactory() =>
Interlocked.Increment(ref calls) <= 2 ? throw new InvalidOperationException("race") : psk;
var actor = Sys.ActorOf(CentralCommunicationActor.Props(
var actor = local.Sys.ActorOf(CentralCommunicationActor.Props(
factory, GrpcMode(), () => null, () => new RecordingDialer(), PskFactory,
bootstrapRetryInterval: TimeSpan.FromSeconds(1)));
// A mailbox barrier also waits for PreStart to register the zero-delay periodic tick.
var marker = new object();
actor.Tell(marker);
barrier.ExpectMsg<UnhandledMessage>(TimeSpan.FromSeconds(5)).Message.ShouldBeSameAs(marker);
scheduler.Advance(TimeSpan.Zero);
loads.ExpectMsg(1, TimeSpan.FromSeconds(5));
local.AwaitCondition(() => Volatile.Read(ref calls) == 2, TimeSpan.FromSeconds(5));
// Positive control: the failed first resolve happened, so a retry is pending.
AwaitCondition(() => Volatile.Read(ref calls) >= 2, TimeSpan.FromSeconds(5));
Thread.Sleep(100);
actor.Tell(new CentralCommunicationActor.RefreshContacts());
AwaitCondition(() => psk.KnownClusterPublications.Count >= 1, TimeSpan.FromSeconds(5));
// This mailbox marker is handled after the failed ContactsLoaded handler returns.
// Unlike observing the provider call alone, it proves retry registration is complete.
actor.Tell(marker);
barrier.ExpectMsg<UnhandledMessage>(TimeSpan.FromSeconds(5)).Message.ShouldBeSameAs(marker);
Thread.Sleep(2500);
local.EventFilter.Info(contains: "Known-cluster gate published").ExpectOne(
TimeSpan.FromSeconds(5), () => actor.Tell(new CentralCommunicationActor.RefreshContacts()));
loads.ExpectMsg(2, TimeSpan.FromSeconds(5));
// Cross the pending retry deadline, leaving the 60-second periodic tick untouched.
// LoadContactsFromDb uses Task.Run, so observe its boundary for the original absence
// budget rather than asserting before a mistakenly retained retry can start its read.
scheduler.Advance(TimeSpan.FromMilliseconds(2500));
loads.ExpectNoMsg(TimeSpan.FromMilliseconds(2500));
factory.Calls.ShouldBe(2);
psk.KnownClusterPublications.Count.ShouldBe(1);
}
@@ -41,10 +41,14 @@ public sealed class AlarmSfConvergenceTests
/// <summary>A writer that never acknowledges — a historian outage, which is when the buffer matters.</summary>
private sealed class OutageWriter : IAlarmHistorianWriter
{
public List<AlarmHistorianEvent> Events { get; } = [];
public Task<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(
IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<HistorianWriteOutcome>>(
IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken cancellationToken)
{
Events.AddRange(batch);
return Task.FromResult<IReadOnlyList<HistorianWriteOutcome>>(
batch.Select(_ => HistorianWriteOutcome.RetryPlease).ToList());
}
}
private static AlarmHistorianEvent Event(string alarmId, int second) => new(
@@ -170,11 +174,15 @@ public sealed class AlarmSfConvergenceTests
await pair.StartAsync();
var shared = Event("eq/shared", 1);
using var sinkA = new LocalDbStoreAndForwardSink(pair.A, new OutageWriter(), Log);
using var sinkB = new LocalDbStoreAndForwardSink(pair.B, new OutageWriter(), Log);
var sharedOnB = Event("eq/shared", 1);
shared.DeliveryId.ShouldNotBe(sharedOnB.DeliveryId);
var writerA = new OutageWriter();
var writerB = new OutageWriter();
using var sinkA = new LocalDbStoreAndForwardSink(pair.A, writerA, Log);
using var sinkB = new LocalDbStoreAndForwardSink(pair.B, writerB, Log);
await sinkA.EnqueueAsync(shared, TestContext.Current.CancellationToken);
await sinkB.EnqueueAsync(shared, TestContext.Current.CancellationToken);
await sinkB.EnqueueAsync(sharedOnB, TestContext.Current.CancellationToken);
// A second, DISTINCT event on B. Without it the scenario would pass with replication
// switched off entirely — each node would hold its own single copy and "count == 1 on
@@ -193,5 +201,13 @@ public sealed class AlarmSfConvergenceTests
async () => await CountAsync(pair.A) > 2 || await CountAsync(pair.B) > 2,
TimeSpan.FromSeconds(3)))
.ShouldBeFalse("the duplicate must stay collapsed");
// Either node can retry after promotion, with the same GUID despite independent input identities.
await sinkA.DrainOnceAsync(TestContext.Current.CancellationToken);
await sinkB.DrainOnceAsync(TestContext.Current.CancellationToken);
var deliveryA = writerA.Events.Single(e => e.AlarmId == "eq/shared").DeliveryId;
var deliveryB = writerB.Events.Single(e => e.AlarmId == "eq/shared").DeliveryId;
deliveryA.ShouldNotBe(Guid.Empty);
deliveryB.ShouldBe(deliveryA);
}
}
@@ -36,6 +36,38 @@ public sealed class AlarmSfLegacyMigratorTests : IDisposable
private ServiceProvider? _provider;
private sealed class CapturingRetryWriter : IAlarmHistorianWriter
{
public List<AlarmHistorianEvent> Events { get; } = [];
public Task<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(
IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken cancellationToken)
{
Events.AddRange(batch);
return Task.FromResult<IReadOnlyList<HistorianWriteOutcome>>(batch.Select(_ => HistorianWriteOutcome.RetryPlease).ToArray());
}
}
[Fact]
public async Task A_legacy_payload_keeps_its_key_and_stable_delivery_identity_across_drains()
{
var payload = Payload("eq/a");
SeedLegacy((1, "eq/a", payload, 0, dead: 0));
var db = BuildDb();
AlarmSfLegacyMigrator.Migrate(db, ConfigWithLegacyPath());
var row = (await db.QueryAsync("SELECT id, payload_json FROM alarm_sf_events",
r => (Id: r.GetString(0), Payload: r.GetString(1)), null, TestContext.Current.CancellationToken)).Single();
row.Id.ShouldBe("B1D80B7207C3F3B6CB31105DBF1A6E0D6FE300DA1B3F877AB746B36535310A60");
row.Payload.ShouldBe(payload);
var writer = new CapturingRetryWriter();
using var log = new Serilog.LoggerConfiguration().CreateLogger();
using (var sink = new LocalDbStoreAndForwardSink(db, writer, log))
await sink.DrainOnceAsync(TestContext.Current.CancellationToken);
using (var sink = new LocalDbStoreAndForwardSink(db, writer, log))
await sink.DrainOnceAsync(TestContext.Current.CancellationToken);
writer.Events.Count.ShouldBe(2);
writer.Events.ShouldAllBe(evt => evt.DeliveryId == Guid.Parse("55372883-eaec-8dd7-a1a4-6f98f416a272"));
}
// ---- the cases ---------------------------------------------------------------------------
[Fact]
@@ -13,8 +13,8 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// comms-loss watchdog trips on). Recovery is the watchdog's sequence, replayed here as <c>DriverInstanceActor</c>
/// runs it: ShutdownAsync, InitializeAsync, re-subscribe — after which the value comes back Good. There is no
/// in-driver reconnect any more (#670: the SDK reconnect handler kept everything Good and Healthy forever).
/// Since #676 a gracefully stopped server is a DEFINITIVE loss (<c>BadServerHalted</c>), so the driver also raises
/// <c>ConnectionLost</c> and the host runs that sequence at once instead of after the watchdog's silence window.
/// Since #676 definitive session loss also raises <c>ConnectionLost</c>, so the host runs that sequence at once
/// instead of after the watchdog's silence window. Shutdown timing determines the SDK's loss status.
/// </summary>
[Trait("Category", "Integration")]
public sealed class OpcUaClientDriverReconnectWireTests
@@ -72,12 +72,22 @@ public sealed class OpcUaClientDriverReconnectWireTests
await Task.Delay(TimeSpan.FromSeconds(3), ct); // three keep-alive intervals
drv.GetHealth().LastSuccessfulRead.ShouldBe(frozen);
// #676: a server that HALTED (graceful stop) is a definitive loss — measured here, the SDK reports
// BadServerHalted on every keep-alive and never re-opens the channel, so the session can never recover in
// place. The driver says so at once, so the host re-initialises it instead of waiting out the ~90 s watchdog.
// #676/#702: shutdown can report a halted server, an invalid/closed session, or a sustained closed
// channel. Each proves definitive loss; #702 reproduced BadSessionIdInvalid during this graceful stop.
// Deterministic ConnectionLossSignal tests cover the individual statuses and the sustained-channel gate.
await WaitAsync(() => { lock (lost) return lost.Count > 0; },
"a halted upstream must raise ConnectionLost", TimeSpan.FromSeconds(10));
lock (lost) lost.Single().ShouldContain("BadServerHalted");
lock (lost)
{
var reason = lost.Single();
string[] definitiveStatuses =
[
"BadSessionIdInvalid", "BadSessionClosed", "BadSessionNotActivated", "BadServerHalted",
"BadSecureChannelClosed", "BadConnectionClosed",
];
definitiveStatuses.ShouldContain(status => reason.EndsWith(status, StringComparison.Ordinal),
$"the restart must report definitive loss, but reported: {reason}");
}
});
// The restarted server knows nothing of the old session, so the driver stays down on its own …